From b3c321be67bf2165aedf0a2f3f1bca3cffa2247b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 14:18:41 +0800 Subject: [PATCH 01/61] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(export):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=A4=A7=E7=BB=93=E6=9E=9C=E9=9B=86=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=93=BE=E8=B7=AF=E5=B9=B6=E6=94=AF=E6=8C=81=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExportFileOptions 统一承载导出格式、进度任务和 XLSX sheet 行数上限 - 查询导出改为流式写入文件,避免一次性缓存整批结果导致高内存占用 - 增加值数组快速路径并复用扫描与写入缓冲,减少逐行 map 分配开销 - 为 ClickHouse、自定义驱动、达梦、SQLServer 和 TDengine 补齐 StreamQuery 支持 - 导出时间字符串仅在形似时间时再解析,避免普通文本被误判改写 - 补充 XLSX 分 sheet、流式导出和基准测试覆盖 --- internal/app/export_options.go | 50 + internal/app/methods_file.go | 1094 +++++++++++++++++++--- internal/app/methods_file_export_test.go | 384 +++++++- internal/db/clickhouse_impl.go | 16 + internal/db/custom_impl.go | 18 + internal/db/dameng_impl.go | 18 + internal/db/database.go | 72 ++ internal/db/scan_rows.go | 121 ++- internal/db/sqlserver_impl.go | 17 + internal/db/tdengine_impl.go | 18 + 10 files changed, 1637 insertions(+), 171 deletions(-) create mode 100644 internal/app/export_options.go diff --git a/internal/app/export_options.go b/internal/app/export_options.go new file mode 100644 index 0000000..963b639 --- /dev/null +++ b/internal/app/export_options.go @@ -0,0 +1,50 @@ +package app + +import "strings" + +const ( + maxXLSXRowsPerSheet = 1048575 + defaultXLSXRowsPerSheet = maxXLSXRowsPerSheet +) + +type ExportFileOptions struct { + Format string `json:"format"` + XLSXMaxRowsPerSheet int `json:"xlsxMaxRowsPerSheet,omitempty"` + JobID string `json:"jobId,omitempty"` + TotalRowsHint int64 `json:"totalRowsHint,omitempty"` + TotalRowsKnown bool `json:"totalRowsKnown,omitempty"` +} + +func normalizeExportFileOptions(format string, options ExportFileOptions) ExportFileOptions { + resolvedFormat := strings.ToLower(strings.TrimSpace(format)) + if explicitFormat := strings.ToLower(strings.TrimSpace(options.Format)); explicitFormat != "" { + resolvedFormat = explicitFormat + } + return ExportFileOptions{ + Format: resolvedFormat, + XLSXMaxRowsPerSheet: normalizeXLSXRowsPerSheet(options.XLSXMaxRowsPerSheet), + JobID: strings.TrimSpace(options.JobID), + TotalRowsHint: normalizeExportTotalRowsHint(options.TotalRowsHint, options.TotalRowsKnown), + TotalRowsKnown: options.TotalRowsKnown, + } +} + +func normalizeXLSXRowsPerSheet(value int) int { + if value <= 0 { + return defaultXLSXRowsPerSheet + } + if value > maxXLSXRowsPerSheet { + return maxXLSXRowsPerSheet + } + return value +} + +func normalizeExportTotalRowsHint(value int64, known bool) int64 { + if !known { + return 0 + } + if value < 0 { + return 0 + } + return value +} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index c8390ef..3a78af6 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -36,6 +36,9 @@ const sqlFileBatchMaxStatements = 1000 const sqlFileBatchMaxBytes = 4 * 1024 * 1024 const sqlFileProgressStatementInterval = 100 const sqlFileProgressTimeInterval = time.Second +const exportProgressEvent = "export:progress" +const exportProgressRowInterval int64 = 1000 +const exportProgressTimeInterval = 500 * time.Millisecond const defaultAppLogTailLineLimit = 80 const maxAppLogTailLineLimit = 200 const appLogTailReadWindowBytes int64 = 256 * 1024 @@ -89,6 +92,31 @@ type SQLDirectoryEntry struct { Children []SQLDirectoryEntry `json:"children,omitempty"` } +type exportProgressPayload struct { + JobID string `json:"jobId"` + Status string `json:"status"` + Stage string `json:"stage"` + Current int64 `json:"current"` + Total int64 `json:"total,omitempty"` + TotalRowsKnown bool `json:"totalRowsKnown,omitempty"` + Format string `json:"format,omitempty"` + TargetName string `json:"targetName,omitempty"` + FilePath string `json:"filePath,omitempty"` + Message string `json:"message,omitempty"` +} + +type exportProgressReporter struct { + app *App + jobID string + format string + targetName string + filePath string + totalRows int64 + totalRowsKnown bool + lastRows int64 + lastEmittedAt time.Time +} + type appLogTailSnapshot struct { LogPath string `json:"logPath"` Keyword string `json:"keyword,omitempty"` @@ -125,6 +153,73 @@ func normalizeSQLDirectoryName(rawName string) (string, error) { return name, nil } +func newExportProgressReporter(a *App, options ExportFileOptions, targetName string, filePath string) *exportProgressReporter { + jobID := strings.TrimSpace(options.JobID) + if a == nil || a.ctx == nil || jobID == "" { + return nil + } + return &exportProgressReporter{ + app: a, + jobID: jobID, + format: strings.ToLower(strings.TrimSpace(options.Format)), + targetName: strings.TrimSpace(targetName), + filePath: strings.TrimSpace(filePath), + totalRows: normalizeExportTotalRowsHint(options.TotalRowsHint, options.TotalRowsKnown), + totalRowsKnown: options.TotalRowsKnown, + } +} + +func (r *exportProgressReporter) emit(status string, stage string, current int64, message string, force bool) { + if r == nil || r.app == nil || r.app.ctx == nil || r.jobID == "" { + return + } + now := time.Now() + if !force && status == "running" { + if current-r.lastRows < exportProgressRowInterval && (!r.lastEmittedAt.IsZero() && now.Sub(r.lastEmittedAt) < exportProgressTimeInterval) { + return + } + } + payload := exportProgressPayload{ + JobID: r.jobID, + Status: strings.TrimSpace(status), + Stage: strings.TrimSpace(stage), + Current: current, + Total: r.totalRows, + TotalRowsKnown: r.totalRowsKnown, + Format: r.format, + TargetName: r.targetName, + FilePath: r.filePath, + Message: strings.TrimSpace(message), + } + runtime.EventsEmit(r.app.ctx, exportProgressEvent, payload) + r.lastRows = current + r.lastEmittedAt = now +} + +func (r *exportProgressReporter) Start(stage string) { + r.emit("start", stage, 0, "", true) +} + +func (r *exportProgressReporter) Rows(current int64, stage string) { + r.emit("running", stage, current, "", false) +} + +func (r *exportProgressReporter) ForceRunning(current int64, stage string) { + r.emit("running", stage, current, "", true) +} + +func (r *exportProgressReporter) Finalizing(current int64) { + r.emit("finalizing", "正在完成文件写入", current, "", true) +} + +func (r *exportProgressReporter) Done(current int64) { + r.emit("done", "导出完成", current, "", true) +} + +func (r *exportProgressReporter) Error(current int64, message string) { + r.emit("error", "导出失败", current, message, true) +} + func normalizeSQLDirectoryPath(directoryPath string) (string, error) { target := strings.TrimSpace(directoryPath) if target == "" { @@ -1730,6 +1825,55 @@ func parseTemporalString(raw string) (time.Time, bool) { return time.Time{}, false } +func looksLikeTemporalText(raw string) bool { + text := strings.TrimSpace(raw) + if text == "" { + return false + } + + if len(text) >= 10 && + isDigit(text[0]) && + isDigit(text[1]) && + isDigit(text[2]) && + isDigit(text[3]) && + text[4] == '-' && + isDigit(text[5]) && + isDigit(text[6]) && + text[7] == '-' && + isDigit(text[8]) && + isDigit(text[9]) { + return true + } + + if len(text) >= 8 && + isDigit(text[0]) && + isDigit(text[1]) && + text[2] == ':' && + isDigit(text[3]) && + isDigit(text[4]) && + text[5] == ':' && + isDigit(text[6]) && + isDigit(text[7]) { + return true + } + + return false +} + +func isDigit(ch byte) bool { + return ch >= '0' && ch <= '9' +} + +func normalizeExportTemporalText(text string) string { + if !looksLikeTemporalText(text) { + return text + } + if parsed, ok := parseTemporalString(text); ok { + return parsed.Format("2006-01-02 15:04:05") + } + return text +} + func normalizeImportTemporalValue(dbType, columnType, raw string) string { text := strings.TrimSpace(raw) if text == "" { @@ -2019,6 +2163,12 @@ func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableNa } func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult { + return a.ExportTableWithOptions(config, dbName, tableName, ExportFileOptions{Format: format}) +} + +func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName string, tableName string, options ExportFileOptions) connection.QueryResult { + options = normalizeExportFileOptions("", options) + format := options.Format filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: fmt.Sprintf("Export %s", tableName), DefaultFilename: fmt.Sprintf("%s.%s", tableName, format), @@ -2028,17 +2178,21 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab return connection.QueryResult{Success: false, Message: "已取消"} } + reporter := newExportProgressReporter(a, options, tableName, filename) + reporter.Start("正在准备导出") runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } - format = strings.ToLower(format) if format == "sql" { + reporter.Start("正在导出 SQL 文件") f, err := os.Create(filename) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } defer f.Close() @@ -2047,35 +2201,39 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab defer w.Flush() if err := writeSQLHeader(w, runConfig, dbName); err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } viewLookup := listViewNameLookup(dbInst, runConfig, dbName) if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } if err := writeSQLFooter(w, runConfig); err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } + reporter.Finalizing(0) + reporter.Done(0) return connection.QueryResult{Success: true, Message: "导出完成"} } dbType := resolveDDLDBType(config) query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(dbType, tableName)) - data, columns, err := queryDataForExport(dbInst, runConfig, query) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - f, err := os.Create(filename) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } defer f.Close() - if err := writeRowsToFile(f, data, columns, format); err != nil { + rowCount, _, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter) + if err != nil { + reporter.Error(rowCount, "写入失败:"+err.Error()) return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()} } + reporter.Done(rowCount) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -3181,45 +3339,44 @@ func dumpTableSQL( qualified := qualifyTable(schemaName, pureTableName) dbType := resolveDDLDBType(config) selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(dbType, qualified)) - data, columns, err := queryDataForExport(dbInst, config, selectSQL) - if err != nil { - return err - } columnTypeMap := map[string]string{} if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil { columnTypeMap = buildImportColumnTypeMap(defs) } - if len(data) == 0 { + insertConsumer := &sqlInsertExportConsumer{ + w: w, + dbType: dbType, + quotedTable: quoteQualifiedIdentByType(dbType, qualified), + columnTypeMap: columnTypeMap, + } + if err := streamQueryDataForExport(dbInst, config, selectSQL, insertConsumer); err != nil { + return err + } + if insertConsumer.rowCount == 0 { if _, err := w.WriteString("-- (0 rows)\n"); err != nil { return err } return nil } - quotedCols := make([]string, 0, len(columns)) - for _, c := range columns { - quotedCols = append(quotedCols, quoteIdentByType(dbType, c)) - } - quotedTable := quoteQualifiedIdentByType(dbType, qualified) - - for _, row := range data { - values := make([]string, 0, len(columns)) - for _, c := range columns { - values = append(values, formatImportSQLValue(dbType, columnTypeMap[normalizeColumnName(c)], row[c])) - } - if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil { - return err - } - } - return nil } // ExportData exports provided data to a file func (a *App) ExportData(data []map[string]interface{}, columns []string, defaultName string, format string) connection.QueryResult { + return a.ExportDataWithOptions(data, columns, defaultName, ExportFileOptions{Format: format}) +} + +func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []string, defaultName string, options ExportFileOptions) connection.QueryResult { if defaultName == "" { defaultName = "export" } + options = normalizeExportFileOptions("", options) + if !options.TotalRowsKnown { + options.TotalRowsKnown = true + options.TotalRowsHint = int64(len(data)) + } + format := options.Format logger.Infof("ExportData 开始:rows=%d cols=%d format=%s defaultName=%s", len(data), len(columns), strings.ToLower(strings.TrimSpace(format)), strings.TrimSpace(defaultName)) filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Export Data", @@ -3231,24 +3388,34 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul return connection.QueryResult{Success: false, Message: "已取消"} } logger.Infof("ExportData 选定文件:%s", filename) + reporter := newExportProgressReporter(a, options, defaultName, filename) + reporter.Start("正在准备导出") f, err := os.Create(filename) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } defer f.Close() - if err := writeRowsToFile(f, data, columns, format); err != nil { + writtenRows, err := writeRowsToFileWithReporter(f, data, columns, options, reporter) + if err != nil { logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err) + reporter.Error(writtenRows, "写入失败:"+err.Error()) return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()} } logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data)) + reporter.Done(writtenRows) return connection.QueryResult{Success: true, Message: "导出完成"} } // ExportQuery exports by executing the provided SELECT query on backend side. // This avoids frontend IPC payload limits when exporting very large/long-text columns (e.g. base64). func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, query string, defaultName string, format string) connection.QueryResult { + return a.ExportQueryWithOptions(config, dbName, query, defaultName, ExportFileOptions{Format: format}) +} + +func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName string, query string, defaultName string, options ExportFileOptions) connection.QueryResult { query = strings.TrimSpace(query) if query == "" { return connection.QueryResult{Success: false, Message: "查询语句不能为空"} @@ -3257,6 +3424,8 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que if defaultName == "" { defaultName = "export" } + options = normalizeExportFileOptions("", options) + format := options.Format filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Export Query Result", @@ -3267,36 +3436,38 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que return connection.QueryResult{Success: false, Message: "已取消"} } logger.Infof("ExportQuery 开始:type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query)) + reporter := newExportProgressReporter(a, options, defaultName, filename) + reporter.Start("正在准备导出") runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } query = sanitizeSQLForPgLike(resolveDDLDBType(config), query) if !looksLikeSelectOrWith(query) { + reporter.Error(0, "仅支持 SELECT/WITH 查询导出") return connection.QueryResult{Success: false, Message: "仅支持 SELECT/WITH 查询导出"} } - data, columns, err := queryDataForExport(dbInst, runConfig, query) - if err != nil { - logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query)) - return connection.QueryResult{Success: false, Message: err.Error()} - } - f, err := os.Create(filename) if err != nil { + reporter.Error(0, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } defer f.Close() - if err := writeRowsToFile(f, data, columns, format); err != nil { - logger.Warnf("ExportQuery 写入失败:file=%s err=%v", filename, err) - return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()} + rowCount, columns, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter) + if err != nil { + logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query)) + reporter.Error(rowCount, err.Error()) + return connection.QueryResult{Success: false, Message: err.Error()} } - logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, len(data), len(columns)) + logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, rowCount, len(columns)) + reporter.Done(rowCount) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -3341,130 +3512,756 @@ func getExportQueryTimeout(config connection.ConnectionConfig) time.Duration { return timeout } -func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string, format string) error { - format = strings.ToLower(strings.TrimSpace(format)) - if f == nil { - return fmt.Errorf("file required") - } +type exportFileWriter interface { + db.QueryStreamConsumer + Close() error +} - // xlsx 使用 excelize 写入真正的 Excel 格式 - if format == "xlsx" { - return writeRowsToXlsx(f.Name(), data, columns) - } +type exportValueStreamConsumer interface { + ConsumeRowValues(values []interface{}) error +} - // html 使用内嵌 CSS 输出可直接浏览器预览的独立页面 - if format == "html" { - return writeRowsToHTML(f, data, columns) - } +type countingExportConsumer struct { + delegate db.QueryStreamConsumer + columns []string + rowCount int64 + reporter *exportProgressReporter +} - // 如果列名为空但数据不为空,从所有数据行提取所有键 - if len(columns) == 0 && len(data) > 0 { - keySet := make(map[string]bool) - for _, row := range data { - for key := range row { - keySet[key] = true - } - } - // 排序以确保输出一致 - for key := range keySet { - columns = append(columns, key) - } - sort.Strings(columns) - } - - var csvWriter *csv.Writer - var jsonEncoder *json.Encoder - isJsonFirstRow := true - - switch format { - case "csv": - if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil { +func (c *countingExportConsumer) SetColumns(columns []string) error { + c.columns = append([]string(nil), columns...) + if c.delegate != nil { + if err := c.delegate.SetColumns(columns); err != nil { return err } - csvWriter = csv.NewWriter(f) - if err := csvWriter.Write(columns); err != nil { - return err - } - case "json": - if _, err := f.WriteString("[\n"); err != nil { - return err - } - jsonEncoder = json.NewEncoder(f) - jsonEncoder.SetIndent(" ", " ") - case "md": - if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | ")); err != nil { - return err - } - seps := make([]string, len(columns)) - for i := range seps { - seps[i] = "---" - } - if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | ")); err != nil { - return err - } - default: - return fmt.Errorf("unsupported format: %s", format) } + if c.reporter != nil { + c.reporter.ForceRunning(c.rowCount, "正在写入文件") + } + return nil +} - for _, rowMap := range data { - record := make([]string, len(columns)) - for i, col := range columns { - val := rowMap[col] - if val == nil { - record[i] = "NULL" - continue - } - - s := formatExportCellText(val) - if format == "md" { - s = strings.ReplaceAll(s, "|", "\\|") - s = strings.ReplaceAll(s, "\n", "
") - } - record[i] = s +func (c *countingExportConsumer) ConsumeRow(row map[string]interface{}) error { + if c.delegate != nil { + if err := c.delegate.ConsumeRow(row); err != nil { + return err } + } + c.rowCount++ + if c.reporter != nil { + c.reporter.Rows(c.rowCount, "正在写入文件") + } + return nil +} - switch format { - case "csv": - if err := csvWriter.Write(record); err != nil { +func (c *countingExportConsumer) ConsumeRowValues(values []interface{}) error { + if c.delegate != nil { + if valueConsumer, ok := c.delegate.(exportValueStreamConsumer); ok { + if err := valueConsumer.ConsumeRowValues(values); err != nil { return err } - case "json": - if !isJsonFirstRow { - if _, err := f.WriteString(",\n"); err != nil { - return err + } else { + row := make(map[string]interface{}, len(c.columns)) + for i, column := range c.columns { + if i < len(values) { + row[column] = values[i] + } else { + row[column] = nil } } - exportedRow := make(map[string]interface{}, len(columns)) - for _, col := range columns { - exportedRow[col] = normalizeExportJSONValue(rowMap[col]) - } - if err := jsonEncoder.Encode(exportedRow); err != nil { - return err - } - isJsonFirstRow = false - case "md": - if _, err := fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | ")); err != nil { + if err := c.delegate.ConsumeRow(row); err != nil { return err } } } - - if format == "csv" { - csvWriter.Flush() - if err := csvWriter.Error(); err != nil { - return err - } + c.rowCount++ + if c.reporter != nil { + c.reporter.Rows(c.rowCount, "正在写入文件") } - - if format == "json" { - if _, err := f.WriteString("\n]"); err != nil { - return err - } - } - return nil } +type csvExportFileWriter struct { + writer *csv.Writer + columns []string + record []string +} + +func newCSVExportFileWriter(f *os.File) (*csvExportFileWriter, error) { + if _, err := f.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil { + return nil, err + } + return &csvExportFileWriter{writer: csv.NewWriter(f)}, nil +} + +func (w *csvExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + w.record = make([]string, len(columns)) + return w.writer.Write(columns) +} + +func (w *csvExportFileWriter) ConsumeRow(row map[string]interface{}) error { + return w.writer.Write(fillExportRecordFromRow(w.record, row, w.columns, false)) +} + +func (w *csvExportFileWriter) ConsumeRowValues(values []interface{}) error { + return w.writer.Write(fillExportRecordFromValues(w.record, values, false)) +} + +func (w *csvExportFileWriter) Close() error { + w.writer.Flush() + return w.writer.Error() +} + +type jsonExportFileWriter struct { + file *os.File + encoder *json.Encoder + columns []string + rowBuf map[string]interface{} + first bool +} + +func newJSONExportFileWriter(f *os.File) (*jsonExportFileWriter, error) { + if _, err := f.WriteString("[\n"); err != nil { + return nil, err + } + encoder := json.NewEncoder(f) + encoder.SetIndent(" ", " ") + return &jsonExportFileWriter{file: f, encoder: encoder, first: true}, nil +} + +func (w *jsonExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + w.rowBuf = make(map[string]interface{}, len(columns)) + return nil +} + +func (w *jsonExportFileWriter) ConsumeRow(row map[string]interface{}) error { + for _, col := range w.columns { + w.rowBuf[col] = normalizeExportJSONValue(row[col]) + } + return w.writeCurrentRow() +} + +func (w *jsonExportFileWriter) ConsumeRowValues(values []interface{}) error { + for i, col := range w.columns { + if i < len(values) { + w.rowBuf[col] = normalizeExportJSONValue(values[i]) + } else { + w.rowBuf[col] = nil + } + } + return w.writeCurrentRow() +} + +func (w *jsonExportFileWriter) writeCurrentRow() error { + if !w.first { + if _, err := w.file.WriteString(",\n"); err != nil { + return err + } + } + if err := w.encoder.Encode(w.rowBuf); err != nil { + return err + } + w.first = false + return nil +} + +func (w *jsonExportFileWriter) Close() error { + _, err := w.file.WriteString("\n]") + return err +} + +type markdownExportFileWriter struct { + file *os.File + columns []string + record []string +} + +func (w *markdownExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + w.record = make([]string, len(columns)) + if _, err := fmt.Fprintf(w.file, "| %s |\n", strings.Join(columns, " | ")); err != nil { + return err + } + seps := make([]string, len(columns)) + for i := range seps { + seps[i] = "---" + } + _, err := fmt.Fprintf(w.file, "| %s |\n", strings.Join(seps, " | ")) + return err +} + +func (w *markdownExportFileWriter) ConsumeRow(row map[string]interface{}) error { + _, err := fmt.Fprintf(w.file, "| %s |\n", strings.Join(fillExportRecordFromRow(w.record, row, w.columns, true), " | ")) + return err +} + +func (w *markdownExportFileWriter) ConsumeRowValues(values []interface{}) error { + _, err := fmt.Fprintf(w.file, "| %s |\n", strings.Join(fillExportRecordFromValues(w.record, values, true), " | ")) + return err +} + +func (w *markdownExportFileWriter) Close() error { + return nil +} + +type htmlExportFileWriter struct { + writer *bufio.Writer + columns []string + rowCount int64 +} + +func newHTMLExportFileWriter(f *os.File) *htmlExportFileWriter { + return &htmlExportFileWriter{writer: bufio.NewWriterSize(f, 1024*256)} +} + +func (w *htmlExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + if _, err := w.writer.WriteString(` + + + + + GoNavi Export + + + +
+
+

GoNavi Data Export

+
`); err != nil { + return err + } + + if _, err := fmt.Fprintf(w.writer, "Columns: %d · Generated: %s", len(columns), time.Now().Format("2006-01-02 15:04:05")); err != nil { + return err + } + + if _, err := w.writer.WriteString(`
+
+
+ + `); err != nil { + return err + } + + for _, col := range columns { + if _, err := fmt.Fprintf(w.writer, "", html.EscapeString(col)); err != nil { + return err + } + } + + _, err := w.writer.WriteString(``) + return err +} + +func (w *htmlExportFileWriter) ConsumeRow(row map[string]interface{}) error { + if _, err := w.writer.WriteString(""); err != nil { + return err + } + for _, col := range w.columns { + if _, err := fmt.Fprintf(w.writer, "", formatExportHTMLCell(row[col])); err != nil { + return err + } + } + if _, err := w.writer.WriteString(""); err != nil { + return err + } + w.rowCount++ + return nil +} + +func (w *htmlExportFileWriter) ConsumeRowValues(values []interface{}) error { + if _, err := w.writer.WriteString(""); err != nil { + return err + } + for i := range w.columns { + var value interface{} + if i < len(values) { + value = values[i] + } + if _, err := fmt.Fprintf(w.writer, "", formatExportHTMLCell(value)); err != nil { + return err + } + } + if _, err := w.writer.WriteString(""); err != nil { + return err + } + w.rowCount++ + return nil +} + +func (w *htmlExportFileWriter) Close() error { + if w.rowCount == 0 { + colspan := len(w.columns) + if colspan <= 0 { + colspan = 1 + } + if _, err := fmt.Fprintf(w.writer, ``, colspan); err != nil { + return err + } + } + if _, err := w.writer.WriteString(`
%s
%s
%s
(0 rows)
+
+
+ +`); err != nil { + return err + } + return w.writer.Flush() +} + +type xlsxExportFileWriter struct { + filename string + workbook *excelize.File + stream *excelize.StreamWriter + sheet string + columns []string + header []interface{} + rowBuf []interface{} + nextRow int + sheetNo int + rowCount int + maxRows int +} + +func newXLSXExportFileWriter(filename string, maxRowsPerSheet int) (*xlsxExportFileWriter, error) { + workbook := excelize.NewFile() + sheet := workbook.GetSheetName(workbook.GetActiveSheetIndex()) + stream, err := workbook.NewStreamWriter(sheet) + if err != nil { + _ = workbook.Close() + return nil, err + } + return &xlsxExportFileWriter{ + filename: filename, + workbook: workbook, + stream: stream, + sheet: sheet, + sheetNo: 1, + nextRow: 2, + maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet), + }, nil +} + +func (w *xlsxExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + w.rowCount = 0 + w.nextRow = 2 + w.header = make([]interface{}, len(columns)) + w.rowBuf = make([]interface{}, len(columns)) + for i, col := range columns { + w.header[i] = col + } + return w.stream.SetRow("A1", w.header) +} + +func (w *xlsxExportFileWriter) rotateSheet() error { + if err := w.stream.Flush(); err != nil { + return err + } + w.sheetNo++ + w.sheet = fmt.Sprintf("Sheet%d", w.sheetNo) + if _, err := w.workbook.NewSheet(w.sheet); err != nil { + return err + } + stream, err := w.workbook.NewStreamWriter(w.sheet) + if err != nil { + return err + } + w.stream = stream + w.rowCount = 0 + w.nextRow = 2 + return w.stream.SetRow("A1", w.header) +} + +func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error { + if w.rowCount >= w.maxRows { + if err := w.rotateSheet(); err != nil { + return err + } + } + values := w.rowBuf + for i, col := range w.columns { + val := row[col] + if val == nil { + values[i] = "NULL" + continue + } + values[i] = formatExportCellText(val) + } + cell := "A" + strconv.Itoa(w.nextRow) + w.nextRow++ + w.rowCount++ + return w.stream.SetRow(cell, values) +} + +func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error { + if w.rowCount >= w.maxRows { + if err := w.rotateSheet(); err != nil { + return err + } + } + rowBuf := w.rowBuf + for i := range w.columns { + var value interface{} + if i < len(values) { + value = values[i] + } + if value == nil { + rowBuf[i] = "NULL" + continue + } + rowBuf[i] = formatExportCellText(value) + } + cell := "A" + strconv.Itoa(w.nextRow) + w.nextRow++ + w.rowCount++ + return w.stream.SetRow(cell, rowBuf) +} + +func (w *xlsxExportFileWriter) Close() error { + if err := w.stream.Flush(); err != nil { + _ = w.workbook.Close() + return err + } + saveErr := w.workbook.SaveAs(w.filename) + closeErr := w.workbook.Close() + if saveErr != nil { + return saveErr + } + return closeErr +} + +type sqlInsertExportConsumer struct { + w *bufio.Writer + dbType string + quotedTable string + columnTypeMap map[string]string + columns []string + quotedCols []string + columnTypes []string + valueBuf []string + rowCount int64 +} + +func (c *sqlInsertExportConsumer) SetColumns(columns []string) error { + c.columns = append([]string(nil), columns...) + c.quotedCols = make([]string, 0, len(columns)) + c.columnTypes = make([]string, len(columns)) + c.valueBuf = make([]string, len(columns)) + for _, column := range columns { + c.quotedCols = append(c.quotedCols, quoteIdentByType(c.dbType, column)) + } + for i, column := range columns { + c.columnTypes[i] = c.columnTypeMap[normalizeColumnName(column)] + } + return nil +} + +func (c *sqlInsertExportConsumer) ConsumeRow(row map[string]interface{}) error { + values := make([]string, 0, len(c.columns)) + for _, column := range c.columns { + values = append(values, formatImportSQLValue(c.dbType, c.columnTypeMap[normalizeColumnName(column)], row[column])) + } + if _, err := c.w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", c.quotedTable, strings.Join(c.quotedCols, ", "), strings.Join(values, ", "))); err != nil { + return err + } + c.rowCount++ + return nil +} + +func (c *sqlInsertExportConsumer) ConsumeRowValues(values []interface{}) error { + for i := range c.columns { + var value interface{} + if i < len(values) { + value = values[i] + } + c.valueBuf[i] = formatImportSQLValue(c.dbType, c.columnTypes[i], value) + } + if _, err := c.w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", c.quotedTable, strings.Join(c.quotedCols, ", "), strings.Join(c.valueBuf, ", "))); err != nil { + return err + } + c.rowCount++ + return nil +} + +func resolveExportColumns(columns []string, data []map[string]interface{}) []string { + if len(columns) > 0 || len(data) == 0 { + return columns + } + keySet := make(map[string]bool) + for _, row := range data { + for key := range row { + keySet[key] = true + } + } + derived := make([]string, 0, len(keySet)) + for key := range keySet { + derived = append(derived, key) + } + sort.Strings(derived) + return derived +} + +func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWriter, error) { + options = normalizeExportFileOptions("", options) + switch options.Format { + case "csv": + return newCSVExportFileWriter(f) + case "json": + return newJSONExportFileWriter(f) + case "md": + return &markdownExportFileWriter{file: f}, nil + case "html": + return newHTMLExportFileWriter(f), nil + case "xlsx": + filename := f.Name() + if err := f.Close(); err != nil { + return nil, err + } + return newXLSXExportFileWriter(filename, options.XLSXMaxRowsPerSheet) + default: + return nil, fmt.Errorf("unsupported format: %s", options.Format) + } +} + +func streamQueryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string, consumer db.QueryStreamConsumer) error { + if consumer == nil { + return fmt.Errorf("export consumer required") + } + + timeout := getExportQueryTimeout(config) + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + + if streamer, ok := dbInst.(db.StreamQueryExecer); ok { + return streamer.StreamQueryContext(ctx, query, consumer) + } + + if provider, ok := dbInst.(db.SessionExecerProvider); ok { + session, err := provider.OpenSessionExecer(ctx) + if err != nil { + logger.Warnf("导出流式会话打开失败,回退到缓冲导出:type=%s err=%v", strings.TrimSpace(config.Type), err) + } else { + defer session.Close() + if streamer, ok := session.(db.StreamQueryExecer); ok { + return streamer.StreamQueryContext(ctx, query, consumer) + } + } + } + + logger.Warnf("导出流式查询不可用,回退到缓冲导出:type=%s", strings.TrimSpace(config.Type)) + data, columns, err := queryDataForExport(dbInst, config, query) + if err != nil { + return err + } + columns = resolveExportColumns(columns, data) + if err := consumer.SetColumns(columns); err != nil { + return err + } + for _, row := range data { + if err := consumer.ConsumeRow(row); err != nil { + return err + } + } + return nil +} + +func exportQueryResultToFile(f *os.File, dbInst db.Database, config connection.ConnectionConfig, query string, options ExportFileOptions, reporter *exportProgressReporter) (int64, []string, error) { + writer, err := newExportFileWriter(f, options) + if err != nil { + return 0, nil, err + } + + if reporter != nil { + reporter.Start("正在查询数据") + } + consumer := &countingExportConsumer{delegate: writer, reporter: reporter} + streamErr := streamQueryDataForExport(dbInst, config, query, consumer) + if reporter != nil && streamErr == nil { + reporter.Finalizing(consumer.rowCount) + } + closeErr := writer.Close() + if streamErr != nil { + return consumer.rowCount, consumer.columns, streamErr + } + if closeErr != nil { + return consumer.rowCount, consumer.columns, closeErr + } + return consumer.rowCount, consumer.columns, nil +} + +func fillExportRecordFromValues(record []string, values []interface{}, markdown bool) []string { + if len(record) != len(values) { + record = make([]string, len(values)) + } + for i, val := range values { + record[i] = formatExportRecordValue(val, markdown) + } + return record +} + +func fillExportRecordFromRow(record []string, row map[string]interface{}, columns []string, markdown bool) []string { + if len(record) != len(columns) { + record = make([]string, len(columns)) + } + for i, col := range columns { + record[i] = formatExportRecordValue(row[col], markdown) + } + return record +} + +func formatExportRecordValue(val interface{}, markdown bool) string { + if val == nil { + return "NULL" + } + text := formatExportCellText(val) + if markdown { + text = strings.ReplaceAll(text, "|", "\\|") + text = strings.ReplaceAll(text, "\n", "
") + } + return text +} + +func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string, options ExportFileOptions) error { + _, err := writeRowsToFileWithReporter(f, data, columns, options, nil) + return err +} + +func writeRowsToFileWithReporter(f *os.File, data []map[string]interface{}, columns []string, options ExportFileOptions, reporter *exportProgressReporter) (int64, error) { + if f == nil { + return 0, fmt.Errorf("file required") + } + columns = resolveExportColumns(columns, data) + writer, err := newExportFileWriter(f, options) + if err != nil { + return 0, err + } + if err := writer.SetColumns(columns); err != nil { + _ = writer.Close() + return 0, err + } + if reporter != nil { + reporter.ForceRunning(0, "正在写入文件") + } + for index, row := range data { + if err := writer.ConsumeRow(row); err != nil { + _ = writer.Close() + return int64(index), err + } + if reporter != nil { + reporter.Rows(int64(index+1), "正在写入文件") + } + } + if reporter != nil { + reporter.Finalizing(int64(len(data))) + } + if err := writer.Close(); err != nil { + return int64(len(data)), err + } + return int64(len(data)), nil +} + func formatExportHTMLCell(val interface{}) string { text := formatExportCellText(val) escaped := html.EscapeString(text) @@ -3677,13 +4474,11 @@ func formatExportCellText(val interface{}) string { return "NULL" } return text + case string: + return normalizeExportTemporalText(v) default: text := fmt.Sprintf("%v", val) - // 字符串型日期时间值(如 RFC3339 "2026-03-10T17:01:55+08:00")统一格式化为 yyyy-MM-dd HH:mm:ss - if parsed, ok := parseTemporalString(text); ok { - return parsed.Format("2006-01-02 15:04:05") - } - return text + return normalizeExportTemporalText(text) } } @@ -3701,10 +4496,7 @@ func normalizeExportJSONValue(val interface{}) interface{} { } return v.Format("2006-01-02 15:04:05") case string: - if parsed, ok := parseTemporalString(v); ok { - return parsed.Format("2006-01-02 15:04:05") - } - return v + return normalizeExportTemporalText(v) case float32: f := float64(v) if math.IsNaN(f) || math.IsInf(f, 0) { diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 7aa5176..903ec17 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -11,6 +11,8 @@ import ( "time" "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "github.com/xuri/excelize/v2" ) type fakeExportQueryDB struct { @@ -24,6 +26,23 @@ type fakeExportQueryDB struct { hasContextDeadline bool } +type fakeStreamExportDB struct { + fakeExportQueryDB + streamData []map[string]interface{} + streamCols []string + streamHits int + queryHits int +} + +type fakeValueStreamExportDB struct { + fakeExportQueryDB + streamCols []string + streamValues [][]interface{} + streamHits int + queryHits int + valueHits int +} + func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil } func (f *fakeExportQueryDB) Close() error { return nil } func (f *fakeExportQueryDB) Ping() error { return nil } @@ -63,6 +82,77 @@ func (f *fakeExportQueryDB) GetTriggers(dbName, tableName string) ([]connection. return nil, nil } +func (f *fakeStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) { + f.queryHits++ + return f.fakeExportQueryDB.Query(query) +} + +func (f *fakeStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + f.queryHits++ + return f.fakeExportQueryDB.QueryContext(ctx, query) +} + +func (f *fakeStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error { + return f.StreamQueryContext(context.Background(), query, consumer) +} + +func (f *fakeStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error { + f.streamHits++ + f.lastQuery = query + if err := consumer.SetColumns(f.streamCols); err != nil { + return err + } + for _, row := range f.streamData { + if err := consumer.ConsumeRow(row); err != nil { + return err + } + } + return nil +} + +func (f *fakeValueStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) { + f.queryHits++ + return f.fakeExportQueryDB.Query(query) +} + +func (f *fakeValueStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + f.queryHits++ + return f.fakeExportQueryDB.QueryContext(ctx, query) +} + +func (f *fakeValueStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error { + return f.StreamQueryContext(context.Background(), query, consumer) +} + +func (f *fakeValueStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error { + f.streamHits++ + f.lastQuery = query + if err := consumer.SetColumns(f.streamCols); err != nil { + return err + } + if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok { + for _, row := range f.streamValues { + f.valueHits++ + if err := valueConsumer.ConsumeRowValues(row); err != nil { + return err + } + } + return nil + } + for _, row := range f.streamValues { + entry := make(map[string]interface{}, len(f.streamCols)) + for idx, column := range f.streamCols { + if idx < len(row) { + entry[column] = row[idx] + } + } + if err := consumer.ConsumeRow(entry); err != nil { + return err + } + } + return nil +} + func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) { got := formatExportCellText(1.445663e+06) if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") { @@ -86,7 +176,7 @@ func TestWriteRowsToFile_Markdown_NumberKeepPlainText(t *testing.T) { } columns := []string{"id"} - if err := writeRowsToFile(f, data, columns, "md"); err != nil { + if err := writeRowsToFile(f, data, columns, ExportFileOptions{Format: "md"}); err != nil { t.Fatalf("写入 md 失败: %v", err) } @@ -116,7 +206,7 @@ func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) { } columns := []string{"id"} - if err := writeRowsToFile(f, data, columns, "json"); err != nil { + if err := writeRowsToFile(f, data, columns, ExportFileOptions{Format: "json"}); err != nil { t.Fatalf("写入 json 失败: %v", err) } @@ -166,6 +256,24 @@ func TestFormatExportCellText_TimeValue_KeepWallClock(t *testing.T) { } } +func TestFormatExportCellText_StringRFC3339_KeepWallClock(t *testing.T) { + originalLocal := time.Local + time.Local = time.FixedZone("UTC+8", 8*60*60) + defer func() { time.Local = originalLocal }() + + got := formatExportCellText("2026-04-07T10:44:32Z") + if got != "2026-04-07 10:44:32" { + t.Fatalf("字符串时间导出应保持原始钟表时间,want=%q got=%q", "2026-04-07 10:44:32", got) + } +} + +func TestFormatExportCellText_PlainString_Untouched(t *testing.T) { + got := formatExportCellText("plain export payload without timezone marker") + if got != "plain export payload without timezone marker" { + t.Fatalf("普通字符串不应被改写,got=%q", got) + } +} + func TestParseTemporalString_LocalDateTime_NoTimezoneShift(t *testing.T) { originalLocal := time.Local time.Local = time.FixedZone("UTC+8", 8*60*60) @@ -248,6 +356,105 @@ func TestQueryDataForExport_UsesLargerConfiguredTimeout(t *testing.T) { } } +func TestExportQueryResultToFile_UsesStreamQueryPath(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-stream-*.csv") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + fake := &fakeStreamExportDB{ + fakeExportQueryDB: fakeExportQueryDB{ + err: context.DeadlineExceeded, + data: []map[string]interface{}{{"id": 999}}, + cols: []string{"id"}, + }, + streamCols: []string{"id", "name"}, + streamData: []map[string]interface{}{ + {"id": 1, "name": "alice"}, + {"id": 2, "name": "bob"}, + }, + } + + rowCount, columns, err := exportQueryResultToFile( + f, + fake, + connection.ConnectionConfig{Type: "mysql", Timeout: 10}, + "SELECT id, name FROM users", + ExportFileOptions{Format: "csv"}, + nil, + ) + if err != nil { + t.Fatalf("exportQueryResultToFile 返回错误: %v", err) + } + if fake.streamHits != 1 { + t.Fatalf("应优先使用流式查询,streamHits=%d", fake.streamHits) + } + if fake.queryHits != 0 { + t.Fatalf("不应回退到缓冲查询,queryHits=%d", fake.queryHits) + } + if rowCount != 2 { + t.Fatalf("导出行数异常,want=2 got=%d", rowCount) + } + if len(columns) != 2 || columns[0] != "id" || columns[1] != "name" { + t.Fatalf("导出列异常,got=%v", columns) + } + + contentBytes, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("读取导出文件失败: %v", err) + } + content := string(contentBytes) + if !strings.Contains(content, "alice") || !strings.Contains(content, "bob") { + t.Fatalf("流式导出内容异常: %s", content) + } +} + +func TestExportQueryResultToFile_UsesValueStreamPathWhenAvailable(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-stream-values-*.csv") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + fake := &fakeValueStreamExportDB{ + streamCols: []string{"id", "name"}, + streamValues: [][]interface{}{ + {1, "alice"}, + {2, "bob"}, + }, + } + + rowCount, columns, err := exportQueryResultToFile( + f, + fake, + connection.ConnectionConfig{Type: "mysql", Timeout: 10}, + "SELECT id, name FROM users", + ExportFileOptions{Format: "csv"}, + nil, + ) + if err != nil { + t.Fatalf("exportQueryResultToFile 返回错误: %v", err) + } + if fake.streamHits != 1 { + t.Fatalf("应优先使用流式查询,streamHits=%d", fake.streamHits) + } + if fake.valueHits != 2 { + t.Fatalf("应走值数组流式路径,valueHits=%d", fake.valueHits) + } + if fake.queryHits != 0 { + t.Fatalf("不应回退到缓冲查询,queryHits=%d", fake.queryHits) + } + if rowCount != 2 { + t.Fatalf("导出行数异常,want=2 got=%d", rowCount) + } + if len(columns) != 2 || columns[0] != "id" || columns[1] != "name" { + t.Fatalf("导出列异常,got=%v", columns) + } +} + func TestGetExportQueryTimeout_ClickHouseUsesLongerMinimum(t *testing.T) { timeout := getExportQueryTimeout(connection.ConnectionConfig{ Type: "clickhouse", @@ -300,7 +507,7 @@ func TestWriteRowsToFile_HTML_EscapeAndStyle(t *testing.T) { } columns := []string{"name", "note", "nullable"} - if err := writeRowsToFile(f, data, columns, "html"); err != nil { + if err := writeRowsToFile(f, data, columns, ExportFileOptions{Format: "html"}); err != nil { t.Fatalf("写入 html 失败: %v", err) } @@ -343,7 +550,7 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { columnName := "name" data := []map[string]interface{}{{columnName: "ok"}} - if err := writeRowsToFile(f, data, []string{columnName}, "html"); err != nil { + if err := writeRowsToFile(f, data, []string{columnName}, ExportFileOptions{Format: "html"}); err != nil { t.Fatalf("写入 html 失败: %v", err) } contentBytes, _ := os.ReadFile(f.Name()) @@ -353,6 +560,175 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) { } } +func TestWriteRowsToFile_XLSX_SplitsByMaxRowsPerSheet(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-export-*.xlsx") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + data := []map[string]interface{}{ + {"id": 1, "name": "alice"}, + {"id": 2, "name": "bob"}, + {"id": 3, "name": "carol"}, + } + columns := []string{"id", "name"} + + if err := writeRowsToFile(f, data, columns, ExportFileOptions{ + Format: "xlsx", + XLSXMaxRowsPerSheet: 2, + }); err != nil { + t.Fatalf("写入 xlsx 失败: %v", err) + } + + workbook, err := excelize.OpenFile(f.Name()) + if err != nil { + t.Fatalf("打开 xlsx 失败: %v", err) + } + defer workbook.Close() + + sheets := workbook.GetSheetList() + if len(sheets) != 2 { + t.Fatalf("sheet 数量异常,want=2 got=%d (%v)", len(sheets), sheets) + } + + rows1, err := workbook.GetRows("Sheet1") + if err != nil { + t.Fatalf("读取 Sheet1 失败: %v", err) + } + if len(rows1) != 3 { + t.Fatalf("Sheet1 行数异常,want=3 got=%d", len(rows1)) + } + + rows2, err := workbook.GetRows("Sheet2") + if err != nil { + t.Fatalf("读取 Sheet2 失败: %v", err) + } + if len(rows2) != 2 { + t.Fatalf("Sheet2 行数异常,want=2 got=%d", len(rows2)) + } + if rows2[1][1] != "carol" { + t.Fatalf("Sheet2 数据异常,want=%q got=%q", "carol", rows2[1][1]) + } +} + +func benchmarkExportRows(rowCount int) ([]map[string]interface{}, []string) { + columns := []string{"id", "name", "note", "created_at", "status"} + rows := make([]map[string]interface{}, rowCount) + for i := 0; i < rowCount; i++ { + rows[i] = map[string]interface{}{ + "id": i + 1, + "name": "benchmark-user", + "note": "plain export payload without timezone marker", + "created_at": "2026-06-17 12:34:56", + "status": "enabled", + } + } + return rows, columns +} + +func benchmarkExportRowValues(rowCount int) ([][]interface{}, []string) { + columns := []string{"id", "name", "note", "created_at", "status"} + rows := make([][]interface{}, rowCount) + for i := 0; i < rowCount; i++ { + rows[i] = []interface{}{ + i + 1, + "benchmark-user", + "plain export payload without timezone marker", + "2026-06-17 12:34:56", + "enabled", + } + } + return rows, columns +} + +func BenchmarkFormatExportCellText_PlainString(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = formatExportCellText("plain export payload without timezone marker") + } +} + +func BenchmarkWriteRowsToFile_XLSX_20000Rows(b *testing.B) { + rows, columns := benchmarkExportRows(20000) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + f, err := os.CreateTemp("", "gonavi-export-bench-*.xlsx") + if err != nil { + b.Fatalf("创建临时文件失败: %v", err) + } + name := f.Name() + if err := writeRowsToFile(f, rows, columns, ExportFileOptions{Format: "xlsx"}); err != nil { + _ = os.Remove(name) + b.Fatalf("写入 xlsx 失败: %v", err) + } + if err := os.Remove(name); err != nil { + b.Fatalf("删除临时文件失败: %v", err) + } + } +} + +func BenchmarkExportQueryResultToFile_XLSX_StreamMap_20000Rows(b *testing.B) { + rows, columns := benchmarkExportRows(20000) + streamDB := &fakeStreamExportDB{ + streamCols: columns, + streamData: rows, + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + f, err := os.CreateTemp("", "gonavi-export-stream-map-*.xlsx") + if err != nil { + b.Fatalf("创建临时文件失败: %v", err) + } + name := f.Name() + if _, _, err := exportQueryResultToFile( + f, + streamDB, + connection.ConnectionConfig{Type: "mysql", Timeout: 10}, + "SELECT * FROM users", + ExportFileOptions{Format: "xlsx"}, + nil, + ); err != nil { + _ = os.Remove(name) + b.Fatalf("流式 map 导出失败: %v", err) + } + if err := os.Remove(name); err != nil { + b.Fatalf("删除临时文件失败: %v", err) + } + } +} + +func BenchmarkExportQueryResultToFile_XLSX_StreamValues_20000Rows(b *testing.B) { + rows, columns := benchmarkExportRowValues(20000) + streamDB := &fakeValueStreamExportDB{ + streamCols: columns, + streamValues: rows, + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + f, err := os.CreateTemp("", "gonavi-export-stream-values-*.xlsx") + if err != nil { + b.Fatalf("创建临时文件失败: %v", err) + } + name := f.Name() + if _, _, err := exportQueryResultToFile( + f, + streamDB, + connection.ConnectionConfig{Type: "mysql", Timeout: 10}, + "SELECT * FROM users", + ExportFileOptions{Format: "xlsx"}, + nil, + ); err != nil { + _ = os.Remove(name) + b.Fatalf("流式值数组导出失败: %v", err) + } + if err := os.Remove(name); err != nil { + b.Fatalf("删除临时文件失败: %v", err) + } + } +} + func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) { got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00") if got != "'2026-01-21 18:32:26'" { diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go index ff12e72..0dac365 100644 --- a/internal/db/clickhouse_impl.go +++ b/internal/db/clickhouse_impl.go @@ -777,6 +777,22 @@ func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, return scanRows(rows) } +func (c *ClickHouseDB) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if c.conn == nil { + return fmt.Errorf("连接未打开") + } + rows, err := c.conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + return streamRows(rows, consumer) +} + +func (c *ClickHouseDB) StreamQuery(query string, consumer QueryStreamConsumer) error { + return c.StreamQueryContext(context.Background(), query, consumer) +} + func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) { if c.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/custom_impl.go b/internal/db/custom_impl.go index 0ad1ba8..0f2d027 100644 --- a/internal/db/custom_impl.go +++ b/internal/db/custom_impl.go @@ -111,6 +111,24 @@ func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, erro return scanRowsForDialect(rows, c.scanDialect()) } +func (c *CustomDB) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if c.conn == nil { + return fmt.Errorf("连接未打开") + } + + rows, err := c.conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + + return streamRowsForDialect(rows, c.scanDialect(), consumer) +} + +func (c *CustomDB) StreamQuery(query string, consumer QueryStreamConsumer) error { + return c.StreamQueryContext(context.Background(), query, consumer) +} + func (c *CustomDB) scanDialect() string { if strings.EqualFold(strings.TrimSpace(c.driver), "mysql") { return "mysql" diff --git a/internal/db/dameng_impl.go b/internal/db/dameng_impl.go index d9ce83b..7b05601 100644 --- a/internal/db/dameng_impl.go +++ b/internal/db/dameng_impl.go @@ -182,6 +182,24 @@ func (d *DamengDB) Query(query string) ([]map[string]interface{}, []string, erro return scanRows(rows) } +func (d *DamengDB) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if d.conn == nil { + return fmt.Errorf("连接未打开") + } + + rows, err := d.conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + + return streamRows(rows, consumer) +} + +func (d *DamengDB) StreamQuery(query string, consumer QueryStreamConsumer) error { + return d.StreamQueryContext(context.Background(), query, consumer) +} + func (d *DamengDB) ExecContext(ctx context.Context, query string) (int64, error) { if d.conn == nil { return 0, fmt.Errorf("连接未打开") diff --git a/internal/db/database.go b/internal/db/database.go index a0dd411..8f69b02 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -76,6 +76,28 @@ type StatementQueryExecer interface { QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) } +// QueryStreamConsumer receives query metadata and rows incrementally. +// Implementations can stream rows directly to files to avoid buffering entire result sets in memory. +type QueryStreamConsumer interface { + SetColumns(columns []string) error + ConsumeRow(row map[string]interface{}) error +} + +// QueryStreamValueConsumer is an optional fast path for stream consumers that +// can consume normalized row values in column order without requiring a +// map[string]interface{} allocation per row. +type QueryStreamValueConsumer interface { + SetColumns(columns []string) error + ConsumeRowValues(values []interface{}) error +} + +// StreamQueryExecer is an optional interface for drivers or pinned sessions that can +// stream query rows incrementally instead of materializing []map rows in memory. +type StreamQueryExecer interface { + StreamQuery(query string, consumer QueryStreamConsumer) error + StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error +} + // StatementQueryMessageExecer can run queries on a pinned session and return // extra server messages/notices alongside rows. type StatementQueryMessageExecer interface { @@ -178,6 +200,22 @@ func (e *sqlConnStatementExecer) Query(query string) ([]map[string]interface{}, return e.QueryContext(context.Background(), query) } +func (e *sqlConnStatementExecer) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if e == nil || e.conn == nil { + return fmt.Errorf("连接未打开") + } + rows, err := e.conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + return streamRowsForDialect(rows, e.scanDialect, consumer) +} + +func (e *sqlConnStatementExecer) StreamQuery(query string, consumer QueryStreamConsumer) error { + return e.StreamQueryContext(context.Background(), query, consumer) +} + func (e *sqlConnStatementExecer) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) { if e == nil || e.conn == nil { return nil, fmt.Errorf("连接未打开") @@ -275,6 +313,23 @@ func (e *sqlConnTransactionExecer) Query(query string) ([]map[string]interface{} return e.QueryContext(context.Background(), query) } +func (e *sqlConnTransactionExecer) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + conn, err := e.activeConn() + if err != nil { + return err + } + rows, err := conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + return streamRowsForDialect(rows, e.scanDialect, consumer) +} + +func (e *sqlConnTransactionExecer) StreamQuery(query string, consumer QueryStreamConsumer) error { + return e.StreamQueryContext(context.Background(), query, consumer) +} + func (e *sqlConnTransactionExecer) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) { conn, err := e.activeConn() if err != nil { @@ -401,6 +456,23 @@ func (e *sqlTxStatementExecer) Query(query string) ([]map[string]interface{}, [] return e.QueryContext(context.Background(), query) } +func (e *sqlTxStatementExecer) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + tx, err := e.activeTx() + if err != nil { + return err + } + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + return streamRows(rows, consumer) +} + +func (e *sqlTxStatementExecer) StreamQuery(query string, consumer QueryStreamConsumer) error { + return e.StreamQueryContext(context.Background(), query, consumer) +} + func (e *sqlTxStatementExecer) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) { tx, err := e.activeTx() if err != nil { diff --git a/internal/db/scan_rows.go b/internal/db/scan_rows.go index cac7064..3277fcf 100644 --- a/internal/db/scan_rows.go +++ b/internal/db/scan_rows.go @@ -11,6 +11,19 @@ func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) { return scanRowsForDialect(rows, "") } +func streamRows(rows *sql.Rows, consumer QueryStreamConsumer) error { + return streamRowsForDialect(rows, "", consumer) +} + +type queryRowScanner struct { + columns []string + dbTypeNames []string + dialect string + values []interface{} + normalized []interface{} + valuePtrs []interface{} +} + func scanRowsForDialect(rows *sql.Rows, dialect string) ([]map[string]interface{}, []string, error) { columns, err := rows.Columns() if err != nil { @@ -23,27 +36,14 @@ func scanRowsForDialect(rows *sql.Rows, dialect string) ([]map[string]interface{ colTypes = nil } + scanner := newQueryRowScanner(columns, colTypes, dialect) resultData := make([]map[string]interface{}, 0) for rows.Next() { - values := make([]interface{}, len(columns)) - valuePtrs := make([]interface{}, len(columns)) - for i := range columns { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { + entry, err := scanner.scanCurrentRow(rows) + if err != nil { continue } - - entry := make(map[string]interface{}, len(columns)) - for i, col := range columns { - dbTypeName := "" - if colTypes != nil && i < len(colTypes) && colTypes[i] != nil { - dbTypeName = colTypes[i].DatabaseTypeName() - } - entry[col] = normalizeQueryValueWithDBTypeAndDialect(values[i], dbTypeName, dialect) - } resultData = append(resultData, entry) } @@ -53,6 +53,95 @@ func scanRowsForDialect(rows *sql.Rows, dialect string) ([]map[string]interface{ return resultData, columns, nil } +func streamRowsForDialect(rows *sql.Rows, dialect string, consumer QueryStreamConsumer) error { + if consumer == nil { + return fmt.Errorf("query stream consumer required") + } + + columns, err := rows.Columns() + if err != nil { + return err + } + columns = ensureUniqueQueryColumnNames(columns) + + colTypes, err := rows.ColumnTypes() + if err != nil || len(colTypes) != len(columns) { + colTypes = nil + } + + scanner := newQueryRowScanner(columns, colTypes, dialect) + if err := consumer.SetColumns(columns); err != nil { + return err + } + valueConsumer, useValueConsumer := consumer.(QueryStreamValueConsumer) + + for rows.Next() { + if useValueConsumer { + values, err := scanner.scanCurrentRowValues(rows) + if err != nil { + continue + } + if err := valueConsumer.ConsumeRowValues(values); err != nil { + return err + } + continue + } + entry, err := scanner.scanCurrentRow(rows) + if err != nil { + continue + } + if err := consumer.ConsumeRow(entry); err != nil { + return err + } + } + + return rows.Err() +} + +func newQueryRowScanner(columns []string, colTypes []*sql.ColumnType, dialect string) *queryRowScanner { + values := make([]interface{}, len(columns)) + valuePtrs := make([]interface{}, len(columns)) + for i := range columns { + valuePtrs[i] = &values[i] + } + dbTypeNames := make([]string, len(columns)) + for i := range columns { + if colTypes != nil && i < len(colTypes) && colTypes[i] != nil { + dbTypeNames[i] = colTypes[i].DatabaseTypeName() + } + } + return &queryRowScanner{ + columns: columns, + dbTypeNames: dbTypeNames, + dialect: dialect, + values: values, + normalized: make([]interface{}, len(columns)), + valuePtrs: valuePtrs, + } +} + +func (s *queryRowScanner) scanCurrentRowValues(rows *sql.Rows) ([]interface{}, error) { + if err := rows.Scan(s.valuePtrs...); err != nil { + return nil, err + } + for i := range s.columns { + s.normalized[i] = normalizeQueryValueWithDBTypeAndDialect(s.values[i], s.dbTypeNames[i], s.dialect) + } + return s.normalized, nil +} + +func (s *queryRowScanner) scanCurrentRow(rows *sql.Rows) (map[string]interface{}, error) { + normalized, err := s.scanCurrentRowValues(rows) + if err != nil { + return nil, err + } + entry := make(map[string]interface{}, len(s.columns)) + for i, col := range s.columns { + entry[col] = normalized[i] + } + return entry, nil +} + func ensureUniqueQueryColumnNames(columns []string) []string { if len(columns) == 0 { return columns diff --git a/internal/db/sqlserver_impl.go b/internal/db/sqlserver_impl.go index 34839d7..429a488 100644 --- a/internal/db/sqlserver_impl.go +++ b/internal/db/sqlserver_impl.go @@ -385,6 +385,23 @@ func (e *sqlServerSessionExecer) QueryContext(ctx context.Context, query string) return rows, columns, err } +func (e *sqlServerSessionExecer) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if e == nil || e.conn == nil { + return fmt.Errorf("连接未打开") + } + retmsg := &sqlexp.ReturnMessage{} + rows, err := e.conn.QueryContext(ctx, query, retmsg) + if err != nil { + return err + } + defer rows.Close() + return streamRows(rows, consumer) +} + +func (e *sqlServerSessionExecer) StreamQuery(query string, consumer QueryStreamConsumer) error { + return e.StreamQueryContext(context.Background(), query, consumer) +} + func (e *sqlServerSessionExecer) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) { return e.QueryContextWithMessages(context.Background(), query) } diff --git a/internal/db/tdengine_impl.go b/internal/db/tdengine_impl.go index 69782e8..02270cb 100644 --- a/internal/db/tdengine_impl.go +++ b/internal/db/tdengine_impl.go @@ -168,6 +168,24 @@ func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, er return scanRows(rows) } +func (t *TDengineDB) StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error { + if t.conn == nil { + return fmt.Errorf("连接未打开") + } + + rows, err := t.conn.QueryContext(ctx, query) + if err != nil { + return err + } + defer rows.Close() + + return streamRows(rows, consumer) +} + +func (t *TDengineDB) StreamQuery(query string, consumer QueryStreamConsumer) error { + return t.StreamQueryContext(context.Background(), query, consumer) +} + func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) { if t.conn == nil { return 0, fmt.Errorf("连接未打开") From 5b31ab743597997b3a42b174393c72c125a95ac2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 14:19:16 +0800 Subject: [PATCH 02/61] =?UTF-8?q?=E2=9C=A8=20feat(export-workbench):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=BC=E5=87=BA=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=E8=BF=9B=E5=BA=A6=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增表级导出工作台标签页,统一承载导出范围、格式和 XLSX sheet 行数配置 - 结果集、表概览、侧栏和右键菜单统一接入导出工作台与带进度的导出入口 - 导出进度改为事件驱动,未知总数时展示不定进度和实时已写入行数 - 持久化每张表的导出历史并复用同一导出标签,重启后仍可查看最近任务 - 调整导出页签标题、状态胶囊和历史列表,补充工作台与状态流测试覆盖 --- frontend/src/components/DataExportDialog.tsx | 209 +++++ .../src/components/DataGrid.layout.test.tsx | 117 +-- frontend/src/components/DataGrid.tsx | 410 +++++----- .../src/components/DataGridToolbarFrame.tsx | 6 +- frontend/src/components/ExportProgressBar.tsx | 86 +++ .../src/components/ExportProgressModal.tsx | 75 ++ .../Sidebar.locate-toolbar.test.tsx | 29 +- frontend/src/components/Sidebar.tsx | 85 +- .../SidebarTableExportFeedback.i18n.test.ts | 33 +- frontend/src/components/TabManager.tsx | 6 + .../components/TableExportWorkbench.test.tsx | 215 ++++++ .../src/components/TableExportWorkbench.tsx | 728 ++++++++++++++++++ frontend/src/components/TableOverview.tsx | 76 +- .../src/components/V2TableContextMenu.tsx | 8 +- .../useExportProgressRunner.test.tsx | 129 ++++ .../src/components/useExportProgressRunner.ts | 235 ++++++ frontend/src/store.test.ts | 73 ++ frontend/src/store.ts | 147 +++- frontend/src/types.ts | 40 + frontend/src/utils/exportProgress.test.ts | 34 + frontend/src/utils/exportProgress.ts | 91 +++ frontend/src/utils/tabDisplay.test.ts | 13 + frontend/src/utils/tabDisplay.ts | 11 +- frontend/src/utils/tableExportTab.test.ts | 63 ++ frontend/src/utils/tableExportTab.ts | 123 +++ frontend/wailsjs/go/app/App.d.ts | 6 + frontend/wailsjs/go/app/App.js | 12 + frontend/wailsjs/go/models.ts | 20 + shared/i18n/de-DE.json | 3 + shared/i18n/en-US.json | 3 + shared/i18n/ja-JP.json | 3 + shared/i18n/ru-RU.json | 3 + shared/i18n/zh-CN.json | 3 + shared/i18n/zh-TW.json | 3 + 34 files changed, 2673 insertions(+), 425 deletions(-) create mode 100644 frontend/src/components/DataExportDialog.tsx create mode 100644 frontend/src/components/ExportProgressBar.tsx create mode 100644 frontend/src/components/ExportProgressModal.tsx create mode 100644 frontend/src/components/TableExportWorkbench.test.tsx create mode 100644 frontend/src/components/TableExportWorkbench.tsx create mode 100644 frontend/src/components/useExportProgressRunner.test.tsx create mode 100644 frontend/src/components/useExportProgressRunner.ts create mode 100644 frontend/src/utils/exportProgress.test.ts create mode 100644 frontend/src/utils/exportProgress.ts create mode 100644 frontend/src/utils/tableExportTab.test.ts create mode 100644 frontend/src/utils/tableExportTab.ts diff --git a/frontend/src/components/DataExportDialog.tsx b/frontend/src/components/DataExportDialog.tsx new file mode 100644 index 0000000..6743172 --- /dev/null +++ b/frontend/src/components/DataExportDialog.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Form, InputNumber, Modal, Select, message } from 'antd'; +import { ExportOutlined } from '@ant-design/icons'; + +export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html'; +export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; + +export type DataExportFileOptions = { + format: DataExportFormat; + xlsxMaxRowsPerSheet?: number; +}; + +export type DataExportDialogValues = DataExportFileOptions & { + scope: DataExportScope | string; +}; + +export type DataExportScopeOption = { + value: DataExportScope | string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type ShowDataExportDialogOptions = { + title: string; + scopeOptions: DataExportScopeOption[]; + initialValues?: Partial; + okText?: string; +}; + +export const MAX_XLSX_ROWS_PER_SHEET = 1048575; +export const DEFAULT_XLSX_ROWS_PER_SHEET = MAX_XLSX_ROWS_PER_SHEET; +export const DEFAULT_DATA_EXPORT_FORMAT: DataExportFormat = 'xlsx'; + +export const DATA_EXPORT_FORMAT_OPTIONS: Array<{ value: DataExportFormat; label: string }> = [ + { value: 'xlsx', label: 'Excel (XLSX)' }, + { value: 'csv', label: 'CSV' }, + { value: 'json', label: 'JSON' }, + { value: 'md', label: 'Markdown' }, + { value: 'html', label: 'HTML' }, +]; + +const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope?: string): string => { + const matchedInitial = scopeOptions.find((item) => item.value === initialScope && !item.disabled); + if (matchedInitial) return String(matchedInitial.value); + const firstEnabled = scopeOptions.find((item) => !item.disabled); + return String(firstEnabled?.value || scopeOptions[0]?.value || 'all'); +}; + +const normalizeDialogValues = ( + scopeOptions: DataExportScopeOption[], + initialValues?: Partial, +): DataExportDialogValues => { + const format = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat; + const scope = resolveDefaultScope(scopeOptions, initialValues?.scope ? String(initialValues.scope) : undefined); + const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0 + ? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet))) + : DEFAULT_XLSX_ROWS_PER_SHEET; + return { + format, + scope, + xlsxMaxRowsPerSheet, + }; +}; + +const validateDialogValues = ( + values: DataExportDialogValues, + scopeOptions: DataExportScopeOption[], +): string | null => { + if (!DATA_EXPORT_FORMAT_OPTIONS.some((item) => item.value === values.format)) { + return '请选择导出格式'; + } + if (scopeOptions.length > 0) { + const matchedScope = scopeOptions.find((item) => String(item.value) === String(values.scope)); + if (!matchedScope || matchedScope.disabled) { + return '请选择可用的导出范围'; + } + } + if (values.format === 'xlsx') { + const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0); + if (!Number.isFinite(rows) || rows <= 0) { + return '请输入有效的每个工作表最大行数'; + } + if (rows > MAX_XLSX_ROWS_PER_SHEET) { + return `每个工作表最大行数不能超过 ${MAX_XLSX_ROWS_PER_SHEET.toLocaleString()}`; + } + } + return null; +}; + +const DataExportDialogContent: React.FC<{ + scopeOptions: DataExportScopeOption[]; + initialValues?: Partial; + onChange: (values: DataExportDialogValues) => void; +}> = ({ scopeOptions, initialValues, onChange }) => { + const [values, setValues] = useState(() => normalizeDialogValues(scopeOptions, initialValues)); + + useEffect(() => { + onChange(values); + }, [onChange, values]); + + const selectedScope = useMemo( + () => scopeOptions.find((item) => String(item.value) === String(values.scope)), + [scopeOptions, values.scope], + ); + + return ( +
+
+ + ({ + value: item.value, + label: item.label, + disabled: item.disabled, + }))} + onChange={(scope) => setValues((prev) => ({ ...prev, scope }))} + /> + + + {selectedScope?.description && ( +
+ {selectedScope.description} +
+ )} + + {values.format === 'xlsx' && ( + + setValues((prev) => ({ + ...prev, + xlsxMaxRowsPerSheet: Number(nextValue) > 0 + ? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(nextValue))) + : 0, + }))} + /> + + )} +
+
+ ); +}; + +export async function showDataExportDialog( + modal: ReturnType[0], + options: ShowDataExportDialogOptions, +): Promise { + const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues); + + return new Promise((resolve) => { + let resolved = false; + let latestValues = initialValues; + + const finish = (nextValue: DataExportDialogValues | null) => { + if (resolved) return; + resolved = true; + resolve(nextValue); + }; + + modal.confirm({ + title: options.title, + icon: , + width: 520, + centered: true, + maskClosable: true, + okText: options.okText || '开始导出', + cancelText: '取消', + content: ( + { + latestValues = values; + }} + /> + ), + onOk: async () => { + const errorMessage = validateDialogValues(latestValues, options.scopeOptions); + if (errorMessage) { + void message.error(errorMessage); + throw new Error(errorMessage); + } + finish(latestValues); + }, + onCancel: () => { + finish(null); + }, + }); + }); +} diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 0f7b6ef..3902d89 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2175,111 +2175,28 @@ describe('DataGrid layout', () => { it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => { const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); - const exportDataSource = source.slice( - source.indexOf(' const exportData = async (rows: any[], format: string) => {'), - source.indexOf(' const [sortInfo, setSortInfo]'), - ); - const exportChromeSource = source.slice( - source.indexOf(' const exportByQuery = useCallback(async (sql: string, format: string, defaultName: string) => {'), - source.indexOf(" const queryResultCopyMenu: MenuProps['items'] ="), - ); - const exportSource = `${exportDataSource}\n${exportChromeSource}`; - const exportChromeKeys = [ - 'data_grid.message.exporting_rows', - 'data_grid.message.exporting', - 'data_grid.message.export_success', - 'data_grid.message.export_failed', - 'data_grid.message.export_with_uncommitted_changes', - 'data_grid.message.no_filter_applied', - 'data_grid.message.filtered_export_not_supported', - 'data_grid.message.filtered_export_uses_committed_data', - 'data_grid.message.no_rows_selected', - 'data_grid.message.select_file_failed', - 'data_grid.message.import_done', - 'data_grid.export.query_result_title', - 'data_grid.export.scope_prompt', - 'data_grid.export.selected_rows', - 'data_grid.export.current_page_rows', - 'data_grid.export.all_rows', - 'data_grid.export.all_rows_requery', - 'data_grid.export.options_title', - 'data_grid.export.no_selection_prompt', - 'data_grid.export.current_page', - 'data_grid.export.all_data', - 'data_grid.export.group_filtered_results', - 'data_grid.export.group_full_table', - ]; + const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8'); - expect(source).toContain("type QueryResultExportScope = 'selected' | 'page' | 'all';"); - exportChromeKeys.forEach((key) => { - expect(exportSource).toContain(`translateDataGrid('${key}'`); - }); - expect(exportChromeSource).toContain("translateDataGrid('common.cancel')"); - expect(exportChromeSource).toContain('data-query-result-export-scope="true"'); - expect(exportDataSource).toMatch(/message\.loading\(\s*translateDataGrid\(\s*'data_grid\.message\.exporting_rows'\s*,\s*\{\s*count:\s*rows\.length\s*\}\s*\)\s*,\s*0\s*\)/); - expect(exportDataSource).toMatch(/message\.success\(\s*translateDataGrid\(\s*'data_grid\.message\.export_success'\s*\)\s*\)/); - expect(exportDataSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/); - expect(exportDataSource).toMatch(/const\s+rawErrorMessage\s*=\s*e\?\.message\s*\|\|\s*String\(e\);[\s\S]*translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*rawErrorMessage\s*\}\s*\)/); - expect(exportChromeSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/); - expect(exportChromeSource).toMatch(/const\s+rawErrorMessage\s*=\s*e\?\.message\s*\|\|\s*String\(e\);[\s\S]*translateDataGrid\(\s*'data_grid\.message\.export_failed'\s*,\s*\{\s*detail:\s*rawErrorMessage\s*\}\s*\)/); - expect(exportChromeSource).toMatch(/translateDataGrid\(\s*'data_grid\.message\.select_file_failed'\s*,\s*\{\s*detail:\s*res\.message\s*\}\s*\)/); - expect(exportChromeSource).toMatch(/},\s*\[[\s\S]*translateDataGrid[\s\S]*\]\);/); - expect(source).toContain('data-query-result-export-scope="true"'); + expect(source).toContain("type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';"); + expect(source).toContain('const handleOpenExportDialog = useCallback(async () => {'); + expect(source).toContain('await runExportWithProgress({'); + expect(source).toContain("title: '导出查询结果'"); + expect(source).toContain("label: '筛选结果(全部)'"); + expect(source).toContain("label: '全表数据'"); + expect(source).toContain("const fallbackAllSql = String(resultSql || '').trim();"); + expect(source).toContain("const backendExportSql = exportAllSql || fallbackAllSql;"); + expect(source).toContain("if (backendExportSql && connectionId) {"); + expect(source).toContain("label: allRowsLabel"); + expect(exportDialogSource).toContain('data-export-config-modal="true"'); + expect(exportDialogSource).toContain('label="导出格式"'); + expect(exportDialogSource).toContain('label="每个工作表最大行数"'); + expect(exportDialogSource).toContain('仅 XLSX 生效'); expect(source).toContain('const queryResultCurrentPageRows = useMemo(() => {'); expect(source).toContain('const resolveContextMenuPosition = useCallback((x: number, y: number, estimatedWidth: number, estimatedHeight: number) => {'); expect(source).toContain('const rect = element.getBoundingClientRect();'); expect(source).toContain('ref={cellContextMenuPortalRef}'); - [ - '正在导出 ${rows.length} 条数据...', - '正在导出...', - '导出成功', - '导出失败: ', - '导出失败:', - '当前未选中任何行', - '导出查询结果', - '请选择导出范围:', - '选中导出', - '当前页导出', - '全部导出', - '当前存在未提交修改,导出将按界面数据生成;如需完整长字段建议先提交后再导出。', - '导出选项', - '您未选中任何行,请选择导出范围:', - '导出当前页', - '导出全部数据', - '当前数据源不支持按筛选结果导出', - '当前存在未提交修改,筛选结果导出基于数据库已提交数据。', - '选择文件失败: ', - '导入完成', - '筛选结果', - '全表', - ].forEach((literal) => { - expect(exportSource).not.toContain(literal); - }); - - [ - 'zh-CN', - 'zh-TW', - 'en-US', - 'ja-JP', - 'de-DE', - 'ru-RU', - ].forEach((locale) => { - const catalog = JSON.parse( - readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), - ) as Record; - - exportChromeKeys.forEach((key) => { - expect(catalog[key]).toEqual(expect.any(String)); - expect(catalog[key].length).toBeGreaterThan(0); - }); - expect(catalog['data_grid.message.exporting_rows']).toContain('{{count}}'); - expect(catalog['data_grid.message.export_failed']).toContain('{{detail}}'); - expect(catalog['data_grid.message.select_file_failed']).toContain('{{detail}}'); - expect(catalog['data_grid.export.selected_rows']).toContain('{{count}}'); - expect(catalog['data_grid.export.current_page_rows']).toContain('{{count}}'); - expect(catalog['data_grid.export.all_rows']).toContain('{{count}}'); - expect(catalog['data_grid.export.current_page']).toContain('{{count}}'); - }); + expect(source).not.toContain('const openQueryResultExportScopeModal = useCallback('); + expect(source).not.toContain('const exportMenu: MenuProps[\'items\'] ='); }); it('keeps inline cell editors stretched to the full cell width', () => { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 2cd0762..f8e0a18 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -21,7 +21,7 @@ import { arrayMove } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; +import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; import { getCurrentLanguage, t } from '../i18n'; @@ -133,14 +133,24 @@ import DataGridToolbarFrame from './DataGridToolbarFrame'; import DataGridModals from './DataGridModals'; import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; import DataGridPreviewPanel from './DataGridPreviewPanel'; +import { + DEFAULT_DATA_EXPORT_FORMAT, + DEFAULT_XLSX_ROWS_PER_SHEET, + showDataExportDialog, + type DataExportDialogValues, + type DataExportFileOptions, + type DataExportScopeOption, +} from './DataExportDialog'; import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; import TableDesigner from './TableDesigner'; +import { useExportProgressDialog } from './ExportProgressModal'; import { useDataGridFilters } from './useDataGridFilters'; import { useDataGridDdlView } from './useDataGridDdlView'; import { useDataGridModalEditors } from './useDataGridModalEditors'; import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; +import { buildTableExportTab } from '../utils/tableExportTab'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -823,7 +833,7 @@ const DataContext = React.createContext<{ handleCopyDelete: (r: any) => void; handleCopyJson: (r: any) => void; handleCopyCsv: (r: any) => void; - handleExportSelected: (format: string, r: any) => Promise; + handleExportSelected: (options: DataExportFileOptions, r: any) => Promise; copyToClipboard: (t: string) => void; tableName?: string; enableRowContextMenu: boolean; @@ -1253,11 +1263,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { label: t('data_grid.context_menu.export_selected'), icon: , children: [ - { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected('csv', record).catch(console.error) }, - { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected('xlsx', record).catch(console.error) }, - { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected('json', record).catch(console.error) }, - { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected('md', record).catch(console.error) }, - { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected('html', record).catch(console.error) }, + { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) }, + { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) }, + { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) }, + { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) }, + { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) }, ] } ]; @@ -1323,7 +1333,7 @@ type GridFilterCondition = FilterCondition & { type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; type DdlViewLayoutMode = 'bottom' | 'side'; -type QueryResultExportScope = 'selected' | 'page' | 'all'; +type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; type VirtualEditingCellState = { rowKey: string; dataIndex: string; @@ -1572,6 +1582,7 @@ const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, + resultSql, resultExportAllSql, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, @@ -1937,6 +1948,7 @@ const DataGrid: React.FC = ({ const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); + const { exportProgressModal, runExportWithProgress } = useExportProgressDialog(); const gridId = useMemo(() => `grid-${generateUuid()}`, []); const [textRecordIndex, setTextRecordIndex] = useState(0); const { @@ -2172,23 +2184,25 @@ const DataGrid: React.FC = ({ }, [resolveContextMenuPosition]); // Helper to export specific data - const exportData = async (rows: any[], format: string) => { - const hide = message.loading(translateDataGrid('data_grid.message.exporting_rows', { count: rows.length }), 0); - try { - const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames); - // Pass tableName (or 'export') as default filename - const res = await ExportData(cleanRows, displayOutputColumnNames, tableName || 'export', format); - if (res.success) { - void message.success(translateDataGrid('data_grid.message.export_success')); - } else if (res.message !== "已取消") { - void message.error(translateDataGrid('data_grid.message.export_failed', { detail: res.message })); - } - } catch (e: any) { - const rawErrorMessage = e?.message || String(e); - void message.error(translateDataGrid('data_grid.message.export_failed', { detail: rawErrorMessage })); - } finally { - hide(); - } + const exportData = async (rows: any[], options: DataExportFileOptions) => { + const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames); + await runExportWithProgress({ + title: `导出 ${tableName || '数据'}`, + targetName: tableName || 'export', + format: options.format, + totalRows: cleanRows.length, + run: (jobId) => ExportDataWithOptions( + cleanRows, + displayOutputColumnNames, + tableName || 'export', + { + ...options, + jobId, + totalRowsHint: cleanRows.length, + totalRowsKnown: true, + } as any, + ), + }); }; const [sortInfo, setSortInfo] = useState>([]); @@ -6089,24 +6103,29 @@ const DataGrid: React.FC = ({ }; }, [connections, connectionId]); - const exportByQuery = useCallback(async (sql: string, format: string, defaultName: string) => { + const exportByQuery = useCallback(async (sql: string, defaultName: string, options: DataExportFileOptions, totalRows?: number) => { const config = buildConnConfig(); if (!config) return; - const hide = message.loading(translateDataGrid('data_grid.message.exporting'), 0); - try { - const res = await ExportQuery(buildRpcConnectionConfig(config) as any, dbName || '', sql, defaultName || 'export', format); - if (res.success) { - void message.success(translateDataGrid('data_grid.message.export_success')); - } else if (res.message !== "已取消") { - void message.error(translateDataGrid('data_grid.message.export_failed', { detail: res.message })); - } - } catch (e: any) { - const rawErrorMessage = e?.message || String(e); - void message.error(translateDataGrid('data_grid.message.export_failed', { detail: rawErrorMessage })); - } finally { - hide(); - } - }, [buildConnConfig, dbName, translateDataGrid]); + const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0; + await runExportWithProgress({ + title: `导出 ${defaultName || '查询结果'}`, + targetName: defaultName || 'export', + format: options.format, + totalRows: totalRowsKnown ? Number(totalRows) : undefined, + run: (jobId) => ExportQueryWithOptions( + buildRpcConnectionConfig(config) as any, + dbName || '', + sql, + defaultName || 'export', + { + ...options, + jobId, + totalRowsHint: totalRowsKnown ? Number(totalRows) : 0, + totalRowsKnown, + } as any, + ), + }); + }, [buildConnConfig, dbName, runExportWithProgress]); const buildPkWhereSql = useCallback((rows: any[], dbType: string) => { if (!tableName || pkColumns.length === 0) return ''; @@ -6191,7 +6210,7 @@ const DataGrid: React.FC = ({ return mergedDisplayData.slice(offset, offset + pagination.pageSize); }, [isQueryResultExport, mergedDisplayData, pagination]); - const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => { + const exportQueryResultRows = useCallback(async (options: DataExportFileOptions, scope: Exclude) => { if (scope === 'selected') { const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key))); const rows = mergedDisplayData.filter((row) => { @@ -6202,87 +6221,53 @@ const DataGrid: React.FC = ({ void message.info(translateDataGrid('data_grid.message.no_rows_selected')); return; } - await exportData(rows, format); + await exportData(rows, options); return; } if (scope === 'page') { - await exportData(queryResultCurrentPageRows, format); + await exportData(queryResultCurrentPageRows, options); return; } const exportAllSql = String(resultExportAllSql || '').trim(); - if (exportAllSql && connectionId) { - await exportByQuery(exportAllSql, format, tableName || 'query_result'); + const fallbackAllSql = String(resultSql || '').trim(); + const backendExportSql = exportAllSql || fallbackAllSql; + if (backendExportSql && connectionId) { + const totalRows = pagination && pagination.totalKnown !== false ? Number(pagination.total) : undefined; + await exportByQuery(backendExportSql, tableName || 'query_result', options, totalRows); return; } - await exportData(mergedDisplayData, format); - }, [connectionId, exportByQuery, exportData, mergedDisplayData, queryResultCurrentPageRows, resultExportAllSql, rowKeyStr, selectedRowKeys, tableName, translateDataGrid]); - - const openQueryResultExportScopeModal = useCallback((format: string) => { - let instance: { destroy: () => void } | null = null; - const selectedCount = selectedRowKeys.length; - const runExport = async (scope: QueryResultExportScope) => { - instance?.destroy(); - await exportQueryResultRows(format, scope); - }; - instance = modal.info({ - title: translateDataGrid('data_grid.export.query_result_title'), - content: ( -
-

{translateDataGrid('data_grid.export.scope_prompt')}

-
- - - - -
-
- ), - icon: , - okButtonProps: { style: { display: 'none' } }, - maskClosable: true, - }); - }, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, resultExportAllSql, selectedRowKeys.length, translateDataGrid]); + await exportData(mergedDisplayData, options); + }, [connectionId, exportByQuery, exportData, mergedDisplayData, pagination, queryResultCurrentPageRows, resultExportAllSql, resultSql, rowKeyStr, selectedRowKeys, tableName]); // Context Menu Export - const handleExportSelected = useCallback(async (format: string, record: any) => { + const handleExportSelected = useCallback(async (options: DataExportFileOptions, record: any) => { if (isQueryResultExport) { - await exportData(getContextMenuTargetRows(record), format); + await exportData(getContextMenuTargetRows(record), options); return; } const records = getTargets(record); if (!connectionId || !tableName) { - await exportData(records, format); + await exportData(records, options); return; } // 有未提交修改时,优先按界面数据导出,避免与数据库不一致。 if (hasChanges) { + await exportData(records, options); void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes')); - await exportData(records, format); return; } const config = buildConnConfig(); if (!config) { - await exportData(records, format); + await exportData(records, options); return; } const dbType = resolveDataSourceType(config); const pkWhere = buildPkWhereSql(records, dbType); if (!pkWhere) { - await exportData(records, format); + await exportData(records, options); return; } @@ -6292,7 +6277,7 @@ const DataGrid: React.FC = ({ columnNames: displayOutputColumnNames, whereSql: `WHERE ${pkWhere}`, }); - await exportByQuery(sql, format, tableName || 'export'); + await exportByQuery(sql, tableName || 'export', options, records.length); }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames, translateDataGrid]); const handleV2CellContextMenuAction = useCallback((action: V2CellContextMenuActionKey) => { @@ -6381,8 +6366,8 @@ const DataGrid: React.FC = ({ case 'export-json': case 'export-html': if (record) { - const format = action.replace('export-', ''); - handleExportSelected(format, record).catch(console.error); + const format = action.replace('export-', '') as DataExportDialogValues['format']; + handleExportSelected({ format }, record).catch(console.error); } closeMenu(); return; @@ -6417,96 +6402,124 @@ const DataGrid: React.FC = ({ ]); // Export - const handleExport = async (format: string) => { + const handleOpenExportDialog = useCallback(async () => { + const selectedCount = selectedRowKeys.length; + const allRowsLabel = (resultExportAllSql || resultSql) + ? '全部结果(重新查询)' + : `全部结果(当前缓存 ${mergedDisplayData.length} 条)`; + const commonInitialValues: Partial = { + format: DEFAULT_DATA_EXPORT_FORMAT, + xlsxMaxRowsPerSheet: DEFAULT_XLSX_ROWS_PER_SHEET, + }; + if (isQueryResultExport) { - openQueryResultExportScopeModal(format); + const scopeOptions: DataExportScopeOption[] = [ + { + value: 'selected', + label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行', + description: '仅导出当前结果集中已勾选的行。', + disabled: selectedCount <= 0, + }, + { + value: 'page', + label: `当前页 (${queryResultCurrentPageRows.length} 条)`, + description: '直接按当前结果页缓存导出。', + }, + { + value: 'all', + label: allRowsLabel, + description: (resultExportAllSql || resultSql) + ? '后台会重新执行 SQL,避免只导出当前页或当前缓存。' + : '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。', + }, + ]; + const values = await showDataExportDialog(modal, { + title: '导出查询结果', + scopeOptions, + initialValues: { + ...commonInitialValues, + scope: (resultExportAllSql || resultSql) ? 'all' : (selectedCount > 0 ? 'selected' : 'page'), + }, + }); + if (!values) return; + await exportQueryResultRows(values, values.scope as Exclude); return; } + if (!connectionId) return; - - // 1. Export Selected - if (selectedRowKeys.length > 0) { - const selectedRows = displayData.filter(d => selectedRowKeys.includes(d?.[GONAVI_ROW_KEY])); - await handleExportSelected(format, selectedRows[0]); - return; - } - - // 2. Prompt for Current vs All - // Using a custom modal content with buttons to handle 3 states - let instance: any; - const handleAll = async () => { - instance.destroy(); - if (!tableName) return; - const config = buildConnConfig(); - if (!config) return; - const sql = buildAllRowsSql(resolveDataSourceType(config)); - if (!sql) return; - await exportByQuery(sql, format, tableName || 'export'); - }; - const handlePage = async () => { - instance.destroy(); - if (hasChanges) { - void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes')); - await exportData(displayData, format); - return; - } - - const config = buildConnConfig(); - if (!config) { - await exportData(displayData, format); - return; - } - - const sql = buildCurrentPageSql(resolveDataSourceType(config)); - if (!sql) { - await exportData(displayData, format); - return; - } - - await exportByQuery(sql, format, tableName || 'export'); - }; - - instance = modal.info({ - title: translateDataGrid('data_grid.export.options_title'), - content: ( -
-

{translateDataGrid('data_grid.export.no_selection_prompt')}

-
- - - -
-
- ), - icon: , - okButtonProps: { style: { display: 'none' } }, // Hide default OK - maskClosable: true, - }); - }; - - const handleExportFilteredAll = async (format: string) => { - if (!connectionId || !tableName) return; - if (!filteredExportSql) { - void message.warning(translateDataGrid('data_grid.message.no_filter_applied')); - return; - } - if (!supportsSqlQueryExport) { - void message.error(translateDataGrid('data_grid.message.filtered_export_not_supported')); - return; - } const config = buildConnConfig(); - if (!config) return; - if (hasChanges) { - void message.warning(translateDataGrid('data_grid.message.filtered_export_uses_committed_data')); - } + const dbType = config ? resolveDataSourceType(config) : ''; + const currentPageSql = config && !hasChanges ? buildCurrentPageSql(dbType) : ''; + const filteredAllSql = config && supportsSqlQueryExport ? buildFilteredAllSql(dbType) : ''; + const allRowsSql = config && objectType !== 'table' ? buildAllRowsSql(dbType) : ''; + const hasKnownFilteredTotal = hasFilteredExportSql && pagination && pagination.totalKnown !== false; + const hasKnownAllTotal = !hasFilteredExportSql && pagination && pagination.totalKnown !== false; - const sql = buildFilteredAllSql(resolveDataSourceType(config)); - if (!sql) { - void message.warning(translateDataGrid('data_grid.message.no_filter_applied')); - return; - } - await exportByQuery(sql, format, `${tableName || 'export'}_filtered`); - }; + addTab(buildTableExportTab({ + connectionId, + dbName, + tableName: tableName || 'export', + title: `导出 ${tableName || '数据'}`, + objectType, + scopeOptions: [ + { + value: 'page', + label: `当前页 (${displayData.length} 条)`, + description: currentPageSql + ? '后台按当前分页条件重新查询后导出当前页。' + : '当前页依赖前端临时状态,建议直接使用快捷导出。', + disabled: !currentPageSql, + }, + ...(hasFilteredExportSql ? [{ + value: 'filteredAll' as const, + label: '筛选结果(全部)', + description: filteredAllSql + ? '按当前筛选条件重新查询数据库并导出全部筛选结果。' + : '当前数据源或当前状态暂不支持在工作台重放筛选导出。', + disabled: !filteredAllSql, + }] : []), + { + value: 'all', + label: '全表数据', + description: '后台重新查询整张表并导出全部数据。', + }, + ], + initialScope: hasFilteredExportSql && filteredAllSql ? 'filteredAll' : 'all', + queryByScope: { + ...(currentPageSql ? { page: currentPageSql } : {}), + ...(filteredAllSql ? { filteredAll: filteredAllSql } : {}), + ...(allRowsSql ? { all: allRowsSql } : {}), + }, + rowCountByScope: { + page: displayData.length, + ...(hasKnownFilteredTotal ? { filteredAll: Number(pagination?.total) } : {}), + ...(hasKnownAllTotal ? { all: Number(pagination?.total) } : {}), + }, + })); + }, [ + addTab, + buildAllRowsSql, + buildConnConfig, + buildCurrentPageSql, + buildFilteredAllSql, + connectionId, + dbName, + displayData.length, + exportQueryResultRows, + hasFilteredExportSql, + objectType, + isQueryResultExport, + mergedDisplayData.length, + modal, + pagination, + queryResultCurrentPageRows.length, + resultExportAllSql, + resultSql, + selectedRowKeys.length, + supportsSqlQueryExport, + tableName, + hasChanges, + ]); const handleImport = async () => { if (!connectionId || !tableName) return; @@ -6529,36 +6542,6 @@ const DataGrid: React.FC = ({ if (onReload) onReload(); }; - const exportMenu: MenuProps['items'] = isQueryResultExport ? [ - { key: 'query-csv', label: 'CSV', onClick: () => handleExport('csv') }, - { key: 'query-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, - { key: 'query-json', label: 'JSON', onClick: () => handleExport('json') }, - { key: 'query-md', label: 'Markdown', onClick: () => handleExport('md') }, - { key: 'query-html', label: 'HTML', onClick: () => handleExport('html') }, - ] : hasFilteredExportSql ? [ - { type: 'group', label: translateDataGrid('data_grid.export.group_filtered_results'), children: [ - { key: 'filtered-csv', label: 'CSV', onClick: () => handleExportFilteredAll('csv') }, - { key: 'filtered-xlsx', label: 'Excel (XLSX)', onClick: () => handleExportFilteredAll('xlsx') }, - { key: 'filtered-json', label: 'JSON', onClick: () => handleExportFilteredAll('json') }, - { key: 'filtered-md', label: 'Markdown', onClick: () => handleExportFilteredAll('md') }, - { key: 'filtered-html', label: 'HTML', onClick: () => handleExportFilteredAll('html') }, - ]}, - { type: 'divider' }, - { type: 'group', label: translateDataGrid('data_grid.export.group_full_table'), children: [ - { key: 'table-csv', label: 'CSV', onClick: () => handleExport('csv') }, - { key: 'table-xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') }, - { key: 'table-json', label: 'JSON', onClick: () => handleExport('json') }, - { key: 'table-md', label: 'Markdown', onClick: () => handleExport('md') }, - { key: 'table-html', label: 'HTML', onClick: () => handleExport('html') }, - ]}, - ] : [ - { 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') }, - { key: 'html', label: 'HTML', onClick: () => handleExport('html') }, - ]; - const queryResultCopyMenu: MenuProps['items'] = [ { key: 'csv', label: 'CSV', onClick: handleCopyQueryResultCsv }, { key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson }, @@ -7871,7 +7854,6 @@ const DataGrid: React.FC = ({ noAutoCapInputProps={noAutoCapInputProps as Record} filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} - exportMenu={exportMenu} queryResultCopyMenu={queryResultCopyMenu} dbType={dbType} onResetPendingChanges={handleResetPendingChanges} @@ -7890,6 +7872,7 @@ const DataGrid: React.FC = ({ onCommit={handleCommit} onPreviewChanges={handlePreviewChanges} onImport={handleImport} + onOpenExportModal={handleOpenExportDialog} onCopyQueryResultCsv={handleCopyQueryResultCsv} onRequestAiInsight={handleRequestAiInsight} onToggleTotalCount={handleToggleTotalCount} @@ -7942,6 +7925,7 @@ const DataGrid: React.FC = ({
{contextHolder} + {exportProgressModal} = ({ } }} onExportCsv={() => { - if (cellContextMenu.record) handleExportSelected('csv', cellContextMenu.record).catch(console.error); + if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error); }} onExportXlsx={() => { - if (cellContextMenu.record) handleExportSelected('xlsx', cellContextMenu.record).catch(console.error); + if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error); }} onExportJson={() => { - if (cellContextMenu.record) handleExportSelected('json', cellContextMenu.record).catch(console.error); + if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error); }} onExportHtml={() => { - if (cellContextMenu.record) handleExportSelected('html', cellContextMenu.record).catch(console.error); + if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error); }} />
diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index f7b3305..01ffec3 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -93,7 +93,7 @@ export interface DataGridToolbarFrameProps { noAutoCapInputProps: Record; filterFieldSelectStyle: React.CSSProperties; filterFieldPopupWidth: number; - exportMenu: MenuProps['items']; + onOpenExportModal: () => void; queryResultCopyMenu: MenuProps['items']; dbType: string; onResetPendingChanges: () => void; @@ -194,7 +194,7 @@ const DataGridToolbarFrame: React.FC = ({ noAutoCapInputProps, filterFieldSelectStyle, filterFieldPopupWidth, - exportMenu, + onOpenExportModal, queryResultCopyMenu, dbType, onResetPendingChanges, @@ -412,7 +412,7 @@ const DataGridToolbarFrame: React.FC = ({ <> {renderToolbarDivider()} {canImport && } - {canExport && } + {canExport && } )} diff --git a/frontend/src/components/ExportProgressBar.tsx b/frontend/src/components/ExportProgressBar.tsx new file mode 100644 index 0000000..01bc8ae --- /dev/null +++ b/frontend/src/components/ExportProgressBar.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Progress } from 'antd'; + +import { + resolveExportProgressPercent, + shouldUseIndeterminateExportProgress, + type ExportProgressStatus, +} from '../utils/exportProgress'; + +const INDETERMINATE_ANIMATION_NAME = 'gonavi-export-indeterminate-progress'; + +const normalizeCount = (value: unknown): number => { + const next = Number(value); + if (!Number.isFinite(next) || next < 0) { + return 0; + } + return Math.trunc(next); +}; + +type ExportProgressBarProps = { + status: ExportProgressStatus; + current: number; + total: number; + totalRowsKnown: boolean; +}; + +export const ExportProgressBar: React.FC = ({ + status, + current, + total, + totalRowsKnown, +}) => { + const isIndeterminate = shouldUseIndeterminateExportProgress(status, totalRowsKnown); + const progressStatus = status === 'error' + ? 'exception' + : (status === 'done' ? 'success' : 'active'); + + if (isIndeterminate) { + return ( +
+ +
+
+
+
+ ); + } + + const percent = resolveExportProgressPercent(status, current, total, totalRowsKnown); + return ( +
+ totalRowsKnown + ? `${normalizeCount(current)}/${normalizeCount(total)}` + : `${Math.round(percent)}%`} + /> +
+ ); +}; + +export default ExportProgressBar; diff --git a/frontend/src/components/ExportProgressModal.tsx b/frontend/src/components/ExportProgressModal.tsx new file mode 100644 index 0000000..194bd3a --- /dev/null +++ b/frontend/src/components/ExportProgressModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Button, Modal, Typography } from 'antd'; +import { + formatExportProgressRows, +} from '../utils/exportProgress'; +import ExportProgressBar from './ExportProgressBar'; +import { useExportProgressRunner } from './useExportProgressRunner'; + +const { Text, Paragraph } = Typography; + +export function useExportProgressDialog() { + const { state, reset, runExportWithProgress } = useExportProgressRunner(); + + const canClose = state.status === 'done' || state.status === 'error'; + + const modalNode = ( + 关闭, + ] : null} + > +
+
+ 任务 + {state.title || state.targetName || '导出任务'} + + 对象 + {state.targetName || '未命名对象'} + + 格式 + {state.format || '-'} + + 状态 + {state.stage || '准备中'} + + {state.filePath ? ( + <> + 文件 + {state.filePath} + + ) : null} +
+ + + +
+ {formatExportProgressRows(state.current, state.total, state.totalRowsKnown)} + {!state.totalRowsKnown && state.status !== 'done' && state.status !== 'error' ? ( + 当前未预先统计总行数,暂不显示百分比,写入行数为实时值。 + ) : null} + {state.message ? ( + {state.message} + ) : null} +
+
+
+ ); + + return { + exportProgressModal: modalNode, + runExportWithProgress, + }; +} diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 8a07b2a..7fbff76 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -1429,9 +1429,10 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('备份 · SQL Dump'); expect(markup).toContain('刷新统计信息'); expect(markup).toContain('导出表数据'); - expect(markup).toContain('Excel · .xlsx'); - expect(markup).toContain('CSV · .csv'); - expect(markup).toContain('JSON · .json'); + expect(markup).toContain('打开导出工作台…'); + expect(markup).not.toContain('Excel · .xlsx'); + expect(markup).not.toContain('CSV · .csv'); + expect(markup).not.toContain('JSON · .json'); expect(markup).not.toContain('Markdown · .md'); expect(markup).not.toContain('HTML · .html'); expect(markup).toContain('用 AI 解释这张表'); @@ -1570,19 +1571,10 @@ describe('Sidebar locate toolbar', () => { expect(exportSourceStart).toBeGreaterThanOrEqual(0); expect(exportSourceEnd).toBeGreaterThan(exportSourceStart); expect(exportSource).toContain("t('sidebar.menu.export_table_data')"); - expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'Excel', suffix: '.xlsx' })"); - expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'CSV', suffix: '.csv' })"); - expect(exportSource).toContain("t('sidebar.v2_table_menu.item_with_suffix', { label: 'JSON', suffix: '.json' })"); - expect(exportSource).toContain('Excel'); - expect(exportSource).toContain('CSV'); - expect(exportSource).toContain('JSON'); - expect(exportSource).toContain('.xlsx'); - expect(exportSource).toContain('.csv'); - expect(exportSource).toContain('.json'); + expect(exportSource).toContain("t('sidebar.v2_table_menu.open_export_workbench')"); + expect(exportSource).toContain("{ action: 'export-data'"); expect(exportSource).not.toContain('导出表数据'); - expect(exportSource).not.toContain("'Excel · .xlsx'"); - expect(exportSource).not.toContain("'CSV · .csv'"); - expect(exportSource).not.toContain("'JSON · .json'"); + expect(exportSource).not.toContain("'打开导出工作台…'"); expect(exportSource).not.toContain('用 AI 解释这张表'); }); @@ -2641,4 +2633,11 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('return !loadingNodesRef.current.has(loadKey);'); expect(source).toContain('对象仍在加载中'); }); + + it('resolves sidebar export workbench connection ids from live tree nodes instead of only reading dataRef.connectionId', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();"); + expect(source).not.toContain("const connectionId = String(node?.dataRef?.connectionId || '').trim();"); + }); }); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index caae396..4400e54 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -56,7 +56,7 @@ import { import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { ConnectionTag, SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -89,6 +89,8 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../util import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; +import { buildTableExportTab } from '../utils/tableExportTab'; +import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import { getCurrentLanguage, t } from '../i18n'; @@ -1155,6 +1157,7 @@ const Sidebar: React.FC<{ const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const { exportProgressModal, runExportWithProgress } = useExportProgressDialog(); const disableLocalBackdropFilter = isMacLikePlatform(); const autoFetchVisible = useAutoFetchVisibility(); const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); @@ -1479,7 +1482,6 @@ const Sidebar: React.FC<{ const [externalSQLFileForm] = Form.useForm(); const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); - // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); @@ -3787,23 +3789,53 @@ const Sidebar: React.FC<{ } }; - const handleExport = async (node: any, format: string) => { + const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => { const { config, dbName, tableName } = node.dataRef; - const hide = message.loading(t('sidebar.message.exporting_table_format', { - table: tableName, - format: format.toUpperCase(), - }), 0); - const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName, tableName, format); - hide(); - if (res.success) { - message.success(t('sidebar.message.export_success')); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.export_failed', { error: res.message })); + const rowCount = Number(node?.dataRef?.rowCount); + const totalRowsKnown = Number.isFinite(rowCount) && rowCount >= 0; + await runExportWithProgress({ + title: `导出 ${tableName}`, + targetName: tableName, + format: options.format, + totalRows: totalRowsKnown ? rowCount : undefined, + run: (jobId) => ExportTableWithOptions( + buildRpcConnectionConfig(config) as any, + dbName, + tableName, + { + ...options, + jobId, + totalRowsHint: totalRowsKnown ? rowCount : 0, + totalRowsKnown, + } as any, + ), + }); + }; + + const openExportDialog = async (node: any) => { + const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); + if (!tableName) { + message.warning('未识别到表名,无法导出'); + return; } + const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim(); + const dbName = String(node?.dataRef?.dbName || '').trim(); + addTab(buildTableExportTab({ + connectionId, + dbName, + tableName, + title: `导出 ${tableName}`, + objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'), + schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined, + sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined, + rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) >= 0 + ? { all: Math.trunc(Number(node.dataRef.rowCount)) } + : undefined, + })); }; const handleCopyTableAsInsert = async (node: any) => { - await handleExport(node, 'sql'); + await handleExport(node, { format: 'sql' }); }; const openTableDdlInDesigner = (node: any) => { @@ -5957,19 +5989,13 @@ const Sidebar: React.FC<{ openCreateStarRocksRollup(node); return; case 'backup-table': - void handleExport(node, 'sql'); + void handleExport(node, { format: 'sql' }); return; case 'refresh-stats': refreshV2TableContextMenuStats(node); return; - case 'export-xlsx': - void handleExport(node, 'xlsx'); - return; - case 'export-csv': - void handleExport(node, 'csv'); - return; - case 'export-json': - void handleExport(node, 'json'); + case 'export-data': + void openExportDialog(node); return; case 'ai-explain': void injectTablePromptToAI(node, 'explain'); @@ -8355,7 +8381,7 @@ const Sidebar: React.FC<{ key: 'backup-table', label: '备份表 (SQL)', icon: , - onClick: () => handleExport(node, 'sql') + onClick: () => handleExport(node, { format: 'sql' }) }, { key: 'rename-table', @@ -8398,15 +8424,9 @@ const Sidebar: React.FC<{ }, { key: 'export', - label: '导出表数据', + label: '导出表数据…', icon: , - children: [ - { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(node, 'csv') }, - { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(node, 'xlsx') }, - { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(node, 'json') }, - { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(node, 'md') }, - { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(node, 'html') }, - ] + onClick: () => openExportDialog(node), } ]; } @@ -9279,6 +9299,7 @@ const Sidebar: React.FC<{ return (
+ {exportProgressModal} {isV2Ui && renderV2ConnectionRail()}
{isV2Ui && ( diff --git a/frontend/src/components/SidebarTableExportFeedback.i18n.test.ts b/frontend/src/components/SidebarTableExportFeedback.i18n.test.ts index d28bba0..7d19f64 100644 --- a/frontend/src/components/SidebarTableExportFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarTableExportFeedback.i18n.test.ts @@ -3,12 +3,6 @@ import { describe, expect, it } from 'vitest'; const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; -const requiredKeys = [ - 'sidebar.message.exporting_table_format', - 'sidebar.message.export_success', - 'sidebar.message.export_failed', -] as const; - const extractHandleExportBlock = (): string => { const start = source.indexOf('const handleExport = async'); const end = source.indexOf('const handleCopyTableAsInsert', start); @@ -22,29 +16,22 @@ const placeholders = (value: string): string[] => [...value.matchAll(/\{\{(\w+)\ .sort(); describe('Sidebar table export feedback i18n', () => { - it('localizes handleExport loading, success, and failure wrappers', () => { + it('routes handleExport through the progress runner and option-based backend export', () => { const block = extractHandleExportBlock(); - expect(block).not.toContain('`正在导出 ${tableName} 为 ${format.toUpperCase()}...`'); - expect(block).not.toContain("message.success('导出成功')"); - expect(block).not.toContain("'导出失败: ' + res.message"); - expect(block).toContain("t('sidebar.message.exporting_table_format'"); - expect(block).toContain("t('sidebar.message.export_success')"); - expect(block).toContain("t('sidebar.message.export_failed'"); - expect(block).toContain('table: tableName'); - expect(block).toContain('format: format.toUpperCase()'); - expect(block).toContain('error: res.message'); + expect(block).not.toContain('ExportTable('); + expect(block).toContain('runExportWithProgress({'); + expect(block).toContain('ExportTableWithOptions('); + expect(block).toContain('jobId'); + expect(block).toContain('totalRowsHint'); + expect(block).toContain('totalRowsKnown'); }); - it('keeps table export feedback keys available with stable placeholders', () => { + it('keeps the export workbench entry key available across locales', () => { locales.forEach((locale) => { const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; - requiredKeys.forEach((key) => { - expect(catalog[key], `${locale}:${key}`).toBeTruthy(); - }); - expect(placeholders(catalog['sidebar.message.exporting_table_format'])).toEqual(['format', 'table']); - expect(placeholders(catalog['sidebar.message.export_success'])).toEqual([]); - expect(placeholders(catalog['sidebar.message.export_failed'])).toEqual(['error']); + expect(catalog['sidebar.v2_table_menu.open_export_workbench'], `${locale}:sidebar.v2_table_menu.open_export_workbench`).toBeTruthy(); + expect(placeholders(catalog['sidebar.v2_table_menu.open_export_workbench'])).toEqual([]); }); }); }); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 40ac1b4..b89dac3 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -17,6 +17,7 @@ import RedisMonitor from './RedisMonitor'; import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; import TableOverview from './TableOverview'; +import TableExportWorkbench from './TableExportWorkbench'; import JVMOverview from './JVMOverview'; import JVMResourceBrowser from './JVMResourceBrowser'; import JVMAuditViewer from './JVMAuditViewer'; @@ -46,6 +47,7 @@ const getTabKindLabel = (tab: TabData): string => { if (tab.type === 'table') return t('tab_manager.kind_badge.table'); if (tab.type === 'design') return t('tab_manager.kind_badge.design'); if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview'); + if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export'); if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis'); if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm'); if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger'); @@ -66,6 +68,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => { if (tab.type === 'table') return t('tab_manager.hover.kind.table'); if (tab.type === 'design') return t('tab_manager.hover.kind.design'); if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview'); + if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export'); if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys'); if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command'); if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor'); @@ -407,6 +410,9 @@ const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ if (tab.type === 'table-overview') { return ; } + if (tab.type === 'table-export') { + return ; + } if (tab.type === 'jvm-overview') { return ; } diff --git a/frontend/src/components/TableExportWorkbench.test.tsx b/frontend/src/components/TableExportWorkbench.test.tsx new file mode 100644 index 0000000..6222e75 --- /dev/null +++ b/frontend/src/components/TableExportWorkbench.test.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import TableExportWorkbench, { buildTableExportHistoryEntry } from './TableExportWorkbench'; + +const mockUpsertTableExportHistory = vi.fn(); +const createMockStoreState = () => ({ + theme: 'light', + connections: [ + { + id: 'conn-1', + name: '本地', + config: { + type: 'mysql', + host: 'localhost', + port: 3306, + user: 'root', + database: 'SYS', + }, + }, + ], + tableExportHistories: {}, + upsertTableExportHistory: mockUpsertTableExportHistory, +}); +const createMockProgressRunnerState = () => ({ + open: true, + jobId: 'job-1', + title: '导出 SYS.test', + targetName: 'SYS.test', + format: 'XLSX', + startedAt: 1_000, + finishedAt: 0, + status: 'running', + stage: '正在写入文件', + current: 259_000, + total: 0, + totalRowsKnown: false, + filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx', + message: '', +}); + +let mockStoreState = createMockStoreState(); +let mockProgressRunnerState = createMockProgressRunnerState(); + +vi.mock('../store', () => ({ + useStore: (selector: (state: any) => any) => selector(mockStoreState), +})); + +vi.mock('../../wailsjs/go/app/App', () => ({ + ExportQueryWithOptions: vi.fn(), + ExportTableWithOptions: vi.fn(), +})); + +vi.mock('./useExportProgressRunner', () => ({ + useExportProgressRunner: () => ({ + state: mockProgressRunnerState, + reset: vi.fn(), + runExportWithProgress: vi.fn(), + isRunning: ['start', 'running', 'finalizing'].includes(mockProgressRunnerState.status), + }), +})); + +describe('TableExportWorkbench', () => { + beforeEach(() => { + mockUpsertTableExportHistory.mockReset(); + mockStoreState = createMockStoreState(); + mockProgressRunnerState = createMockProgressRunnerState(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the redesigned workbench with a single main progress area and elapsed time', () => { + vi.spyOn(Date, 'now').mockReturnValue(61_000); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-export-workbench-layout="true"'); + expect(markup).toContain('data-export-workbench-main-progress="true"'); + expect(markup).toContain('data-export-progress-mode="indeterminate"'); + expect(markup).toContain('导出耗时'); + expect(markup).toContain('01:00'); + expect(markup).toContain('当前任务'); + expect(markup).toContain('最近任务'); + expect(markup).toContain('正在写入文件'); + expect(markup).toContain('暂不显示百分比'); + expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx'); + }); + + it('renders persisted history when reopening the workbench without an active export job', () => { + mockProgressRunnerState = { + open: false, + jobId: '', + title: '', + targetName: '', + format: '', + startedAt: 0, + finishedAt: 0, + status: 'idle', + stage: '', + current: 0, + total: 0, + totalRowsKnown: false, + filePath: '', + message: '', + }; + mockStoreState = { + ...createMockStoreState(), + tableExportHistories: { + 'conn-1::SYS::SYS.test': [ + { + jobId: 'job-finished-1', + targetName: 'SYS.test', + startedAt: 1_000, + finishedAt: 61_000, + format: 'XLSX', + scope: 'all', + scopeLabel: '全表数据', + strategyLabel: '整表导出链路', + status: 'done', + stage: '导出完成', + current: 500_000, + total: 500_000, + totalRowsKnown: true, + filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx', + message: '', + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('1 条记录'); + expect(markup).toContain('导出完成'); + expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx'); + }); + + it('keeps only one progress component in source and no longer uses top tabs', () => { + const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8'); + const progressMatches = source.match(/ { + const entry = buildTableExportHistoryEntry({ + progressState: { + ...createMockProgressRunnerState(), + startedAt: 8_000, + stage: '正在准备导出', + filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx', + }, + existingEntry: { + jobId: 'job-1', + targetName: 'SYS.test', + startedAt: 0, + finishedAt: 0, + format: 'XLSX', + scope: 'all', + scopeLabel: '全表数据', + strategyLabel: '整表导出链路', + status: 'start', + stage: '等待选择导出文件', + current: 0, + total: 500_000, + totalRowsKnown: true, + filePath: '', + message: '', + }, + fallbackTargetName: 'SYS.test', + fallbackFormat: 'XLSX', + scope: 'all', + scopeLabel: '全表数据', + strategyLabel: '整表导出链路', + }); + + expect(entry.startedAt).toBe(8_000); + expect(entry.filePath).toBe('/Users/yangguofeng/Desktop/SYS.test.xlsx'); + }); +}); diff --git a/frontend/src/components/TableExportWorkbench.tsx b/frontend/src/components/TableExportWorkbench.tsx new file mode 100644 index 0000000..0c57edd --- /dev/null +++ b/frontend/src/components/TableExportWorkbench.tsx @@ -0,0 +1,728 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Empty, InputNumber, Select, Typography } from 'antd'; +import { ClockCircleOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons'; +import { ExportQueryWithOptions, ExportTableWithOptions } from '../../wailsjs/go/app/App'; +import { useStore } from '../store'; +import type { + SavedConnection, + TabData, + TableExportHistoryEntry, + TableExportScope, + TableExportScopeOption, +} from '../types'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { resolveConnectionHostSummary } from '../utils/tabDisplay'; +import { buildTableExportHistoryKey } from '../utils/tableExportTab'; +import { + formatExportElapsed, + formatExportProgressRows, + resolveExportElapsedMs, + type ExportProgressStatus, +} from '../utils/exportProgress'; +import { + DATA_EXPORT_FORMAT_OPTIONS, + DEFAULT_DATA_EXPORT_FORMAT, + DEFAULT_XLSX_ROWS_PER_SHEET, + MAX_XLSX_ROWS_PER_SHEET, + type DataExportFormat, +} from './DataExportDialog'; +import ExportProgressBar from './ExportProgressBar'; +import { useExportProgressRunner } from './useExportProgressRunner'; +import type { ExportProgressState } from './useExportProgressRunner'; + +const { Text, Paragraph, Title } = Typography; +const EMPTY_HISTORY: TableExportHistoryEntry[] = []; + +const DEFAULT_SCOPE_OPTIONS: TableExportScopeOption[] = [ + { value: 'all', label: '全表数据', description: '后台重新查询整张表并导出全部数据。' }, +]; + +const normalizeScopeOptions = (input: TabData['tableExportScopeOptions']): TableExportScopeOption[] => { + if (!Array.isArray(input) || input.length === 0) { + return DEFAULT_SCOPE_OPTIONS; + } + return input; +}; + +const resolveInitialScope = ( + scopeOptions: TableExportScopeOption[], + preferred?: TableExportScope, +): TableExportScope => { + if (preferred && scopeOptions.some((item) => item.value === preferred && !item.disabled)) { + return preferred; + } + return scopeOptions.find((item) => !item.disabled)?.value || 'all'; +}; + +const normalizeConnectionConfig = (connection: SavedConnection) => ({ + ...connection.config, + port: Number(connection.config.port), + password: connection.config.password || '', + database: connection.config.database || '', + useSSH: connection.config.useSSH || false, + ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, +}); + +const formatDateTime = (timestamp: number): string => { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '-'; + return new Date(timestamp).toLocaleString('zh-CN', { hour12: false }); +}; + +const resolveObjectTypeLabel = (objectType?: TabData['objectType']): string => { + if (objectType === 'view') return '视图'; + if (objectType === 'materialized-view') return '物化视图'; + return '表'; +}; + +const STATUS_META: Record = { + idle: { label: '待开始', border: 'rgba(148, 163, 184, 0.35)', bg: 'rgba(148, 163, 184, 0.12)', text: '#475467' }, + start: { label: '准备中', border: 'rgba(59, 130, 246, 0.3)', bg: 'rgba(59, 130, 246, 0.12)', text: '#1d4ed8' }, + running: { label: '执行中', border: 'rgba(16, 185, 129, 0.3)', bg: 'rgba(16, 185, 129, 0.14)', text: '#047857' }, + finalizing: { label: '收尾中', border: 'rgba(249, 115, 22, 0.3)', bg: 'rgba(249, 115, 22, 0.12)', text: '#c2410c' }, + done: { label: '已完成', border: 'rgba(34, 197, 94, 0.3)', bg: 'rgba(34, 197, 94, 0.14)', text: '#15803d' }, + error: { label: '失败', border: 'rgba(239, 68, 68, 0.32)', bg: 'rgba(239, 68, 68, 0.12)', text: '#dc2626' }, +}; + +const renderStatusPill = (status: ExportProgressStatus) => { + const meta = STATUS_META[status] || STATUS_META.idle; + return ( + + {meta.label} + + ); +}; + +export const buildTableExportHistoryEntry = ({ + progressState, + existingEntry, + fallbackTargetName, + fallbackFormat, + scope, + scopeLabel, + strategyLabel, +}: { + progressState: ExportProgressState; + existingEntry?: TableExportHistoryEntry; + fallbackTargetName: string; + fallbackFormat: string; + scope: TableExportScope; + scopeLabel: string; + strategyLabel: string; +}): TableExportHistoryEntry => ({ + jobId: progressState.jobId, + targetName: progressState.targetName || fallbackTargetName || '未命名对象', + startedAt: progressState.startedAt || existingEntry?.startedAt || 0, + finishedAt: progressState.finishedAt || existingEntry?.finishedAt || 0, + format: progressState.format || existingEntry?.format || fallbackFormat, + scope, + scopeLabel: existingEntry?.scopeLabel || scopeLabel, + strategyLabel: existingEntry?.strategyLabel || strategyLabel, + status: progressState.status as ExportProgressStatus, + stage: progressState.stage, + current: progressState.current, + total: progressState.total, + totalRowsKnown: progressState.totalRowsKnown, + filePath: progressState.filePath, + message: progressState.message, +}); + +const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { + const connections = useStore((state) => state.connections); + const theme = useStore((state) => state.theme); + const exportHistoryKey = useMemo( + () => buildTableExportHistoryKey(tab.connectionId, tab.dbName, tab.tableName), + [tab.connectionId, tab.dbName, tab.tableName], + ); + const history = useStore((state) => state.tableExportHistories[exportHistoryKey] || EMPTY_HISTORY); + const upsertTableExportHistory = useStore((state) => state.upsertTableExportHistory); + const darkMode = theme === 'dark'; + const shellBg = darkMode ? '#101319' : '#f5f7fb'; + const panelBg = darkMode ? '#161b22' : '#ffffff'; + const panelBorder = darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)'; + const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'; + const headingColor = darkMode ? 'rgba(255,255,255,0.96)' : '#101828'; + const secondaryTextColor = darkMode ? 'rgba(255,255,255,0.68)' : '#667085'; + const subtleBg = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc'; + const pillBg = darkMode ? 'rgba(255,255,255,0.06)' : '#eef2f7'; + + const connection = useMemo( + () => connections.find((item) => item.id === tab.connectionId), + [connections, tab.connectionId], + ); + const connectionConfig = useMemo( + () => (connection ? normalizeConnectionConfig(connection) : null), + [connection], + ); + const scopeOptions = useMemo( + () => normalizeScopeOptions(tab.tableExportScopeOptions), + [tab.tableExportScopeOptions], + ); + const [scope, setScope] = useState(() => resolveInitialScope(scopeOptions, tab.tableExportInitialScope)); + const [format, setFormat] = useState(DEFAULT_DATA_EXPORT_FORMAT); + const [xlsxMaxRowsPerSheet, setXlsxMaxRowsPerSheet] = useState(DEFAULT_XLSX_ROWS_PER_SHEET); + const [nowTick, setNowTick] = useState(() => Date.now()); + const { state: progressState, reset, runExportWithProgress, isRunning } = useExportProgressRunner(); + + useEffect(() => { + setScope((prev) => { + if (scopeOptions.some((item) => item.value === prev && !item.disabled)) { + return prev; + } + return resolveInitialScope(scopeOptions, tab.tableExportInitialScope); + }); + }, [scopeOptions, tab.tableExportInitialScope]); + + useEffect(() => { + if (!progressState.startedAt || progressState.finishedAt > 0) return undefined; + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + return () => { + window.clearInterval(timer); + }; + }, [progressState.startedAt, progressState.finishedAt, isRunning]); + + const hostSummary = useMemo( + () => resolveConnectionHostSummary(connection?.config), + [connection?.config], + ); + const activeScopeOption = useMemo( + () => scopeOptions.find((item) => item.value === scope), + [scope, scopeOptions], + ); + const activeScopeLabel = activeScopeOption?.label || scope; + const activeScopeQuery = useMemo( + () => String(tab.tableExportQueryByScope?.[scope] || '').trim(), + [scope, tab.tableExportQueryByScope], + ); + const activeScopeRowCount = useMemo(() => { + const raw = tab.tableExportRowCountByScope?.[scope]; + return Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : undefined; + }, [scope, tab.tableExportRowCountByScope]); + const totalRowsKnown = typeof activeScopeRowCount === 'number'; + const exportStrategyLabel = scope === 'all' && !activeScopeQuery ? '整表导出链路' : 'SQL 重放导出'; + + useEffect(() => { + const jobId = String(progressState.jobId || '').trim(); + if (!jobId) return; + const existingEntry = history.find((item) => item.jobId === jobId); + const entry = buildTableExportHistoryEntry({ + progressState, + existingEntry, + fallbackTargetName: tab.tableName || '未命名对象', + fallbackFormat: String(format || '').toUpperCase(), + scope, + scopeLabel: activeScopeLabel, + strategyLabel: exportStrategyLabel, + }); + upsertTableExportHistory(exportHistoryKey, entry); + }, [ + activeScopeLabel, + exportHistoryKey, + format, + history, + progressState.current, + progressState.filePath, + progressState.finishedAt, + progressState.format, + progressState.jobId, + progressState.message, + progressState.stage, + progressState.startedAt, + progressState.status, + progressState.targetName, + progressState.total, + progressState.totalRowsKnown, + scope, + tab.tableName, + exportStrategyLabel, + upsertTableExportHistory, + ]); + + const canStart = !!connectionConfig && !!tab.tableName && !!scope && !activeScopeOption?.disabled && (scope === 'all' || !!activeScopeQuery); + const currentElapsedMs = useMemo( + () => resolveExportElapsedMs(progressState.startedAt, progressState.finishedAt, nowTick), + [nowTick, progressState.finishedAt, progressState.startedAt], + ); + const historyEntries = useMemo( + () => history.filter((entry) => entry.jobId !== progressState.jobId), + [history, progressState.jobId], + ); + const currentHistoryEntry = useMemo( + () => history.find((entry) => entry.jobId === progressState.jobId), + [history, progressState.jobId], + ); + const currentScopeLabel = currentHistoryEntry?.scopeLabel || activeScopeLabel; + const currentStrategyLabel = currentHistoryEntry?.strategyLabel || exportStrategyLabel; + + const handleStartExport = async () => { + if (!connectionConfig) { + return; + } + const objectName = String(tab.tableName || '').trim(); + if (!objectName) { + return; + } + await runExportWithProgress({ + title: tab.title || `导出 ${objectName}`, + targetName: objectName, + format, + totalRows: activeScopeRowCount, + run: (jobId) => { + const options = { + format, + xlsxMaxRowsPerSheet, + jobId, + totalRowsHint: totalRowsKnown ? activeScopeRowCount : 0, + totalRowsKnown, + }; + if (scope !== 'all' && activeScopeQuery) { + return ExportQueryWithOptions( + buildRpcConnectionConfig(connectionConfig) as any, + tab.dbName || '', + activeScopeQuery, + objectName, + options as any, + ); + } + if (scope === 'all' && activeScopeQuery) { + return ExportQueryWithOptions( + buildRpcConnectionConfig(connectionConfig) as any, + tab.dbName || '', + activeScopeQuery, + objectName, + options as any, + ); + } + return ExportTableWithOptions( + buildRpcConnectionConfig(connectionConfig) as any, + tab.dbName || '', + objectName, + options as any, + ); + }, + }); + }; + + return ( +
+
+
+ 导出工作台 +
+ 在同一页内配置导出、观察主进度,并回看最近任务摘要。 +
+
+
+ {[ + `${resolveObjectTypeLabel(tab.objectType)} · ${tab.tableName || '-'}`, + `数据库 · ${tab.dbName || '-'}`, + `连接 · ${connection?.name || '-'}`, + `Host · ${hostSummary || '-'}`, + ].map((label) => ( + + {label} + + ))} +
+
+ +
+
+
+
导出配置
+
+ 对象 + {tab.tableName || '-'} + + 类型 + {resolveObjectTypeLabel(tab.objectType)} + + 连接 + {connection?.name || '-'} + + 数据库 + {tab.dbName || '-'} + + Host + {hostSummary || '-'} +
+
+ + {!connectionConfig ? ( + + ) : null} + +
+
+
导出范围
+ setFormat(next as DataExportFormat)} + /> +
+ + {format === 'xlsx' ? ( +
+
每个工作表最大行数
+ { + const next = Number(value); + setXlsxMaxRowsPerSheet( + Number.isFinite(next) && next > 0 + ? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(next)) + : DEFAULT_XLSX_ROWS_PER_SHEET, + ); + }} + /> +
+ 仅 XLSX 生效,最大 {MAX_XLSX_ROWS_PER_SHEET.toLocaleString()} 行(不含表头) +
+
+ ) : null} +
+ + {scope !== 'all' && !activeScopeQuery ? ( + + ) : null} + +
+
+ 预计行数 + {typeof activeScopeRowCount === 'number' ? activeScopeRowCount.toLocaleString() : '当前未预先统计'} + + 执行链路 + {exportStrategyLabel} +
+
+ 导出开始后会先选择目标文件,再在右侧主面板展示唯一进度条、导出耗时和输出路径。 +
+ +
+
+ +
+
+
+
+
+
当前任务
+ {renderStatusPill(progressState.status)} +
+ + {progressState.title || `导出 ${tab.tableName || '未命名对象'}`} + +
+ {progressState.jobId + ? `${progressState.targetName || tab.tableName || '-'} · ${currentScopeLabel} · ${progressState.format || String(format).toUpperCase()}` + : '开始导出后,这里会展示当前任务的唯一主进度。'} +
+
+ +
+
+
导出耗时
+
+ + {progressState.startedAt ? formatExportElapsed(currentElapsedMs) : '--:--'} +
+
+
+
开始时间
+
{formatDateTime(progressState.startedAt)}
+
+
+
导出范围
+
{progressState.jobId ? currentScopeLabel : '-'}
+
+
+
执行链路
+
{progressState.jobId ? currentStrategyLabel : '-'}
+
+
+
+ + {progressState.jobId ? ( + <> +
+ +
+ +
+
+
当前阶段
+ + {progressState.stage || STATUS_META[progressState.status]?.label || '等待开始'} + +
进度说明
+ {formatExportProgressRows(progressState.current, progressState.total, progressState.totalRowsKnown)} + {!progressState.totalRowsKnown && progressState.status !== 'done' && progressState.status !== 'error' ? ( +
+ 当前未预先统计总行数,暂不显示百分比,写入行数为实时值。 +
+ ) : null} +
+
+
输出文件
+ {progressState.filePath ? ( + {progressState.filePath} + ) : ( + 等待选择目标文件路径 + )} +
+
+ + {progressState.message ? ( + + ) : null} + + {(progressState.status === 'done' || progressState.status === 'error') ? ( +
+ +
+ ) : null} + + ) : ( +
+ +
+ )} +
+ +
+
+
+
最近任务
+
+ 当前任务不在这里重复展示,历史区只保留已结束或已切换开的摘要记录。 +
+
+
+ {historyEntries.length} 条记录 +
+
+ + {historyEntries.length > 0 ? ( +
+ {historyEntries.map((entry, index) => ( +
+
+
+ {entry.targetName} + {renderStatusPill(entry.status)} + + {entry.scopeLabel} · {entry.format || '-'} + +
+
+ {entry.stage || STATUS_META[entry.status].label} +
+
+ {formatExportProgressRows(entry.current, entry.total, entry.totalRowsKnown)} +
+ {entry.message ? ( +
{entry.message}
+ ) : null} +
+ +
+
+ 开始时间 + {formatDateTime(entry.startedAt)} + + 导出耗时 + {formatExportElapsed(resolveExportElapsedMs(entry.startedAt, entry.finishedAt, nowTick))} + + 文件 + + {entry.filePath || '-'} + +
+
+
+ ))} +
+ ) : ( +
+ 暂无历史任务。完成一次导出后,这里会保留最近任务的摘要。 +
+ )} +
+
+
+
+ ); +}; + +export default TableExportWorkbench; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 35a7e4f..23e1d8f 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -4,7 +4,7 @@ import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'a import type { MenuProps } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; import { buildSidebarTablePinKey, useStore } from '../store'; -import { DBGetTables, DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App'; +import { DBGetTables, DBQuery, DBShowCreateTable, ExportTableWithOptions, DropTable, RenameTable } from '../../wailsjs/go/app/App'; import type { TabData } from '../types'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; @@ -24,7 +24,9 @@ import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { isMacLikePlatform } from '../utils/appearance'; import { getShortcutPlatform } from '../utils/shortcuts'; import { t } from '../i18n'; +import { buildTableExportTab } from '../utils/tableExportTab'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; +import { useExportProgressDialog } from './ExportProgressModal'; interface TableOverviewProps { tab: TabData; @@ -264,6 +266,7 @@ const TableOverview: React.FC = ({ tab }) => { const [sortOrder, setSortOrder] = useState('asc'); const [viewMode, setViewMode] = useState(isV2Ui ? 'card' : 'list'); const [v2ContextMenu, setV2ContextMenu] = useState(null); + const { exportProgressModal, runExportWithProgress } = useExportProgressDialog(); const v2ContextMenuPortalRef = useRef(null); const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE); const deferredSearchText = useDeferredValue(searchText); @@ -535,21 +538,44 @@ const TableOverview: React.FC = ({ tab }) => { } }, []); - const handleExport = useCallback(async (tableName: string, format: string) => { + const handleExport = useCallback(async (tableName: string, options: { format: string; xlsxMaxRowsPerSheet?: number }, totalRows?: number) => { const config = buildConfig(); if (!config) return; - const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0); - const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format); - hide(); - if (res.success) { - message.success('导出成功'); - } else if (res.message !== '已取消') { - message.error('导出失败: ' + res.message); - } - }, [buildConfig, tab.dbName]); + const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0; + await runExportWithProgress({ + title: `导出 ${tableName}`, + targetName: tableName, + format: options.format, + totalRows: totalRowsKnown ? Number(totalRows) : undefined, + run: (jobId) => ExportTableWithOptions( + buildRpcConnectionConfig(config) as any, + tab.dbName || '', + tableName, + { + ...options, + jobId, + totalRowsHint: totalRowsKnown ? Number(totalRows) : 0, + totalRowsKnown, + } as any, + ), + }); + }, [buildConfig, runExportWithProgress, tab.dbName]); + + const openExportDialog = useCallback(async (tableName: string, totalRows?: number) => { + addTab(buildTableExportTab({ + connectionId: tab.connectionId, + dbName: tab.dbName, + tableName, + title: `导出 ${tableName}`, + objectType: 'table', + rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) >= 0 + ? { all: Math.trunc(Number(totalRows)) } + : undefined, + })); + }, [addTab, tab.connectionId, tab.dbName]); const handleCopyTableAsInsert = useCallback(async (tableName: string) => { - await handleExport(tableName, 'sql'); + await handleExport(tableName, { format: 'sql' }); }, [handleExport]); const handleDeleteTable = useCallback((tableName: string) => { @@ -813,19 +839,13 @@ const TableOverview: React.FC = ({ tab }) => { openCreateStarRocksRollup(tableName); return; case 'backup-table': - void handleExport(tableName, 'sql'); + void handleExport(tableName, { format: 'sql' }); return; case 'refresh-stats': void loadData(); return; - case 'export-xlsx': - void handleExport(tableName, 'xlsx'); - return; - case 'export-csv': - void handleExport(tableName, 'csv'); - return; - case 'export-json': - void handleExport(tableName, 'json'); + case 'export-data': + void openExportDialog(tableName, tables.find((item) => item.name === tableName)?.rows); return; case 'ai-explain': void injectTablePromptToAI(tableName, 'explain'); @@ -850,6 +870,7 @@ const TableOverview: React.FC = ({ tab }) => { handleExport, handleRenameTable, handleTableDataDangerAction, + openExportDialog, injectTablePromptToAI, loadData, openCreateStarRocksRollup, @@ -858,6 +879,7 @@ const TableOverview: React.FC = ({ tab }) => { openTable, openTableDdl, openTableInER, + tables, toggleOverviewTablePinned, ]); @@ -887,7 +909,7 @@ const TableOverview: React.FC = ({ tab }) => { { key: 'design-table', label: supportsDesignWrite ? '设计表' : '表结构', icon: , onClick: () => openDesign(table.name) }, { key: 'copy-table-name', label: '复制表名', icon: , onClick: () => handleCopyTableName(table.name) }, { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(table.name) }, - { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(table.name, 'sql') }, + { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(table.name, { format: 'sql' }) }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(table.name) }, { key: 'danger-zone', label: '危险操作', icon: , children: [ ...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []), @@ -895,13 +917,7 @@ const TableOverview: React.FC = ({ tab }) => { { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(table.name) }, ]}, { type: 'divider' }, - { key: 'export', label: '导出表数据', icon: , children: [ - { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') }, - { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') }, - { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') }, - { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') }, - { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') }, - ]}, + { key: 'export', label: '导出表数据…', icon: , onClick: () => openExportDialog(table.name, table.rows) }, ], [ allowTruncate, handleCopyStructure, @@ -910,6 +926,7 @@ const TableOverview: React.FC = ({ tab }) => { handleExport, handleRenameTable, handleTableDataDangerAction, + openExportDialog, openDesign, openQueryForTable, supportsDesignWrite, @@ -1128,6 +1145,7 @@ const TableOverview: React.FC = ({ tab }) => { return (
+ {exportProgressModal} {/* Toolbar */}
diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index c2303d7..7c8904d 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -52,9 +52,7 @@ export type V2TableContextMenuActionKey = | 'new-rollup' | 'backup-table' | 'refresh-stats' - | 'export-xlsx' - | 'export-csv' - | 'export-json' + | 'export-data' | 'ai-explain' | 'ai-generate-query' | 'truncate-table' @@ -248,9 +246,7 @@ export const V2TableContextMenuView: React.FC<{
{t('sidebar.menu.export_table_data')}
{renderItems([ - { action: 'export-xlsx', icon: , title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'Excel', suffix: '.xlsx' }) }, - { action: 'export-csv', icon: , title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'CSV', suffix: '.csv' }) }, - { action: 'export-json', icon: , title: t('sidebar.v2_table_menu.item_with_suffix', { label: 'JSON', suffix: '.json' }) }, + { action: 'export-data', icon: , title: t('sidebar.v2_table_menu.open_export_workbench') }, ])}
diff --git a/frontend/src/components/useExportProgressRunner.test.tsx b/frontend/src/components/useExportProgressRunner.test.tsx new file mode 100644 index 0000000..382b52b --- /dev/null +++ b/frontend/src/components/useExportProgressRunner.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useExportProgressRunner } from './useExportProgressRunner'; + +const runtimeApi = vi.hoisted(() => { + let exportProgressHandler: ((event: any) => void) | null = null; + return { + EventsOn: vi.fn((eventName: string, handler: (event: any) => void) => { + if (eventName === 'export:progress') { + exportProgressHandler = handler; + } + return () => { + if (exportProgressHandler === handler) { + exportProgressHandler = null; + } + }; + }), + emitExportProgress: (event: any) => { + exportProgressHandler?.(event); + }, + reset: () => { + exportProgressHandler = null; + }, + }; +}); + +const messageApi = vi.hoisted(() => ({ + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../wailsjs/runtime/runtime', () => runtimeApi); + +vi.mock('antd', () => ({ + message: messageApi, +})); + +describe('useExportProgressRunner', () => { + let runner: ReturnType | null = null; + let renderer: ReactTestRenderer | null = null; + let now = 1_000; + + const renderRunner = () => { + const Harness = () => { + runner = useExportProgressRunner({ showToast: false }); + return null; + }; + + act(() => { + renderer = create(); + }); + }; + + beforeEach(() => { + runner = null; + renderer = null; + now = 1_000; + runtimeApi.reset(); + runtimeApi.EventsOn.mockClear(); + messageApi.warning.mockReset(); + messageApi.success.mockReset(); + messageApi.error.mockReset(); + vi.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + act(() => { + renderer?.unmount(); + }); + vi.restoreAllMocks(); + }); + + it('starts elapsed timing only after backend progress begins and path is selected', async () => { + renderRunner(); + + let resolveRun!: (value: { success: boolean; message: string }) => void; + const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => { + resolveRun = resolve; + }); + + let runPromise: Promise<{ success: boolean; message: string } | null> | null = null; + await act(async () => { + runPromise = runner?.runExportWithProgress({ + title: '导出 SYS.test', + targetName: 'SYS.test', + format: 'xlsx', + totalRows: 500_000, + run: async () => pendingRun, + }) || null; + await Promise.resolve(); + }); + + expect(runner?.state.status).toBe('start'); + expect(runner?.state.stage).toBe('等待选择导出文件'); + expect(runner?.state.startedAt).toBe(0); + expect(runner?.state.filePath).toBe(''); + + const jobId = runner?.state.jobId || ''; + expect(jobId).not.toBe(''); + + now = 8_000; + act(() => { + runtimeApi.emitExportProgress({ + jobId, + status: 'start', + stage: '正在准备导出', + filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx', + }); + }); + + expect(runner?.state.status).toBe('start'); + expect(runner?.state.stage).toBe('正在准备导出'); + expect(runner?.state.startedAt).toBe(8_000); + expect(runner?.state.filePath).toBe('/Users/yangguofeng/Desktop/SYS.test.xlsx'); + + now = 13_000; + await act(async () => { + resolveRun({ success: true, message: '导出完成' }); + await runPromise; + }); + + expect(runner?.state.status).toBe('done'); + expect(runner?.state.startedAt).toBe(8_000); + expect(runner?.state.finishedAt).toBe(13_000); + }); +}); diff --git a/frontend/src/components/useExportProgressRunner.ts b/frontend/src/components/useExportProgressRunner.ts new file mode 100644 index 0000000..0403ec2 --- /dev/null +++ b/frontend/src/components/useExportProgressRunner.ts @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { message } from 'antd'; +import { EventsOn } from '../../wailsjs/runtime/runtime'; +import type { ExportProgressStatus } from '../utils/exportProgress'; + +export type ExportProgressEvent = { + jobId: string; + status?: ExportProgressStatus; + stage?: string; + current?: number; + total?: number; + totalRowsKnown?: boolean; + format?: string; + targetName?: string; + filePath?: string; + message?: string; +}; + +export type ExportProgressState = { + open: boolean; + jobId: string; + title: string; + targetName: string; + format: string; + startedAt: number; + finishedAt: number; + status: ExportProgressStatus; + stage: string; + current: number; + total: number; + totalRowsKnown: boolean; + filePath: string; + message: string; +}; + +export type ExportRunResult = { + success: boolean; + message: string; +}; + +export type RunExportWithProgressOptions = { + title: string; + targetName: string; + format: string; + totalRows?: number; + run: (jobId: string) => Promise; +}; + +type UseExportProgressRunnerOptions = { + showToast?: boolean; +}; + +const createInitialState = (): ExportProgressState => ({ + open: false, + jobId: '', + title: '', + targetName: '', + format: '', + startedAt: 0, + finishedAt: 0, + status: 'idle', + stage: '', + current: 0, + total: 0, + totalRowsKnown: false, + filePath: '', + message: '', +}); + +const normalizeCount = (value: unknown): number => { + const next = Number(value); + if (!Number.isFinite(next) || next < 0) { + return 0; + } + return Math.trunc(next); +}; + +const buildExportJobId = (): string => `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const isActiveExportStatus = (status: ExportProgressStatus): boolean => + status === 'start' || status === 'running' || status === 'finalizing' || status === 'done' || status === 'error'; + +export function useExportProgressRunner(options?: UseExportProgressRunnerOptions) { + const showToast = options?.showToast !== false; + const [state, setState] = useState(() => createInitialState()); + const activeJobIdRef = useRef(''); + + useEffect(() => { + const off = EventsOn('export:progress', (event: ExportProgressEvent) => { + if (!event || String(event.jobId || '') !== activeJobIdRef.current) { + return; + } + + setState((prev) => { + if (prev.jobId !== activeJobIdRef.current) { + return prev; + } + const nextStatus = (event.status || prev.status || 'running') as ExportProgressStatus; + const nextStartedAt = prev.startedAt > 0 || !isActiveExportStatus(nextStatus) + ? prev.startedAt + : Date.now(); + const nextTotalRowsKnown = typeof event.totalRowsKnown === 'boolean' ? event.totalRowsKnown : prev.totalRowsKnown; + const nextTotal = nextTotalRowsKnown + ? normalizeCount(typeof event.total === 'number' ? event.total : prev.total) + : prev.total; + return { + ...prev, + open: true, + startedAt: nextStartedAt, + status: nextStatus, + finishedAt: (nextStatus === 'done' || nextStatus === 'error') + ? (prev.finishedAt || Date.now()) + : prev.finishedAt, + stage: typeof event.stage === 'string' && event.stage.trim() ? event.stage.trim() : prev.stage, + current: normalizeCount(typeof event.current === 'number' ? event.current : prev.current), + total: nextTotal, + totalRowsKnown: nextTotalRowsKnown, + format: typeof event.format === 'string' && event.format.trim() ? String(event.format).toUpperCase() : prev.format, + targetName: typeof event.targetName === 'string' && event.targetName.trim() ? event.targetName.trim() : prev.targetName, + filePath: typeof event.filePath === 'string' && event.filePath.trim() ? event.filePath.trim() : prev.filePath, + message: typeof event.message === 'string' ? event.message : prev.message, + }; + }); + }); + + return () => { + if (typeof off === 'function') off(); + }; + }, []); + + const reset = useCallback(() => { + activeJobIdRef.current = ''; + setState(createInitialState()); + }, []); + + const runExportWithProgress = useCallback(async ( + runOptions: RunExportWithProgressOptions, + ): Promise => { + if (state.open && (state.status === 'start' || state.status === 'running' || state.status === 'finalizing')) { + if (showToast) { + void message.warning('当前已有导出任务正在执行,请等待完成后再发起新的导出'); + } + return null; + } + + const jobId = buildExportJobId(); + const totalRowsKnown = Number.isFinite(runOptions.totalRows) && Number(runOptions.totalRows) >= 0; + activeJobIdRef.current = jobId; + setState({ + open: true, + jobId, + title: runOptions.title, + targetName: String(runOptions.targetName || '').trim(), + format: String(runOptions.format || '').trim().toUpperCase(), + startedAt: 0, + finishedAt: 0, + status: 'start', + stage: '等待选择导出文件', + current: 0, + total: totalRowsKnown ? normalizeCount(runOptions.totalRows) : 0, + totalRowsKnown, + filePath: '', + message: '', + }); + + try { + const result = await runOptions.run(jobId); + if (result.success) { + setState((prev) => { + if (prev.jobId !== jobId) { + return prev; + } + return { + ...prev, + open: true, + status: 'done', + finishedAt: prev.finishedAt || Date.now(), + stage: prev.stage || '导出完成', + current: prev.totalRowsKnown ? Math.max(prev.current, prev.total) : prev.current, + message: '', + }; + }); + if (showToast) { + void message.success('导出成功'); + } + } else if (result.message !== '已取消') { + setState((prev) => { + if (prev.jobId !== jobId) { + return prev; + } + return { + ...prev, + open: true, + status: 'error', + finishedAt: prev.finishedAt || Date.now(), + stage: prev.stage || '导出失败', + message: result.message, + }; + }); + if (showToast) { + void message.error(`导出失败: ${result.message}`); + } + } else { + reset(); + } + return result; + } catch (error: any) { + const errorMessage = error?.message || String(error); + setState((prev) => { + if (prev.jobId !== jobId) { + return prev; + } + return { + ...prev, + open: true, + status: 'error', + finishedAt: prev.finishedAt || Date.now(), + stage: prev.stage || '导出失败', + message: errorMessage, + }; + }); + if (showToast) { + void message.error(`导出失败: ${errorMessage}`); + } + throw error; + } + }, [reset, showToast, state.open, state.status]); + + return { + state, + reset, + runExportWithProgress, + isRunning: state.status === 'start' || state.status === 'running' || state.status === 'finalizing', + }; +} diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 213aef1..d8a9de5 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1110,6 +1110,79 @@ describe('store appearance persistence', () => { }); }); + it('reuses the same table-export tab for the same connection and table identity', async () => { + const { useStore } = await importStore(); + + useStore.getState().addTab({ + id: 'table-export-conn-1-main-users', + title: '导出 users', + type: 'table-export', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'users', + initialTab: 'config', + }); + useStore.getState().addTab({ + id: 'another-id-that-should-collapse', + title: '导出 users', + type: 'table-export', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'users', + initialTab: 'progress', + }); + + expect(useStore.getState().tabs).toHaveLength(1); + expect(useStore.getState().tabs[0]).toEqual(expect.objectContaining({ + id: 'table-export-conn-1-main-users', + type: 'table-export', + initialTab: 'progress', + })); + expect(useStore.getState().activeTabId).toBe('table-export-conn-1-main-users'); + }); + + it('persists table export history across store reloads', async () => { + const { useStore } = await importStore(); + + useStore.getState().upsertTableExportHistory('conn-1::main::users', { + jobId: 'job-1', + targetName: 'users', + startedAt: 1_000, + finishedAt: 61_000, + format: 'XLSX', + scope: 'all', + scopeLabel: '全表数据', + strategyLabel: '整表导出链路', + status: 'done', + stage: '导出完成', + current: 500_000, + total: 500_000, + totalRowsKnown: true, + filePath: '/tmp/users.xlsx', + message: '', + }); + + const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}'); + expect(persisted.state.tableExportHistories['conn-1::main::users']).toEqual([ + expect.objectContaining({ + jobId: 'job-1', + status: 'done', + filePath: '/tmp/users.xlsx', + }), + ]); + + vi.resetModules(); + const reloaded = await importStore(); + expect(reloaded.useStore.getState().tableExportHistories['conn-1::main::users']).toEqual([ + expect.objectContaining({ + jobId: 'job-1', + current: 500_000, + total: 500_000, + status: 'done', + }), + ]); + }); + it('only restores persisted query tabs with useful SQL state', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 686e5bf..b7f7b74 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -19,6 +19,7 @@ import { JVMDiagnosticCommandDraft, JVMDiagnosticEventChunk, SqlSnippet, + TableExportHistoryEntry, } from "./types"; import { ShortcutAction, @@ -125,7 +126,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; -const PERSIST_VERSION = 11; +const PERSIST_VERSION = 12; const PERSIST_STORAGE_KEY = "lite-db-storage"; const PERSIST_WRITE_DEBOUNCE_MS = 160; const MAX_PERSISTED_QUERY_TABS = 20; @@ -134,6 +135,8 @@ const MAX_SQL_LOGS = 1000; const MAX_PERSISTED_SQL_LOGS = 200; const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024; const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024; +const MAX_TABLE_EXPORT_HISTORY_PER_TARGET = 20; +const MAX_TABLE_EXPORT_HISTORY_TARGETS = 200; const DEFAULT_CONNECTION_TYPE = "mysql"; const DEFAULT_JVM_PORT = 9010; const DEFAULT_LANGUAGE_PREFERENCE: LanguagePreference = "system"; @@ -1217,6 +1220,7 @@ interface AppState { shortcutOptions: ShortcutOptions; sqlSnippets: SqlSnippet[]; sqlLogs: SqlLog[]; + tableExportHistories: Record; tableAccessCount: Record; tableSortPreference: Record; tableColumnOrders: Record; @@ -1348,6 +1352,10 @@ interface AppState { addSqlLog: (log: SqlLog) => void; clearSqlLogs: () => void; + upsertTableExportHistory: ( + historyKey: string, + entry: TableExportHistoryEntry, + ) => void; recordTableAccess: ( connectionId: string, @@ -1490,6 +1498,95 @@ const sanitizeExternalSQLDirectories = ( return result; }; +const sanitizeTableExportHistoryEntry = ( + value: unknown, +): TableExportHistoryEntry | null => { + if (!value || typeof value !== "object") { + return null; + } + const raw = value as Record; + const jobId = toTrimmedString(raw.jobId); + if (!jobId) { + return null; + } + const normalizeCount = (input: unknown): number => { + const next = Number(input); + if (!Number.isFinite(next) || next < 0) { + return 0; + } + return Math.trunc(next); + }; + const normalizeTimestamp = (input: unknown): number => { + const next = Number(input); + if (!Number.isFinite(next) || next <= 0) { + return 0; + } + return Math.trunc(next); + }; + const statusRaw = toTrimmedString(raw.status).toLowerCase(); + const status: TableExportHistoryEntry["status"] = + statusRaw === "start" || + statusRaw === "running" || + statusRaw === "finalizing" || + statusRaw === "done" || + statusRaw === "error" + ? statusRaw + : "idle"; + return { + jobId, + targetName: toTrimmedString(raw.targetName, "未命名对象") || "未命名对象", + startedAt: normalizeTimestamp(raw.startedAt), + finishedAt: normalizeTimestamp(raw.finishedAt), + format: toTrimmedString(raw.format).slice(0, 32), + scope: toTrimmedString(raw.scope).slice(0, 64), + scopeLabel: toTrimmedString(raw.scopeLabel).slice(0, 128), + strategyLabel: toTrimmedString(raw.strategyLabel).slice(0, 128), + status, + stage: toTrimmedString(raw.stage).slice(0, 256), + current: normalizeCount(raw.current), + total: normalizeCount(raw.total), + totalRowsKnown: raw.totalRowsKnown === true, + filePath: toTrimmedString(raw.filePath).slice(0, MAX_URI_LENGTH), + message: toTrimmedString(raw.message).slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH), + }; +}; + +const sanitizeTableExportHistories = ( + value: unknown, +): Record => { + if (!value || typeof value !== "object") { + return {}; + } + const raw = value as Record; + const entries = Object.entries(raw) + .filter(([key, history]) => toTrimmedString(key) && Array.isArray(history)) + .slice(0, MAX_TABLE_EXPORT_HISTORY_TARGETS); + const result: Record = {}; + entries.forEach(([key, history]) => { + const seenJobIds = new Set(); + const sanitizedHistory = (history as unknown[]) + .map((entry) => sanitizeTableExportHistoryEntry(entry)) + .filter((entry): entry is TableExportHistoryEntry => !!entry) + .filter((entry) => { + if (seenJobIds.has(entry.jobId)) { + return false; + } + seenJobIds.add(entry.jobId); + return true; + }) + .sort((a, b) => { + const timeA = a.finishedAt || a.startedAt || 0; + const timeB = b.finishedAt || b.startedAt || 0; + return timeB - timeA; + }) + .slice(0, MAX_TABLE_EXPORT_HISTORY_PER_TARGET); + if (sanitizedHistory.length > 0) { + result[toTrimmedString(key)] = sanitizedHistory; + } + }); + return result; +}; + const sanitizeQueryTabs = (value: unknown): TabData[] => { if (!Array.isArray(value)) return []; const result: TabData[] = []; @@ -2151,6 +2248,7 @@ export const useStore = create()( shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS), sqlSnippets: DEFAULT_SQL_SNIPPETS, sqlLogs: [], + tableExportHistories: {}, tableAccessCount: {}, tableSortPreference: {}, tableColumnOrders: {}, @@ -2497,9 +2595,13 @@ export const useStore = create()( ), }; } - // 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab + // 语义去重:对表相关标签页按 connectionId+dbName+tableName 匹配已有 Tab if ( - (incomingTab.type === "table" || incomingTab.type === "design") && + ( + incomingTab.type === "table" || + incomingTab.type === "design" || + incomingTab.type === "table-export" + ) && incomingTab.tableName && incomingTab.connectionId && incomingTab.dbName @@ -3012,6 +3114,39 @@ export const useStore = create()( addSqlLog: (log) => set((state) => ({ sqlLogs: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_LOGS) })), clearSqlLogs: () => set({ sqlLogs: [] }), + upsertTableExportHistory: (historyKey, entry) => + set((state) => { + const safeHistoryKey = toTrimmedString(historyKey); + const safeEntry = sanitizeTableExportHistoryEntry(entry); + if (!safeHistoryKey || !safeEntry) { + return state; + } + const existingEntries = state.tableExportHistories[safeHistoryKey] || []; + const existingIndex = existingEntries.findIndex( + (item) => item.jobId === safeEntry.jobId, + ); + const nextEntries = + existingIndex >= 0 + ? existingEntries.map((item, index) => + index === existingIndex ? { ...item, ...safeEntry } : item, + ) + : [safeEntry, ...existingEntries]; + const trimmedEntries = nextEntries.slice(0, MAX_TABLE_EXPORT_HISTORY_PER_TARGET); + const unchanged = + existingEntries.length === trimmedEntries.length && + existingEntries.every((item, index) => + JSON.stringify(item) === JSON.stringify(trimmedEntries[index]), + ); + if (unchanged) { + return state; + } + return { + tableExportHistories: { + ...state.tableExportHistories, + [safeHistoryKey]: trimmedEntries, + }, + }; + }), recordTableAccess: (connectionId, dbName, tableName) => set((state) => { @@ -3375,6 +3510,9 @@ export const useStore = create()( state.shortcutOptions, ); nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs); + nextState.tableExportHistories = sanitizeTableExportHistories( + state.tableExportHistories, + ); const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets); const existingSnippetIds = new Set(existingSnippets.map((s) => s.id)); const missingSnippets = DEFAULT_SQL_SNIPPETS.filter( @@ -3517,6 +3655,9 @@ export const useStore = create()( sqlEditorTransactionOptions: state.sqlEditorTransactionOptions, shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), sqlLogs: sanitizeSqlLogs(state.sqlLogs), + tableExportHistories: sanitizeTableExportHistories( + state.tableExportHistories, + ), sqlSnippets: state.sqlSnippets, tableAccessCount: state.tableAccessCount, tableSortPreference: state.tableSortPreference, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 88927f6..527c2e2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -392,6 +392,41 @@ export interface TriggerDefinition { statement: string; } +export type TableExportScope = "selected" | "page" | "all" | "filteredAll"; + +export interface TableExportScopeOption { + value: TableExportScope; + label: string; + description?: string; + disabled?: boolean; +} + +export type TableExportHistoryStatus = + | "idle" + | "start" + | "running" + | "finalizing" + | "done" + | "error"; + +export interface TableExportHistoryEntry { + jobId: string; + targetName: string; + startedAt: number; + finishedAt: number; + format: string; + scope: string; + scopeLabel: string; + strategyLabel: string; + status: TableExportHistoryStatus; + stage: string; + current: number; + total: number; + totalRowsKnown: boolean; + filePath: string; + message: string; +} + export interface TabData { id: string; title: string; @@ -407,6 +442,7 @@ export interface TabData { | "event-def" | "routine-def" | "table-overview" + | "table-export" | "jvm-overview" | "jvm-resource" | "jvm-audit" @@ -436,6 +472,10 @@ export interface TabData { sidebarLocateKey?: string; // Precise sidebar tree key for locating an object node savedQueryId?: string; // Saved query identity for quick-save behavior objectType?: 'table' | 'view' | 'materialized-view'; // Table-like object type for shared viewers + tableExportScopeOptions?: TableExportScopeOption[]; + tableExportInitialScope?: TableExportScope; + tableExportQueryByScope?: Partial>; + tableExportRowCountByScope?: Partial>; formatRestoreSnapshot?: { query: string; createdAt: number; diff --git a/frontend/src/utils/exportProgress.test.ts b/frontend/src/utils/exportProgress.test.ts new file mode 100644 index 0000000..dc104ce --- /dev/null +++ b/frontend/src/utils/exportProgress.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + formatExportElapsed, + formatExportProgressRows, + resolveExportElapsedMs, + resolveExportProgressPercent, + shouldUseExactExportProgress, + shouldUseIndeterminateExportProgress, +} from './exportProgress'; + +describe('exportProgress', () => { + it('uses actual percent when total row count is known', () => { + expect(resolveExportProgressPercent('running', 25, 100, true)).toBe(25); + }); + + it('does not fabricate percentages when total row count is unknown', () => { + expect(resolveExportProgressPercent('running', 5000, 0, false)).toBe(0); + expect(resolveExportProgressPercent('finalizing', 5000, 0, false)).toBe(0); + expect(shouldUseExactExportProgress('running', 0, false)).toBe(false); + expect(shouldUseIndeterminateExportProgress('running', false)).toBe(true); + }); + + it('formats row summary for known and unknown totals', () => { + expect(formatExportProgressRows(12345, 0, false)).toBe('已写入 12,345 行'); + expect(formatExportProgressRows(12345, 880000, true)).toBe('已写入 12,345 / 880,000 行'); + }); + + it('resolves and formats elapsed export duration', () => { + expect(resolveExportElapsedMs(1000, 91_000)).toBe(90_000); + expect(resolveExportElapsedMs(1000, 0, 31_500)).toBe(30_500); + expect(formatExportElapsed(30_500)).toBe('00:30'); + expect(formatExportElapsed(3_723_000)).toBe('01:02:03'); + }); +}); diff --git a/frontend/src/utils/exportProgress.ts b/frontend/src/utils/exportProgress.ts new file mode 100644 index 0000000..0cd94a2 --- /dev/null +++ b/frontend/src/utils/exportProgress.ts @@ -0,0 +1,91 @@ +export type ExportProgressStatus = 'idle' | 'start' | 'running' | 'finalizing' | 'done' | 'error'; + +const clampPercent = (value: number): number => { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 100) return 100; + return value; +}; + +export const shouldUseExactExportProgress = ( + status: ExportProgressStatus, + total: number, + totalRowsKnown: boolean, +): boolean => { + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0; + if (totalRowsKnown && normalizedTotal > 0) { + return true; + } + if ((status === 'done' || status === 'error') && totalRowsKnown) { + return true; + } + return false; +}; + +export const shouldUseIndeterminateExportProgress = ( + status: ExportProgressStatus, + totalRowsKnown: boolean, +): boolean => !totalRowsKnown && status !== 'idle' && status !== 'done' && status !== 'error'; + +export const resolveExportProgressPercent = ( + status: ExportProgressStatus, + current: number, + total: number, + totalRowsKnown: boolean, +): number => { + const normalizedCurrent = Number.isFinite(current) ? Math.max(0, current) : 0; + const normalizedTotal = Number.isFinite(total) ? Math.max(0, total) : 0; + if (totalRowsKnown && normalizedTotal > 0) { + return clampPercent((normalizedCurrent / normalizedTotal) * 100); + } + if ((status === 'done' || status === 'error') && (totalRowsKnown || normalizedCurrent >= 0)) { + return 100; + } + return 0; +}; + +export const formatExportProgressRows = ( + current: number, + total: number, + totalRowsKnown: boolean, +): string => { + const formatter = new Intl.NumberFormat('zh-CN'); + const safeCurrent = formatter.format(Math.max(0, Math.trunc(Number(current) || 0))); + if (!totalRowsKnown) { + return `已写入 ${safeCurrent} 行`; + } + const safeTotal = formatter.format(Math.max(0, Math.trunc(Number(total) || 0))); + return `已写入 ${safeCurrent} / ${safeTotal} 行`; +}; + +export const resolveExportElapsedMs = ( + startedAt: number, + finishedAt = 0, + now = Date.now(), +): number => { + const safeStartedAt = Number(startedAt); + if (!Number.isFinite(safeStartedAt) || safeStartedAt <= 0) { + return 0; + } + const safeFinishedAt = Number(finishedAt); + const endAt = Number.isFinite(safeFinishedAt) && safeFinishedAt > 0 + ? safeFinishedAt + : Number(now); + if (!Number.isFinite(endAt) || endAt <= safeStartedAt) { + return 0; + } + return Math.max(0, Math.trunc(endAt - safeStartedAt)); +}; + +const padTimePart = (value: number): string => String(Math.max(0, Math.trunc(value))).padStart(2, '0'); + +export const formatExportElapsed = (elapsedMs: number): string => { + const totalSeconds = Math.max(0, Math.trunc(Number(elapsedMs) / 1000)); + const hours = Math.trunc(totalSeconds / 3600); + const minutes = Math.trunc((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${padTimePart(hours)}:${padTimePart(minutes)}:${padTimePart(seconds)}`; + } + return `${padTimePart(minutes)}:${padTimePart(seconds)}`; +}; diff --git a/frontend/src/utils/tabDisplay.test.ts b/frontend/src/utils/tabDisplay.test.ts index 6be9956..e92ab28 100644 --- a/frontend/src/utils/tabDisplay.test.ts +++ b/frontend/src/utils/tabDisplay.test.ts @@ -75,6 +75,19 @@ describe('tabDisplay', () => { expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders'); }); + it('keeps table export tabs on the same connection prefix strategy', () => { + const exportTab: TabData = { + id: 'table-export-1', + title: '导出 public.orders', + type: 'table-export', + connectionId: 'redis-1', + dbName: 'app', + tableName: 'public.orders', + }; + + expect(buildTabDisplayTitle(exportTab, redisConnection)).toBe('[订单缓存] 导出 orders'); + }); + it('hides schema prefixes from schema-qualified table tab labels', () => { const connection: SavedConnection = { id: 'kingbase-1', diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts index f4b63e1..3859496 100644 --- a/frontend/src/utils/tabDisplay.ts +++ b/frontend/src/utils/tabDisplay.ts @@ -440,6 +440,9 @@ const buildCompactObjectTabTitle = (tab: TabData): string => { if (tab.type === 'table-overview') { return stripSchemaFromTableOverviewTitle(tab.title); } + if (tab.type === 'table-export') { + return replaceTitleObjectLabel(tab.title, tab.tableName); + } if (tab.type === 'view-def') { return replaceTitleObjectLabel(tab.title, tab.viewName); } @@ -460,6 +463,7 @@ export const getTabDisplayKindLabel = (tab: TabData): string => { if (tab.type === 'table') return 'TABLE'; if (tab.type === 'design') return 'DESIGN'; if (tab.type === 'table-overview') return 'DB'; + if (tab.type === 'table-export') return 'EXPORT'; if (tab.type.startsWith('redis')) return 'REDIS'; if (tab.type.startsWith('jvm')) return 'JVM'; if (tab.type === 'trigger') return 'TRG'; @@ -590,7 +594,12 @@ export const buildTabDisplayTitle = ( } const baseTitle = buildCompactObjectTabTitle(tab); - if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') { + if ( + tab.type !== 'table' && + tab.type !== 'design' && + tab.type !== 'table-overview' && + tab.type !== 'table-export' + ) { return baseTitle; } if (!connectionName) { diff --git a/frontend/src/utils/tableExportTab.test.ts b/frontend/src/utils/tableExportTab.test.ts new file mode 100644 index 0000000..3966152 --- /dev/null +++ b/frontend/src/utils/tableExportTab.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTableExportHistoryKey, + buildTableExportTab, + DEFAULT_TABLE_EXPORT_SCOPE_OPTION, +} from './tableExportTab'; + +describe('tableExportTab', () => { + it('builds a stable history key for persisted export records', () => { + expect(buildTableExportHistoryKey(' conn-1 ', ' app ', ' public.orders ')).toBe('conn-1::app::public.orders'); + }); + + it('builds a stable table export tab with normalized defaults', () => { + const tab = buildTableExportTab({ + connectionId: 'conn-1', + dbName: 'app', + tableName: 'public.orders', + }); + + expect(tab.id).toBe('table-export-conn-1-app-public.orders'); + expect(tab.type).toBe('table-export'); + expect(tab.title).toBe('导出 public.orders'); + expect(tab.tableExportScopeOptions).toEqual([DEFAULT_TABLE_EXPORT_SCOPE_OPTION]); + expect(tab.tableExportInitialScope).toBe('all'); + expect(tab.tableExportQueryByScope).toBeUndefined(); + expect(tab.tableExportRowCountByScope).toBeUndefined(); + }); + + it('deduplicates scope options and sanitizes scope payloads', () => { + const tab = buildTableExportTab({ + connectionId: 'conn-1', + dbName: 'app', + tableName: 'orders', + scopeOptions: [ + { value: 'filteredAll', label: '筛选结果', description: 'desc' }, + { value: 'filteredAll', label: '重复项应移除' }, + { value: 'page', label: '' }, + ], + initialScope: 'filteredAll', + queryByScope: { + filteredAll: ' select * from orders where status = 1 ', + page: ' ', + }, + rowCountByScope: { + filteredAll: 42.8, + page: -1, + }, + }); + + expect(tab.tableExportScopeOptions).toEqual([ + { value: 'filteredAll', label: '筛选结果', description: 'desc', disabled: false }, + { value: 'page', label: 'page', description: undefined, disabled: false }, + ]); + expect(tab.tableExportInitialScope).toBe('filteredAll'); + expect(tab.tableExportQueryByScope).toEqual({ + filteredAll: 'select * from orders where status = 1', + }); + expect(tab.tableExportRowCountByScope).toEqual({ + filteredAll: 42, + }); + }); +}); diff --git a/frontend/src/utils/tableExportTab.ts b/frontend/src/utils/tableExportTab.ts new file mode 100644 index 0000000..93d127e --- /dev/null +++ b/frontend/src/utils/tableExportTab.ts @@ -0,0 +1,123 @@ +import type { TabData, TableExportScope, TableExportScopeOption } from '../types'; + +export const DEFAULT_TABLE_EXPORT_SCOPE_OPTION: TableExportScopeOption = { + value: 'all', + label: '全表数据', + description: '后台重新查询整张表并导出全部数据。', +}; + +export const buildTableExportHistoryKey = ( + connectionId: string, + dbName: string | undefined, + tableName: string | undefined, +): string => { + return [ + String(connectionId || '').trim(), + String(dbName || '').trim(), + String(tableName || '').trim(), + ].join('::'); +}; + +type BuildTableExportTabInput = { + connectionId: string; + dbName?: string; + tableName: string; + title?: string; + objectType?: TabData['objectType']; + schemaName?: string; + sidebarLocateKey?: string; + scopeOptions?: TableExportScopeOption[]; + initialScope?: TableExportScope; + queryByScope?: Partial>; + rowCountByScope?: Partial>; +}; + +const normalizeScopeOptions = ( + scopeOptions: TableExportScopeOption[] | undefined, +): TableExportScopeOption[] => { + if (!Array.isArray(scopeOptions) || scopeOptions.length === 0) { + return [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }]; + } + const seen = new Set(); + const normalized = scopeOptions + .filter((item): item is TableExportScopeOption => !!item && typeof item.value === 'string') + .map((item) => ({ + value: item.value, + label: String(item.label || '').trim() || item.value, + description: typeof item.description === 'string' ? item.description : undefined, + disabled: item.disabled === true, + })) + .filter((item) => { + if (seen.has(item.value)) return false; + seen.add(item.value); + return true; + }); + return normalized.length > 0 ? normalized : [{ ...DEFAULT_TABLE_EXPORT_SCOPE_OPTION }]; +}; + +const resolveInitialScope = ( + scopeOptions: TableExportScopeOption[], + initialScope?: TableExportScope, +): TableExportScope => { + if (initialScope && scopeOptions.some((item) => item.value === initialScope && !item.disabled)) { + return initialScope; + } + return scopeOptions.find((item) => !item.disabled)?.value || 'all'; +}; + +const normalizeQueryByScope = ( + queryByScope: BuildTableExportTabInput['queryByScope'], +): Partial> | undefined => { + if (!queryByScope || typeof queryByScope !== 'object') { + return undefined; + } + const next: Partial> = {}; + (['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => { + const value = String(queryByScope[scope] || '').trim(); + if (value) { + next[scope] = value; + } + }); + return Object.keys(next).length > 0 ? next : undefined; +}; + +const normalizeRowCountByScope = ( + rowCountByScope: BuildTableExportTabInput['rowCountByScope'], +): Partial> | undefined => { + if (!rowCountByScope || typeof rowCountByScope !== 'object') { + return undefined; + } + const next: Partial> = {}; + (['selected', 'page', 'all', 'filteredAll'] as TableExportScope[]).forEach((scope) => { + const value = Number(rowCountByScope[scope]); + if (Number.isFinite(value) && value >= 0) { + next[scope] = Math.trunc(value); + } + }); + return Object.keys(next).length > 0 ? next : undefined; +}; + +export const buildTableExportTab = (input: BuildTableExportTabInput): TabData => { + const connectionId = String(input.connectionId || '').trim(); + const dbName = String(input.dbName || '').trim(); + const tableName = String(input.tableName || '').trim(); + const scopeOptions = normalizeScopeOptions(input.scopeOptions); + const initialScope = resolveInitialScope(scopeOptions, input.initialScope); + const objectLabel = tableName || '未命名对象'; + return { + id: `table-export-${connectionId}-${dbName}-${tableName}`, + title: String(input.title || `导出 ${objectLabel}`).trim() || `导出 ${objectLabel}`, + type: 'table-export', + connectionId, + dbName, + tableName, + objectType: input.objectType, + schemaName: input.schemaName, + sidebarLocateKey: input.sidebarLocateKey, + initialTab: 'config', + tableExportScopeOptions: scopeOptions, + tableExportInitialScope: initialScope, + tableExportQueryByScope: normalizeQueryByScope(input.queryByScope), + tableExportRowCountByScope: normalizeRowCountByScope(input.rowCountByScope), + }; +}; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 83767b2..d5e9cb4 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -106,16 +106,22 @@ export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promi export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; +export function ExportDataWithOptions(arg1:Array>,arg2:Array,arg3:string,arg4:app.ExportFileOptions):Promise; + export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise; export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:string):Promise; +export function ExportQueryWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:app.ExportFileOptions):Promise; + export function ExportSQLFile(arg1:string,arg2:string):Promise; export function ExportSchemaSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:boolean):Promise; export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function ExportTableWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:app.ExportFileOptions):Promise; + export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array,arg4:boolean):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 1b7d06b..c85c874 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -202,6 +202,10 @@ export function ExportData(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4); } +export function ExportDataWithOptions(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['ExportDataWithOptions'](arg1, arg2, arg3, arg4); +} + export function ExportDatabaseSQL(arg1, arg2, arg3) { return window['go']['app']['App']['ExportDatabaseSQL'](arg1, arg2, arg3); } @@ -210,6 +214,10 @@ export function ExportQuery(arg1, arg2, arg3, arg4, arg5) { return window['go']['app']['App']['ExportQuery'](arg1, arg2, arg3, arg4, arg5); } +export function ExportQueryWithOptions(arg1, arg2, arg3, arg4, arg5) { + return window['go']['app']['App']['ExportQueryWithOptions'](arg1, arg2, arg3, arg4, arg5); +} + export function ExportSQLFile(arg1, arg2) { return window['go']['app']['App']['ExportSQLFile'](arg1, arg2); } @@ -222,6 +230,10 @@ export function ExportTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4); } +export function ExportTableWithOptions(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['ExportTableWithOptions'](arg1, arg2, arg3, arg4); +} + export function ExportTablesDataSQL(arg1, arg2, arg3) { return window['go']['app']['App']['ExportTablesDataSQL'](arg1, arg2, arg3); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1db922e..01062c3 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -432,6 +432,26 @@ export namespace app { this.filePassword = source["filePassword"]; } } + export class ExportFileOptions { + format: string; + xlsxMaxRowsPerSheet?: number; + jobId?: string; + totalRowsHint?: number; + totalRowsKnown?: boolean; + + static createFrom(source: any = {}) { + return new ExportFileOptions(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.format = source["format"]; + this.xlsxMaxRowsPerSheet = source["xlsxMaxRowsPerSheet"]; + this.jobId = source["jobId"]; + this.totalRowsHint = source["totalRowsHint"]; + this.totalRowsKnown = source["totalRowsKnown"]; + } + } export class SecurityUpdateOptions { allowPartial?: boolean; writeBackup?: boolean; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 77a4ccc..a467397 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "Backup · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "Statistiken aktualisieren", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "Export-Workbench öffnen...", "sidebar.v2_table_menu.truncate_table": "Tabelle abschneiden", "sidebar.v2_table_menu.ai_explain_table": "Mit AI diese Tabelle erklären", "sidebar.v2_table_menu.ai_generate_query": "Mit AI eine Abfrage erzeugen", @@ -1563,6 +1564,7 @@ "tab_manager.kind_badge.view": "Ansicht", "tab_manager.kind_badge.event": "Ereignis", "tab_manager.kind_badge.routine": "Funktion", + "tab_manager.kind_badge.table_export": "Export", "tab_manager.kind_badge.fallback": "Tab", "tab_manager.empty.action.open_ai": "AI öffnen", "tab_manager.empty.aria.start_workbench": "GoNavi-Startarbeitsbereich", @@ -1596,6 +1598,7 @@ "tab_manager.hover.kind.redis_monitor": "Redis-Monitor", "tab_manager.hover.kind.routine": "Funktion / Prozedur", "tab_manager.hover.kind.table": "Tabellendaten", + "tab_manager.hover.kind.table_export": "Export-Workbench", "tab_manager.hover.kind.table_overview": "Tabellenübersicht", "tab_manager.hover.kind.trigger": "Trigger", "tab_manager.hover.kind.view": "Ansicht", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 6ba5b5e..4a57d33 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "Backup · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "Refresh stats", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "Open export workbench...", "sidebar.v2_table_menu.truncate_table": "Truncate table", "sidebar.v2_table_menu.ai_explain_table": "Use AI to explain this table", "sidebar.v2_table_menu.ai_generate_query": "Use AI to generate a query", @@ -1571,6 +1572,7 @@ "tab_manager.kind_badge.view": "View", "tab_manager.kind_badge.event": "Event", "tab_manager.kind_badge.routine": "Func", + "tab_manager.kind_badge.table_export": "Export", "tab_manager.kind_badge.fallback": "Tab", "tab_manager.empty.action.open_ai": "Open AI", "tab_manager.empty.aria.start_workbench": "GoNavi start workbench", @@ -1604,6 +1606,7 @@ "tab_manager.hover.kind.redis_monitor": "Redis monitor", "tab_manager.hover.kind.routine": "Function / procedure", "tab_manager.hover.kind.table": "Table data", + "tab_manager.hover.kind.table_export": "Export workbench", "tab_manager.hover.kind.table_overview": "Table overview", "tab_manager.hover.kind.trigger": "Trigger", "tab_manager.hover.kind.view": "View", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 5dc9b1a..d280393 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "バックアップ · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "統計情報を更新", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "エクスポートワークベンチを開く…", "sidebar.v2_table_menu.truncate_table": "テーブルを切り詰め", "sidebar.v2_table_menu.ai_explain_table": "AI でこのテーブルを説明", "sidebar.v2_table_menu.ai_generate_query": "AI でクエリを生成", @@ -1563,6 +1564,7 @@ "tab_manager.kind_badge.view": "ビュー", "tab_manager.kind_badge.event": "イベント", "tab_manager.kind_badge.routine": "関数", + "tab_manager.kind_badge.table_export": "エクスポート", "tab_manager.kind_badge.fallback": "タブ", "tab_manager.empty.action.open_ai": "AI を開く", "tab_manager.empty.aria.start_workbench": "GoNavi 開始ワークベンチ", @@ -1596,6 +1598,7 @@ "tab_manager.hover.kind.redis_monitor": "Redis 監視", "tab_manager.hover.kind.routine": "関数 / プロシージャ", "tab_manager.hover.kind.table": "テーブルデータ", + "tab_manager.hover.kind.table_export": "エクスポートワークベンチ", "tab_manager.hover.kind.table_overview": "テーブル概要", "tab_manager.hover.kind.trigger": "トリガー", "tab_manager.hover.kind.view": "ビュー", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index bff8b48..b5febb2 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "Резервная копия · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "Обновить статистику", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "Открыть рабочую область экспорта…", "sidebar.v2_table_menu.truncate_table": "Усечь таблицу", "sidebar.v2_table_menu.ai_explain_table": "Объяснить эту таблицу с помощью AI", "sidebar.v2_table_menu.ai_generate_query": "Сгенерировать запрос с помощью AI", @@ -1563,6 +1564,7 @@ "tab_manager.kind_badge.view": "Вид", "tab_manager.kind_badge.event": "Событие", "tab_manager.kind_badge.routine": "Функция", + "tab_manager.kind_badge.table_export": "Экспорт", "tab_manager.kind_badge.fallback": "Вкладка", "tab_manager.empty.action.open_ai": "Открыть AI", "tab_manager.empty.aria.start_workbench": "Стартовая рабочая область GoNavi", @@ -1596,6 +1598,7 @@ "tab_manager.hover.kind.redis_monitor": "Монитор Redis", "tab_manager.hover.kind.routine": "Функция / процедура", "tab_manager.hover.kind.table": "Данные таблицы", + "tab_manager.hover.kind.table_export": "Рабочая область экспорта", "tab_manager.hover.kind.table_overview": "Обзор таблицы", "tab_manager.hover.kind.trigger": "Триггер", "tab_manager.hover.kind.view": "Представление", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 1378839..aea2de8 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "备份 · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "刷新统计信息", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "打开导出工作台…", "sidebar.v2_table_menu.truncate_table": "截断表", "sidebar.v2_table_menu.ai_explain_table": "用 AI 解释这张表", "sidebar.v2_table_menu.ai_generate_query": "用 AI 生成查询", @@ -1571,6 +1572,7 @@ "tab_manager.kind_badge.view": "视图", "tab_manager.kind_badge.event": "事件", "tab_manager.kind_badge.routine": "函数", + "tab_manager.kind_badge.table_export": "导出", "tab_manager.kind_badge.fallback": "标签", "tab_manager.empty.action.open_ai": "打开 AI", "tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台", @@ -1604,6 +1606,7 @@ "tab_manager.hover.kind.redis_monitor": "Redis 监控", "tab_manager.hover.kind.routine": "函数 / 存储过程", "tab_manager.hover.kind.table": "表数据", + "tab_manager.hover.kind.table_export": "导出工作台", "tab_manager.hover.kind.table_overview": "表概览", "tab_manager.hover.kind.trigger": "触发器", "tab_manager.hover.kind.view": "视图", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 80ccf40..deb5a71 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -1391,6 +1391,7 @@ "sidebar.v2_table_menu.backup_sql_dump": "備份 · {{keyword}}", "sidebar.v2_table_menu.refresh_stats": "重新整理統計資訊", "sidebar.v2_table_menu.item_with_suffix": "{{label}} · {{suffix}}", + "sidebar.v2_table_menu.open_export_workbench": "開啟匯出工作台…", "sidebar.v2_table_menu.truncate_table": "截斷資料表", "sidebar.v2_table_menu.ai_explain_table": "用 AI 解釋這張資料表", "sidebar.v2_table_menu.ai_generate_query": "用 AI 產生查詢", @@ -1563,6 +1564,7 @@ "tab_manager.kind_badge.view": "視圖", "tab_manager.kind_badge.event": "事件", "tab_manager.kind_badge.routine": "函式", + "tab_manager.kind_badge.table_export": "匯出", "tab_manager.kind_badge.fallback": "標籤", "tab_manager.empty.action.open_ai": "開啟 AI", "tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台", @@ -1596,6 +1598,7 @@ "tab_manager.hover.kind.redis_monitor": "Redis 監控", "tab_manager.hover.kind.routine": "函數 / 程序", "tab_manager.hover.kind.table": "表資料", + "tab_manager.hover.kind.table_export": "匯出工作台", "tab_manager.hover.kind.table_overview": "表概覽", "tab_manager.hover.kind.trigger": "觸發器", "tab_manager.hover.kind.view": "視圖", From 954d126a8f759d34f22791d3e32fd1cc32518121 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 15:36:58 +0800 Subject: [PATCH 03/61] =?UTF-8?q?=E2=9C=85=20test(sidebar):=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=A4=9A=E8=AF=AD=E8=A8=80=E5=90=8E=E7=9A=84=20locate?= =?UTF-8?q?-toolbar=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 7fbff76..18eac77 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -45,7 +45,8 @@ import { DEFAULT_SHORTCUT_OPTIONS, cloneShortcutOptions, } from '../utils/shortcuts'; -import { SUPPORTED_LANGUAGES, setCurrentLanguage, t } from '../i18n'; +import { SUPPORTED_LANGUAGES, getCurrentLanguage, setCurrentLanguage, t } from '../i18n'; +import { I18nProvider } from '../i18n/provider'; import { V2ConnectionGroupContextMenuView, V2ConnectionContextMenuView, @@ -211,6 +212,15 @@ vi.mock('../utils/appearance', async () => { }; }); +const renderSidebarMarkup = (props: React.ComponentProps = {}) => renderToStaticMarkup( + undefined} + > + + , +); + describe('Sidebar locate toolbar', () => { beforeEach(() => { setCurrentLanguage('zh-CN'); @@ -579,7 +589,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders the current table locate action in the sidebar toolbar', () => { - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup(); const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"'); const locateActionIndex = markup.indexOf('data-sidebar-locate-current-tab-action="true"'); @@ -613,7 +623,7 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('DeleteSQLDirectory(directoryPath)'); expect(source).toContain('refreshGlobalExternalSQLRootNode(false)'); expect(source).toContain("request.objectGroup === 'externalSqlFiles'"); - expect(source).toContain('SQL 文件未在外部 SQL 目录中找到'); + expect(source).toContain("t('sidebar.message.locate_external_sql_file_not_found', { path: request.filePath })"); expect(source).toContain('filePath: data.filePath || undefined'); expect(source).toContain("key: 'add-external-sql-directory'"); expect(source).toContain("key: 'new-external-sql-file'"); @@ -622,22 +632,20 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("key: 'new-external-sql-directory'"); expect(source).toContain("key: 'rename-external-sql-directory'"); expect(source).toContain("key: 'delete-external-sql-directory'"); - expect(source).toContain('新建 SQL 文件'); - expect(source).toContain('重命名 SQL 文件'); - expect(source).toContain('确认删除 SQL 文件'); - expect(source).toContain('新建目录'); - expect(source).toContain('重命名目录'); - expect(source).toContain('确认删除目录'); - expect(source).toContain('仅支持删除空目录'); - expect(source).toContain('文件名不能包含路径分隔符'); - expect(source).toContain('目录名不能包含路径分隔符'); - expect(loadTablesSource).not.toContain('externalSQLRootNode'); - expect(loadTablesSource).not.toContain('dbExternalSQLDirectories'); + expect(source).toContain("t('sidebar.external_sql_modal.title.create_file')"); + expect(source).toContain("t('sidebar.external_sql_modal.title.rename_file')"); + expect(source).toContain("t('sidebar.modal.confirm_delete_sql_file.title')"); + expect(source).toContain("t('sidebar.external_sql_modal.title.create_directory')"); + expect(source).toContain("t('sidebar.external_sql_modal.title.rename_directory')"); + expect(source).toContain("t('sidebar.modal.confirm_delete_sql_directory.title')"); + expect(source).toContain("t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName })"); + expect(source).toContain("t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')"); + expect(source).toContain("t('sidebar.external_sql_modal.validation.directory_name_no_separator')"); }); it('keeps the legacy sidebar toolbar on a stable five-column grid layout', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup(); expect(markup).toContain('data-sidebar-legacy-toolbar="true"'); expect(markup).toContain('data-sidebar-legacy-toolbar-item="true"'); @@ -650,7 +658,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => { - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2', sqlLogCount: 2341, onCreateConnection: mocks.noop }); const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(markup).toContain('gn-v2-sidebar-redesign'); @@ -729,10 +737,10 @@ describe('Sidebar locate toolbar', () => { mocks.state.appearance.v2SidebarSearchMode = 'filter'; mocks.state.appearance.v2SidebarPersistedFilter = 'fs_org'; - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2' }); expect(markup).toContain('data-v2-sidebar-search-mode="filter"'); - expect(markup).toContain('筛选左侧表、连接、对象...'); + expect(markup).toContain('placeholder="搜索..."'); expect(markup).toContain('value="fs_org"'); expect(markup).toContain('重置侧栏筛选'); }); @@ -741,7 +749,7 @@ describe('Sidebar locate toolbar', () => { mocks.state.shortcutOptions = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); mocks.state.shortcutOptions.focusSidebarSearch.mac = { combo: 'Meta+F', enabled: true }; - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2' }); expect(markup).toContain('gn-v2-search-shortcut'); expect(markup).toContain(''); @@ -987,7 +995,7 @@ describe('Sidebar locate toolbar', () => { uiVersion: 'v2', }; - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2' }); expect(markup).toContain('gn-v2-connection-rail'); expect(markup).toContain('gn-v2-active-connection-copy'); @@ -1019,7 +1027,7 @@ describe('Sidebar locate toolbar', () => { uiVersion: 'v2', }; - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2' }); expect(markup).toContain(`${t('sidebar.active_connection.no_host_selected')}`); expect(markup).toContain(`${t('sidebar.active_connection.no_database_selected')}`); @@ -1062,7 +1070,7 @@ describe('Sidebar locate toolbar', () => { uiVersion: 'v2', }; - const markup = renderToStaticMarkup(); + const markup = renderSidebarMarkup({ uiVersion: 'v2' }); const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(source).toContain("if (v2ExplorerFilter === 'all') {"); @@ -1867,15 +1875,10 @@ describe('Sidebar locate toolbar', () => { ); expect(markup).toContain('Export table data'); - expect(markup).toContain('Excel · .xlsx'); - expect(markup).toContain('CSV · .csv'); - expect(markup).toContain('JSON · .json'); - expect(markup).toContain('Excel'); - expect(markup).toContain('CSV'); - expect(markup).toContain('JSON'); - expect(markup).toContain('.xlsx'); - expect(markup).toContain('.csv'); - expect(markup).toContain('.json'); + expect(markup).toContain('Open export workbench...'); + expect(markup).not.toContain('Excel · .xlsx'); + expect(markup).not.toContain('CSV · .csv'); + expect(markup).not.toContain('JSON · .json'); expect(markup).not.toContain('导出表数据'); }); @@ -2269,12 +2272,14 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("const buildConnectionRootQueryTabTitle = () => t('query.new');"); expect(source).toContain("const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') =>"); expect(source).toContain("const buildConnectionRootRedisMonitorTabTitle = (redisDbLabel = 'db0') =>"); + expect(source).toContain("t('sidebar.tab.redis_command', { database: redisDbLabel })"); + expect(source).toContain("t('sidebar.tab.redis_monitor', { database: redisDbLabel })"); expect(source).toContain("title: buildConnectionRootQueryTabTitle()"); expect(source).toContain("title: buildConnectionRootRedisCommandTabTitle()"); expect(source).toContain("title: buildConnectionRootRedisMonitorTabTitle()"); expect(source).toContain("title: t('sidebar.tab.new_query_database', { database: node.title })"); - expect(source).toContain("title: `命令 - db${redisDB}`"); - expect(source).toContain("title: `监控 - db${redisDB}`"); + expect(source).toContain("title: buildConnectionRootRedisCommandTabTitle(`db${redisDB}`)"); + expect(source).toContain("title: buildConnectionRootRedisMonitorTabTitle(`db${redisDB}`)"); }); it('localizes sidebar JVM probe and resource failure prompts', () => { @@ -2355,23 +2360,21 @@ describe('Sidebar locate toolbar', () => { expect(loadTablesSource).not.toContain('SQL 目录读取失败'); expect(loadTablesSource).not.toContain("'SQL目录'"); - expect(externalSqlFlowSource).toContain("message.warning(t('sidebar.message.add_sql_directory_database_required'))"); expect(externalSqlFlowSource).toContain("message.error(t('sidebar.message.select_sql_directory_failed'"); expect(externalSqlFlowSource).toContain("message.error(t('sidebar.message.sql_directory_path_invalid'))"); expect(externalSqlFlowSource).toContain("t('sidebar.sql_directory.default_name')"); expect(externalSqlFlowSource).toContain("message.success(t('sidebar.message.external_sql_directory_added'))"); expect(externalSqlFlowSource).toContain("message.error(t('sidebar.message.external_sql_directory_not_found'))"); expect(externalSqlFlowSource).toContain("message.success(t('sidebar.message.external_sql_directory_removed'))"); - expect(externalSqlFlowSource).toContain("message.warning(t('sidebar.message.external_sql_directory_context_missing'))"); expect(externalSqlFlowSource).toContain("message.success(t('sidebar.message.external_sql_directory_refreshed'))"); + expect(externalSqlFlowSource).not.toContain("sidebar.message.add_sql_directory_database_required"); + expect(externalSqlFlowSource).not.toContain("sidebar.message.external_sql_directory_context_missing"); [ - '请在具体数据库下添加外部 SQL 目录', '选择 SQL 目录失败', '未获取到有效的 SQL 目录路径', '外部 SQL 目录已添加', '未找到可移除的 SQL 目录', '外部 SQL 目录已移除', - '当前目录缺少数据库上下文,无法刷新', '外部 SQL 目录已刷新', ].forEach((rawSnippet) => { expect(externalSqlFlowSource).not.toContain(rawSnippet); @@ -2407,14 +2410,12 @@ describe('Sidebar locate toolbar', () => { 'sidebar.menu.refresh_directory', 'sidebar.menu.remove_directory', 'sidebar.menu.open_sql_file', - 'sidebar.message.add_sql_directory_database_required', 'sidebar.message.select_sql_directory_failed', 'sidebar.message.sql_directory_path_invalid', 'sidebar.sql_directory.default_name', 'sidebar.message.external_sql_directory_added', 'sidebar.message.external_sql_directory_not_found', 'sidebar.message.external_sql_directory_removed', - 'sidebar.message.external_sql_directory_context_missing', 'sidebar.message.external_sql_directory_refreshed', 'sidebar.message.external_sql_directory_read_failed', ].forEach((key) => { @@ -2631,7 +2632,7 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain('attempt < SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS'); expect(source).toContain('window.setTimeout(resolve, SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS)'); expect(source).toContain('return !loadingNodesRef.current.has(loadKey);'); - expect(source).toContain('对象仍在加载中'); + expect(source).toContain("t('sidebar.message.locate_object_loading', {"); }); it('resolves sidebar export workbench connection ids from live tree nodes instead of only reading dataRef.connectionId', () => { From 4e31d4793617478f5a67eafcf4176d29ab4d8beb Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 16:50:05 +0800 Subject: [PATCH 04/61] =?UTF-8?q?=E2=9C=A8=20feat(export-workbench):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E5=AF=BC=E5=87=BA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E5=B9=B6=E4=BC=98=E5=8C=96=20SQL=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧边栏批量表/批量库入口改为直接打开导出工作台,统一导出配置与进度视图 - 导出工作台新增 batch-tables / batch-databases 模式,支持连接、数据库、对象选择与独立历史记录键 - 连接、数据库、对象下拉项补齐完整名展示与悬浮提示,避免长名称被截断后不可识别 - 后端新增批量对象/批量库导出 WithOptions 链路,统一返回输出文件/目录与进度信息 - SQL dump 数据导出改为按方言批量写入,MySQL/PG 等使用多值 VALUES,Oracle/达梦使用 INSERT ALL - 补充导出工作台与 SQL dump 的回归测试和 benchmark,覆盖批量模式与批量写入语义 --- frontend/src/components/Sidebar.tsx | 56 +- .../components/TableExportWorkbench.test.tsx | 108 ++- .../src/components/TableExportWorkbench.tsx | 887 +++++++++++++++--- frontend/src/types.ts | 1 + frontend/src/utils/tableExportTab.test.ts | 42 + frontend/src/utils/tableExportTab.ts | 58 ++ frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + internal/app/methods_file.go | 416 +++++++- internal/app/methods_file_export_test.go | 130 +++ 10 files changed, 1541 insertions(+), 169 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4400e54..a5feea2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -89,7 +89,11 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../util import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; -import { buildTableExportTab } from '../utils/tableExportTab'; +import { + buildBatchDatabaseExportWorkbenchTab, + buildBatchTableExportWorkbenchTab, + buildTableExportTab, +} from '../utils/tableExportTab'; import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; @@ -4024,6 +4028,30 @@ const Sidebar: React.FC<{ setIsBatchModalOpen(true); }; + const openBatchTableExportWorkbench = () => { + let connId = ''; + let dbName = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { + connId = node.key as string; + } else if (node.type === 'database') { + connId = node.dataRef.id; + dbName = node.title; + } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { + connId = node.dataRef.id; + dbName = node.dataRef.dbName; + } + } + + addTab(buildBatchTableExportWorkbenchTab({ + connectionId: connId, + dbName: dbName || undefined, + title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象', + })); + }; + const loadDatabasesForBatch = async (conn: SavedConnection) => { const config = { ...conn.config, @@ -4347,6 +4375,24 @@ const Sidebar: React.FC<{ setIsBatchDbModalOpen(true); }; + const openBatchDatabaseExportWorkbench = () => { + let connId = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { + connId = node.key as string; + } else if (node.type === 'database' || node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { + connId = node.dataRef.id; + } + } + + addTab(buildBatchDatabaseExportWorkbenchTab({ + connectionId: connId, + title: '批量导出库', + })); + }; + const loadDatabasesForDbBatch = async (conn: SavedConnection) => { setBatchConnContext(conn); @@ -9221,7 +9267,7 @@ const Sidebar: React.FC<{
- {[ - `${resolveObjectTypeLabel(tab.objectType)} · ${tab.tableName || '-'}`, - `数据库 · ${tab.dbName || '-'}`, - `连接 · ${connection?.name || '-'}`, - `Host · ${hostSummary || '-'}`, - ].map((label) => ( + {headerBadges.map((label) => ( = ({ tab }) => { >
导出配置
-
- 对象 - {tab.tableName || '-'} + {isSingleWorkbench ? ( +
+ 对象 + {tab.tableName || '-'} - 类型 - {resolveObjectTypeLabel(tab.objectType)} + 类型 + {resolveObjectTypeLabel(tab.objectType)} - 连接 - {connection?.name || '-'} + 连接 + {connection?.name || '-'} - 数据库 - {tab.dbName || '-'} + 数据库 + {tab.dbName || '-'} - Host - {hostSummary || '-'} -
+ Host + {hostSummary || '-'} +
+ ) : isBatchTablesWorkbench ? ( +
+ 模式 + 批量对象 + + 连接 + {connection?.name || '-'} + + 数据库 + {selectedDbName || '-'} + + 对象数 + {selectedObjectNames.length} + + Host + {hostSummary || '-'} +
+ ) : ( +
+ 模式 + 批量库 + + 连接 + {connection?.name || '-'} + + 已选库 + {selectedDatabaseNames.length} + + Host + {hostSummary || '-'} +
+ )}
{!connectionConfig ? ( @@ -423,60 +850,284 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { /> ) : null} + {databaseLoadError ? ( + + ) : null} + + {objectLoadError ? ( + + ) : null} +
-
-
导出范围
- ({ + value: item.value, + label: item.label, + disabled: item.disabled, + }))} + onChange={(next) => setScope(next as TableExportScope)} + /> + {activeScopeOption?.description ? ( +
+ {activeScopeOption.description} +
+ ) : null}
- ) : null} -
-
-
导出格式
- setFormat(next as DataExportFormat)} + />
-
- ) : null} + + {format === 'xlsx' ? ( +
+
每个工作表最大行数
+ { + const next = Number(value); + setXlsxMaxRowsPerSheet( + Number.isFinite(next) && next > 0 + ? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(next)) + : DEFAULT_XLSX_ROWS_PER_SHEET, + ); + }} + /> +
+ 仅 XLSX 生效,最大 {MAX_XLSX_ROWS_PER_SHEET.toLocaleString()} 行(不含表头) +
+
+ ) : null} + + ) : isBatchTablesWorkbench ? ( + <> +
+
连接
+ +
+ { + setSelectedDbName(String(next || '').trim()); + setSelectedObjectNames([]); + setObjectLoadError(''); + }} + /> +
+
+
+ +
+
+
对象
+
+ + +
+
+ ({ value: item.value, label: item.label }))} + onChange={(next) => setBatchTableMode(next as BatchTableExportMode)} + /> +
+ {batchTableModeMeta.description} +
+
+ +
+
导出格式
+ { + setSelectedConnectionId(String(next || '').trim()); + setSelectedDatabaseNames([]); + setDatabaseLoadError(''); + }} + /> +
+ +
+ +
+
+
数据库
+
+ + +
+
+ ({ value: item.value, label: item.label }))} + onChange={(next) => setBatchDatabaseMode(next as BatchDatabaseExportMode)} + /> +
+ {batchDatabaseModeMeta.description} +
+
+ +
+
导出格式
+
- {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( + {supportsAdvancedEndpoint && (
基本信息
- 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> - - + {presetKeyFromForm === 'custom' && ( + 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> + + + )} {presetKeyFromForm === 'custom' && ( API 格式} name="apiFormat" style={{ marginBottom: 16 }}> @@ -314,7 +319,7 @@ const AISettingsProvidersSection: React.FC = ({ )} 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> -
)} @@ -326,21 +331,22 @@ const AISettingsProvidersSection: React.FC = ({ 认证 & 连接
API Key} + label={{codeBuddyUsesOptionalSecret ? 'API Key / Auth Token(可选)' : 'API Key'}} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); - if (apiKey || editingProvider?.id) { + if (apiKey || editingProvider?.id || codeBuddyUsesOptionalSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); }, }]} + extra={codeBuddyUsesOptionalSecret ? '留空则使用本机 CodeBuddy CLI 已登录账号;填写后优先使用当前凭证。' : undefined} style={{ marginBottom: 16 }} > = ({ /> - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> + {supportsAdvancedEndpoint && ( + API Endpoint (URL)} + name="baseUrl" + rules={presetKeyFromForm === 'codebuddy' ? [] : [{ required: true, message: '请输入有效的接口地址' }]} + style={{ marginBottom: 0 }} + > } style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} diff --git a/frontend/src/components/ai/aiChatReadiness.test.ts b/frontend/src/components/ai/aiChatReadiness.test.ts index 65ac298..d8027ba 100644 --- a/frontend/src/components/ai/aiChatReadiness.test.ts +++ b/frontend/src/components/ai/aiChatReadiness.test.ts @@ -92,4 +92,28 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.contextAttachedCount).toBe(1); expect(snapshot.title).toContain('OpenAI 主账号 / gpt-5.5'); }); + + it('treats CodeBuddy CLI as ready without explicit base url or model', () => { + const snapshot = buildAIChatReadinessSnapshot({ + providers: [{ + id: 'provider-1', + type: 'custom', + name: 'CodeBuddy', + apiKey: '', + hasSecret: true, + baseUrl: '', + model: '', + apiFormat: 'codebuddy-cli', + models: [], + maxTokens: 4096, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + }); + + expect(snapshot.status).toBe('ready'); + expect(snapshot.ready).toBe(true); + expect(snapshot.title).toContain('CodeBuddy'); + expect(snapshot.title).toContain('自动选择'); + }); }); diff --git a/frontend/src/components/ai/aiChatReadiness.ts b/frontend/src/components/ai/aiChatReadiness.ts index a3de8c8..551d4d3 100644 --- a/frontend/src/components/ai/aiChatReadiness.ts +++ b/frontend/src/components/ai/aiChatReadiness.ts @@ -62,6 +62,12 @@ const getProviderHost = (baseUrl: string): string => { const hasProviderSecret = (provider: AIProviderConfig): boolean => provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey); +const isBaseURLOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + +const isModelOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + const getSelectedProvider = (params: { providers?: AIProviderConfig[]; activeProvider?: AIProviderConfig | null; @@ -143,10 +149,10 @@ export const buildAIChatReadinessSnapshot = (params: { if (!hasProviderSecret(activeProvider)) { issues.push('missing_secret'); } - if (!trimText(activeProvider.baseUrl)) { + if (!isBaseURLOptionalProvider(activeProvider) && !trimText(activeProvider.baseUrl)) { issues.push('missing_base_url'); } - if (!trimText(activeProvider.model)) { + if (!isModelOptionalProvider(activeProvider) && !trimText(activeProvider.model)) { issues.push('missing_selected_model'); } @@ -189,7 +195,7 @@ export const buildAIChatReadinessSnapshot = (params: { }; } - if (!providerSummary.model) { + if (!providerSummary.model && !isModelOptionalProvider(activeProvider)) { const title = params.loadingModels ? `正在加载 ${providerSummary.name || providerSummary.id || '当前供应商'} 的模型列表` : `先为 ${providerSummary.name || providerSummary.id || '当前供应商'} 选择一个模型`; @@ -218,7 +224,11 @@ export const buildAIChatReadinessSnapshot = (params: { }; } - const title = `AI 已就绪:${providerSummary.name || providerSummary.id} / ${providerSummary.model}`; + const resolvedProviderLabel = providerSummary.name || providerSummary.id; + const resolvedModelLabel = providerSummary.model || (isModelOptionalProvider(activeProvider) ? '自动选择' : ''); + const title = resolvedModelLabel + ? `AI 已就绪:${resolvedProviderLabel} / ${resolvedModelLabel}` + : `AI 已就绪:${resolvedProviderLabel}`; const description = contextAttachedCount > 0 ? `当前已关联 ${contextAttachedCount} 张表结构上下文,可直接发送。` : hasConnectionContext diff --git a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx index 8b6f95e..952864f 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx @@ -25,6 +25,16 @@ describe('aiSettingsModalConfig', () => { expect(preset.key).toBe('qwen-coding-plan'); }); + it('matches a CodeBuddy CLI provider back to the dedicated preset', () => { + const preset = matchProviderPreset({ + type: 'custom', + baseUrl: '', + apiFormat: 'codebuddy-cli', + }); + + expect(preset.key).toBe('codebuddy'); + }); + it('creates MCP server drafts and skill drafts with stable defaults', () => { const server = EMPTY_MCP_SERVER({ name: 'Browser', args: ['stdio'] }); const skill = EMPTY_SKILL(); @@ -38,6 +48,7 @@ describe('aiSettingsModalConfig', () => { it('keeps the provider preset list available for the settings modal', () => { expect(PROVIDER_PRESETS.some((item) => item.key === 'codex')).toBe(false); + expect(PROVIDER_PRESETS.some((item) => item.key === 'codebuddy')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true); }); diff --git a/frontend/src/components/ai/aiSettingsModalConfig.tsx b/frontend/src/components/ai/aiSettingsModalConfig.tsx index a806d61..5e54355 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.tsx @@ -46,6 +46,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'volcengine-ark', label: '火山方舟', icon: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, { key: 'minimax', label: 'MiniMax', icon: , desc: 'M3 / M2.7 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] }, + { key: 'codebuddy', label: 'CodeBuddy', icon: , desc: '本地 CodeBuddy CLI / 官方登录态', color: '#2563eb', backendType: 'custom', fixedApiFormat: 'codebuddy-cli', defaultBaseUrl: '', defaultModel: '', models: [] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c01860d..98473a9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -600,7 +600,7 @@ export interface AIProviderConfig { baseUrl: string; model: string; models?: string[]; - apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli + apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli headers?: Record; maxTokens: number; temperature: number; diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index c1384e1..f872fc8 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -29,6 +29,7 @@ const PRESETS: PresetMatcher[] = [ defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, fixedApiFormat: 'claude-cli', }, + { key: 'codebuddy', backendType: 'custom', defaultBaseUrl: '', fixedApiFormat: 'codebuddy-cli' }, { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, ]; @@ -112,7 +113,7 @@ describe('ai provider preset helpers', () => { it('keeps the user-entered base URL for custom-like presets', () => { expect(resolvePresetBaseURL({ - presetKey: 'custom', + presetKey: 'codebuddy', presetDefaultBaseUrl: '', valuesBaseUrl: 'https://example-proxy.internal/v1', })).toBe('https://example-proxy.internal/v1'); @@ -182,4 +183,18 @@ describe('resolveProviderPresetKey', () => { expect(key).toBe('qwen-bailian'); }); + + it('能识别没有 Base URL 的 CodeBuddy CLI 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'codebuddy-cli', + baseUrl: '', + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('codebuddy'); + }); }); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index 0e96558..fd1fe33 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -17,7 +17,7 @@ export const QWEN_CODING_PLAN_MODELS = [ 'glm-4.7', ]; -const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']); +const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy']); export interface ResolvePresetModelSelectionInput { presetKey: string; @@ -126,6 +126,17 @@ export const resolveProviderPresetKey = ( } const fingerprint = getProviderFingerprint(provider.baseUrl); + const formatOnlyPreset = presets.find((preset) => + preset.backendType === provider.type + && Boolean(preset.fixedApiFormat) + && preset.fixedApiFormat === provider.apiFormat + && getProviderFingerprint(preset.defaultBaseUrl) === '' + && fingerprint === '', + ); + if (formatOnlyPreset) { + return formatOnlyPreset.key; + } + const exactPreset = presets.find((preset) => preset.backendType === provider.type && fingerprint !== '' diff --git a/internal/ai/provider/codebuddy_cli.go b/internal/ai/provider/codebuddy_cli.go new file mode 100644 index 0000000..84714c8 --- /dev/null +++ b/internal/ai/provider/codebuddy_cli.go @@ -0,0 +1,446 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + ai "GoNavi-Wails/internal/ai" + "GoNavi-Wails/internal/logger" +) + +var codebuddyLookPath = exec.LookPath +var codebuddyCommandContext = exec.CommandContext +var codebuddyCLIRequestTimeout = 90 * time.Second + +// CodeBuddyCLIProvider 通过 CodeBuddy CLI 发送聊天请求。 +type CodeBuddyCLIProvider struct { + config ai.ProviderConfig +} + +// NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。 +func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) { + return &CodeBuddyCLIProvider{config: config}, nil +} + +func (p *CodeBuddyCLIProvider) Name() string { + return "CodeBuddyCLI" +} + +func (p *CodeBuddyCLIProvider) Validate() error { + _, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return err + } + return nil +} + +func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) + defer cancel() + + commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return nil, err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"} + if strings.TrimSpace(p.config.Model) != "" { + args = append(args, "--model", strings.TrimSpace(p.config.Model)) + } + + cmd := codebuddyCommandContext(ctx, commandName, args...) + if err := p.setEnv(cmd); err != nil { + return nil, err + } + + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + codebuddyCLIEndpointForLog(p.config), + buildCodeBuddyCLIRequestLogBody("json", commandName, args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + + output, err := cmd.Output() + if err != nil { + if isClaudeCLITimeout(ctx, err) { + requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + return nil, requestErr + } + if exitErr, ok := err.(*exec.ExitError); ok { + requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr)) + return nil, requestErr + } + requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err) + return nil, requestErr + } + + resp, parseErr := parseCodeBuddyCLIChatOutput(output) + if parseErr != nil { + requestErr = parseErr + return nil, requestErr + } + return resp, nil +} + +func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) + defer cancel() + + commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) + if err != nil { + return err + } + + prompt := buildPrompt(req.Messages) + args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"} + if strings.TrimSpace(p.config.Model) != "" { + args = append(args, "--model", strings.TrimSpace(p.config.Model)) + } + + cmd := codebuddyCommandContext(ctx, commandName, args...) + if err := p.setEnv(cmd); err != nil { + return err + } + + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + codebuddyCLIEndpointForLog(p.config), + buildCodeBuddyCLIRequestLogBody("stream-json", commandName, args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + + cmd.Stdin = nil + + stdout, err := cmd.StdoutPipe() + if err != nil { + requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err) + return requestErr + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err) + return requestErr + } + + if cmd.Process != nil { + logger.Infof("CodeBuddyCLI 请求进程已启动:requestId=%s pid=%d", requestLog.id, cmd.Process.Pid) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + continue + } + + var event cliStreamEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出:requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line)) + continue + } + + switch event.Type { + case "system": + if isCodeBuddyCLISystemRetryEvent(event) { + if errMsg, hasError := extractCodeBuddyCLISystemRetryError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 鉴权失败: %s", errMsg) + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + return nil + } + } + case "assistant": + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + if event.Message.Content != nil { + for _, block := range event.Message.Content { + if block.Type == "thinking" && block.Thinking != "" { + callback(ai.StreamChunk{Thinking: block.Thinking}) + } else if block.Type == "text" && block.Text != "" { + callback(ai.StreamChunk{Content: block.Text}) + } + } + } + case "content_block_delta": + if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { + callback(ai.StreamChunk{Thinking: event.Delta.Thinking}) + } else if event.Delta.Text != "" { + callback(ai.StreamChunk{Content: event.Delta.Text}) + } + case "result": + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + callback(ai.StreamChunk{Done: true}) + _ = cmd.Wait() + return nil + case "error": + errMsg, _ := extractCodeBuddyCLIEventError(event) + callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + _ = cmd.Wait() + return nil + } + } + + waitErr := cmd.Wait() + stderrStr := strings.TrimSpace(stderrBuf.String()) + + if isClaudeCLITimeout(ctx, waitErr) { + requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + callback(ai.StreamChunk{ + Error: requestErr.Error(), + Done: true, + }) + return nil + } + + if waitErr != nil { + errMsg := fmt.Sprintf("CodeBuddy CLI 异常退出: %v", waitErr) + if stderrStr != "" { + errMsg = fmt.Sprintf("CodeBuddy CLI 异常退出: %s", stderrStr) + } + requestErr = fmt.Errorf("%s", errMsg) + callback(ai.StreamChunk{Error: errMsg, Done: true}) + return nil + } + + callback(ai.StreamChunk{Done: true}) + return nil +} + +func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) { + for _, command := range []string{"codebuddy", "cbc"} { + if _, err := lookPath(command); err == nil { + return command, nil + } + } + return "", fmt.Errorf("未找到 codebuddy 命令,请先安装 CodeBuddy CLI: npm install -g @tencent/codebuddy") +} + +func codebuddyCLIEndpointForLog(config ai.ProviderConfig) string { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL != "" { + return sanitizeAIUpstreamURL(baseURL) + } + return "codebuddy://cli" +} + +func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any { + return map[string]any{ + "command": commandName, + "args": claudeCLIArgsForLog(args), + "prompt": prompt, + "output_format": outputFormat, + "model": strings.TrimSpace(config.Model), + "base_url": codebuddyCLIEndpointForLog(config), + "has_api_key": strings.TrimSpace(config.APIKey) != "", + "message_count": len(req.Messages), + "tool_count": len(req.Tools), + "tool_names": claudeCLIToolNamesForLog(req.Tools), + } +} + +func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) { + trimmed := bytes.TrimSpace(output) + if len(trimmed) == 0 { + return &ai.ChatResponse{}, nil + } + + var events []cliStreamEvent + if err := json.Unmarshal(trimmed, &events); err == nil && len(events) > 0 { + return buildCodeBuddyCLIResponseFromEvents(events) + } + + var event cliStreamEvent + if err := json.Unmarshal(trimmed, &event); err == nil { + return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event}) + } + + return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil +} + +func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) { + parts := make([]string, 0, len(events)) + resultText := "" + + for _, event := range events { + if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { + return nil, fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + } + if strings.TrimSpace(event.Result) != "" { + resultText = strings.TrimSpace(event.Result) + } + for _, block := range event.Message.Content { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + parts = append(parts, block.Text) + } + } + } + + if resultText != "" { + return &ai.ChatResponse{Content: resultText}, nil + } + if len(parts) > 0 { + return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil + } + return &ai.ChatResponse{}, nil +} + +func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error { + env, err := buildCodeBuddyCLIEnv(p.config, cmd.Environ(), runtime.GOOS, codebuddyLookPath, fileExists) + if err != nil { + return err + } + cmd.Env = env + return nil +} + +func buildCodeBuddyCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) { + env := append([]string(nil), baseEnv...) + if strings.TrimSpace(config.BaseURL) != "" { + env = upsertEnv(env, "CODEBUDDY_BASE_URL", strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")) + } + if strings.TrimSpace(config.APIKey) != "" { + env = upsertEnv(env, "CODEBUDDY_API_KEY", strings.TrimSpace(config.APIKey)) + env = upsertEnv(env, "CODEBUDDY_AUTH_TOKEN", strings.TrimSpace(config.APIKey)) + } + if len(config.Headers) > 0 { + if payload, err := json.Marshal(config.Headers); err == nil { + env = upsertEnv(env, "CODEBUDDY_CUSTOM_HEADERS", string(payload)) + } + } + + gitBashPath, err := resolveCodeBuddyGitBashPath(env, goos, lookPath, exists) + if err != nil { + return nil, err + } + if gitBashPath != "" { + env = upsertEnv(env, "CODEBUDDY_CODE_GIT_BASH_PATH", gitBashPath) + } + return env, nil +} + +func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) { + if goos != "windows" { + return "", nil + } + + if configured := strings.TrimSpace(envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH")); configured != "" { + if exists(configured) { + return configured, nil + } + return "", fmt.Errorf("CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %s", configured) + } + + for _, command := range []string{"bash.exe", "bash"} { + if bashPath, err := lookPath(command); err == nil && exists(bashPath) { + return bashPath, nil + } + } + + if gitPath, err := lookPath("git.exe"); err == nil { + gitDir := parentWindowsPath(gitPath) + for _, candidate := range []string{ + joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"), + joinWindowsPath(gitDir, "bash.exe"), + } { + if candidate != "" && exists(candidate) { + return candidate, nil + } + } + } + + for _, candidate := range windowsGitBashCandidates(env) { + if exists(candidate) { + return candidate, nil + } + } + + return "", nil +} + +func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) { + if event.Type != "error" && !event.IsError { + return "", false + } + + if msg := strings.TrimSpace(event.Result); msg != "" { + return msg, true + } + + for _, block := range event.Message.Content { + if block.Type == "text" && strings.TrimSpace(block.Text) != "" { + return strings.TrimSpace(block.Text), true + } + } + + if msg := strings.TrimSpace(event.Error.Message); msg != "" { + return msg, true + } + + return "CodeBuddy CLI 返回未知错误", true +} + +func isCodeBuddyCLISystemRetryEvent(event cliStreamEvent) bool { + return event.Type == "system" && event.Subtype == "api_retry" +} + +func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) { + if !isCodeBuddyCLISystemRetryEvent(event) { + return "", false + } + + errText := strings.TrimSpace(event.Error.Message) + if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") { + return "", false + } + + if errText == "" { + errText = "authentication_failed" + } + + if event.ErrorStatus > 0 { + return fmt.Sprintf("CodeBuddy CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true + } + return fmt.Sprintf("CodeBuddy CLI 鉴权失败: %s", errText), true +} diff --git a/internal/ai/provider/codebuddy_cli_test.go b/internal/ai/provider/codebuddy_cli_test.go new file mode 100644 index 0000000..6112fa8 --- /dev/null +++ b/internal/ai/provider/codebuddy_cli_test.go @@ -0,0 +1,140 @@ +package provider + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestBuildCodeBuddyCLIEnv_IncludesOfficialEnvNames(t *testing.T) { + env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{ + BaseURL: "https://gateway.codebuddy.example/", + APIKey: "cb-test", + Headers: map[string]string{ + "X-Workspace": "gonavi", + }, + }, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) { + return "", errors.New("unexpected lookup") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := envValue(env, "CODEBUDDY_BASE_URL"); got != "https://gateway.codebuddy.example" { + t.Fatalf("expected trimmed base url, got %q", got) + } + if got := envValue(env, "CODEBUDDY_API_KEY"); got != "cb-test" { + t.Fatalf("expected api key in env, got %q", got) + } + if got := envValue(env, "CODEBUDDY_AUTH_TOKEN"); got != "cb-test" { + t.Fatalf("expected auth token in env, got %q", got) + } + if got := envValue(env, "CODEBUDDY_CUSTOM_HEADERS"); !strings.Contains(got, `"X-Workspace":"gonavi"`) { + t.Fatalf("expected custom headers JSON in env, got %q", got) + } +} + +func TestBuildCodeBuddyCLIEnv_AllowsMissingGitBashOnWindows(t *testing.T) { + env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { + return "", errors.New("not found") + }, func(path string) bool { + return false + }) + if err != nil { + t.Fatalf("expected no error when git bash is missing on windows, got %v", err) + } + if got := envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH"); got != "" { + t.Fatalf("expected no git bash env when missing, got %q", got) + } +} + +func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\"}]'\n") + restore := overrideCodeBuddyCLIForTest(t, fakeCodeBuddy) + defer restore() + + provider, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + resp, err := provider.Chat(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }) + if err != nil { + t.Fatalf("expected chat to succeed, got %v", err) + } + if resp.Content != "hello world" { + t.Fatalf("expected result content, got %#v", resp) + } +} + +func writeFakeCodeBuddyScript(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + + if runtime.GOOS == "windows" { + bashPath, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, exec.LookPath, fileExists) + if err != nil { + t.Fatalf("failed to resolve git bash for fake codebuddy command: %v", err) + } + + scriptPath := filepath.Join(dir, "codebuddy.sh") + if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy shell script: %v", err) + } + + wrapperPath := filepath.Join(dir, "codebuddy.cmd") + wrapper := "@echo off\r\n\"" + bashPath + "\" \"" + scriptPath + "\" %*\r\n" + if err := os.WriteFile(wrapperPath, []byte(wrapper), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy wrapper: %v", err) + } + return wrapperPath + } + + path := filepath.Join(dir, "codebuddy") + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write fake codebuddy script: %v", err) + } + return path +} + +func overrideCodeBuddyCLIForTest(t *testing.T, fakeCodeBuddyPath string) func() { + t.Helper() + + originalLookPath := codebuddyLookPath + originalCommandContext := codebuddyCommandContext + codebuddyLookPath = func(name string) (string, error) { + if name == "codebuddy" || name == "cbc" { + return fakeCodeBuddyPath, nil + } + return originalLookPath(name) + } + codebuddyCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "codebuddy" || name == "cbc" { + return exec.CommandContext(ctx, fakeCodeBuddyPath, args...) + } + return originalCommandContext(ctx, name, args...) + } + + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", filepath.Dir(fakeCodeBuddyPath)+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("failed to override PATH: %v", err) + } + + return func() { + codebuddyLookPath = originalLookPath + codebuddyCommandContext = originalCommandContext + _ = os.Setenv("PATH", originalPath) + } +} diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go index 7900ec2..04a5983 100644 --- a/internal/ai/provider/custom.go +++ b/internal/ai/provider/custom.go @@ -17,15 +17,14 @@ type CustomProvider struct { // NewCustomProvider 创建自定义 Provider 实例 func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { - if strings.TrimSpace(config.BaseURL) == "" { - return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") - } - // 根据 apiFormat 决定使用哪个底层协议,默认 openai apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat)) if apiFormat == "" { apiFormat = "openai" } + if strings.TrimSpace(config.BaseURL) == "" && apiFormat != "claude-cli" && apiFormat != "codebuddy-cli" { + return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") + } var innerProvider Provider var err error @@ -36,6 +35,8 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { innerProvider, err = NewGeminiProvider(config) case "claude-cli": innerProvider, err = NewClaudeCLIProvider(config) + case "codebuddy-cli": + innerProvider, err = NewCodeBuddyCLIProvider(config) default: // "openai" 及其他 innerProvider, err = NewOpenAIProvider(config) } diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index c36a423..9edd556 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -102,6 +102,25 @@ var claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error { return err } +var codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cliProvider, err := provider.NewProvider(config) + if err != nil { + return err + } + + _, err = cliProvider.Chat(ctx, ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "ping"}, + }, + MaxTokens: 1, + Temperature: 0, + }) + return err +} + // NewService 创建 AI Service 实例 func NewService() *Service { return NewServiceWithSecretStore(secretstore.NewKeyringStore()) @@ -533,6 +552,8 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ testConfig.Model = dashScopeCodingPlanModels[0] } err = claudeCLIHealthCheckFunc(testConfig) + case "codebuddy-cli": + err = codebuddyCLIHealthCheckFunc(config) default: if baseURL != "" { req, _ := http.NewRequest("GET", baseURL, nil) @@ -660,6 +681,9 @@ func filterFetchedModelsForProvider(config ai.ProviderConfig, models []string) ( } func defaultStaticModelsForProvider(config ai.ProviderConfig) []string { + if normalizedProviderType(config) == "codebuddy-cli" { + return append([]string(nil), config.Models...) + } if isMiniMaxAnthropicProvider(config) { return append([]string(nil), miniMaxAnthropicModels...) } @@ -726,6 +750,8 @@ func resolveModelsURL(config ai.ProviderConfig) string { baseURL = "https://generativelanguage.googleapis.com" } return baseURL + "/v1beta/models?key=" + config.APIKey + case "codebuddy-cli": + return "" case "openai": fallthrough default: @@ -736,6 +762,9 @@ func resolveModelsURL(config ai.ProviderConfig) string { func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { config = normalizeProviderConfig(config) url := resolveModelsURL(config) + if strings.TrimSpace(url) == "" { + return nil, fmt.Errorf("当前供应商不支持远端模型列表") + } req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %w", err) @@ -862,6 +891,13 @@ func (s *Service) AIListModels() map[string]interface{} { } config = normalizeProviderConfig(config) + if normalizedProviderType(config) == "codebuddy-cli" { + return map[string]interface{}{ + "success": true, + "models": append([]string(nil), config.Models...), + "source": "static", + } + } if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 { return map[string]interface{}{"success": true, "models": staticModels, "source": "static"} } @@ -899,6 +935,8 @@ func fetchModels(config ai.ProviderConfig) ([]string, error) { return fetchAnthropicModels(config) case "gemini": return fetchGeminiModels(config) + case "codebuddy-cli": + return append([]string(nil), config.Models...), nil default: return fetchOpenAIModels(config) } diff --git a/internal/ai/service/service_codebuddy_test.go b/internal/ai/service/service_codebuddy_test.go new file mode 100644 index 0000000..77b0f7e --- /dev/null +++ b/internal/ai/service/service_codebuddy_test.go @@ -0,0 +1,61 @@ +package aiservice + +import ( + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestAIListModels_ReturnsStaticModelsForCodeBuddyCLIWithoutRemoteFetch(t *testing.T) { + service := NewService() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-codebuddy", + Type: "custom", + APIFormat: "codebuddy-cli", + Models: []string{"claude-sonnet-4", "gpt-4.1"}, + }, + } + service.activeProvider = "provider-codebuddy" + + result := service.AIListModels() + if result["success"] != true { + t.Fatalf("expected AIListModels to succeed, got %#v", result) + } + models, ok := result["models"].([]string) + if !ok { + t.Fatalf("expected []string models, got %#v", result["models"]) + } + if len(models) != 2 || models[0] != "claude-sonnet-4" { + t.Fatalf("expected static CodeBuddy models, got %#v", models) + } + if source, _ := result["source"].(string); source != "static" { + t.Fatalf("expected static source, got %#v", result["source"]) + } +} + +func TestAITestProvider_UsesCodeBuddyCLIHealthCheck(t *testing.T) { + originalHealthCheckFunc := codebuddyCLIHealthCheckFunc + defer func() { + codebuddyCLIHealthCheckFunc = originalHealthCheckFunc + }() + + var received ai.ProviderConfig + codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error { + received = config + return nil + } + + service := NewService() + result := service.AITestProvider(ai.ProviderConfig{ + Type: "custom", + APIFormat: "codebuddy-cli", + APIKey: "cb-test", + }) + if result["success"] != true { + t.Fatalf("expected AITestProvider to succeed, got %#v", result) + } + if received.APIFormat != "codebuddy-cli" { + t.Fatalf("expected CodeBuddy test to use codebuddy-cli api format, got %q", received.APIFormat) + } +} diff --git a/internal/ai/types.go b/internal/ai/types.go index c296e50..223b9cc 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -80,7 +80,7 @@ type ProviderConfig struct { BaseURL string `json:"baseUrl"` Model string `json:"model"` Models []string `json:"models,omitempty"` - APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli + APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli Headers map[string]string `json:"headers,omitempty"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"` From b588235b62c2092344ff5da3b8e9acdbf7f5c8e4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 18 Jun 2026 12:35:58 +0800 Subject: [PATCH 14/61] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20Cursor=20Cloud=20Agents=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 cursor-agent provider,支持创建 agent、轮询 run 状态和 SSE 流式响应 - 接入 AITestProvider 与 AIListModels,支持 Cursor 官方 /v1/models 连通性测试和模型发现 - 在 AI 设置中新增 Cursor 供应商预设,固定 cursor-agent 协议并补齐默认端点配置 - 调整 provider readiness 与 insights 规则,允许 Cursor 未显式选模型时走官方默认模型 - 补充后端 provider/service 测试和前端 preset、表单、readiness 相关用例 Close #576 --- frontend/src/components/AISettingsModal.tsx | 2 +- .../ai/AISettingsProvidersSection.test.tsx | 41 ++ .../ai/AISettingsProvidersSection.tsx | 18 +- .../src/components/ai/aiChatReadiness.test.ts | 24 + frontend/src/components/ai/aiChatReadiness.ts | 2 +- .../components/ai/aiProviderInsights.test.ts | 22 + .../src/components/ai/aiProviderInsights.ts | 10 +- .../ai/aiSettingsModalConfig.test.tsx | 11 + .../components/ai/aiSettingsModalConfig.tsx | 1 + frontend/src/types.ts | 2 +- frontend/src/utils/aiProviderPresets.test.ts | 36 ++ frontend/src/utils/aiProviderPresets.ts | 8 +- internal/ai/provider/cursor_agent.go | 568 ++++++++++++++++++ internal/ai/provider/cursor_agent_test.go | 166 +++++ internal/ai/provider/custom.go | 4 +- internal/ai/service/service.go | 44 +- internal/ai/service/service_cursor_test.go | 101 ++++ internal/ai/types.go | 2 +- 18 files changed, 1049 insertions(+), 13 deletions(-) create mode 100644 internal/ai/provider/cursor_agent.go create mode 100644 internal/ai/provider/cursor_agent_test.go create mode 100644 internal/ai/service/service_cursor_test.go diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 1ad4f15..7275f1e 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -345,7 +345,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo // 构建 payload,处理 model/models 逻辑 const preset = findPreset(values.presetKey); - const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama' || values.presetKey === 'codebuddy'; + const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama' || values.presetKey === 'codebuddy' || values.presetKey === 'cursor'; const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({ presetKey: values.presetKey, presetDefaultModel: preset.defaultModel, diff --git a/frontend/src/components/ai/AISettingsProvidersSection.test.tsx b/frontend/src/components/ai/AISettingsProvidersSection.test.tsx index 5496f7d..a7d84f5 100644 --- a/frontend/src/components/ai/AISettingsProvidersSection.test.tsx +++ b/frontend/src/components/ai/AISettingsProvidersSection.test.tsx @@ -9,6 +9,7 @@ import AISettingsProvidersSection from './AISettingsProvidersSection'; const providerPresets = [ { key: 'openai', label: 'OpenAI', icon: O, desc: 'GPT', defaultBaseUrl: 'https://api.openai.com/v1' }, + { key: 'cursor', label: 'Cursor', icon: R, desc: 'Cursor API', defaultBaseUrl: 'https://api.cursor.com/v1' }, { key: 'custom', label: '自定义', icon: C, desc: '自定义接口', defaultBaseUrl: 'https://example.com' }, ]; @@ -150,4 +151,44 @@ describe('AISettingsProvidersSection', () => { expect(markup).toContain('本机 CodeBuddy CLI 已登录账号'); expect(markup).toContain('留空则使用 CodeBuddy CLI 默认网关'); }); + + it('renders automatic-model copy for the Cursor preset', () => { + const Wrap = () => { + const [form] = Form.useForm(); + return ( + {}} + resolveProviderPreset={() => ({ label: 'Cursor', icon: R })} + resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]} + onAddProvider={() => {}} + onEditProvider={() => {}} + onDeleteProvider={() => {}} + onSetActiveProvider={() => {}} + onCancelEdit={() => {}} + onPresetChange={() => {}} + onTestProvider={() => {}} + onSaveProvider={() => {}} + /> + ); + }; + + const markup = renderToStaticMarkup(); + expect(markup).toContain('可选:预填常用 Cursor 模型 ID;留空则由 Cursor 默认模型自动选择'); + }); }); diff --git a/frontend/src/components/ai/AISettingsProvidersSection.tsx b/frontend/src/components/ai/AISettingsProvidersSection.tsx index 94badc3..9d52ae3 100644 --- a/frontend/src/components/ai/AISettingsProvidersSection.tsx +++ b/frontend/src/components/ai/AISettingsProvidersSection.tsx @@ -106,8 +106,9 @@ const AISettingsProvidersSection: React.FC = ({ onSaveProvider, }) => { const presetKeyFromForm = watchedPresetKey || (editingProvider as (AIProviderConfig & { presetKey?: string }) | null)?.presetKey || 'openai'; - const supportsAdvancedEndpoint = presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama' || presetKeyFromForm === 'codebuddy'; + const supportsAdvancedEndpoint = presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama' || presetKeyFromForm === 'codebuddy' || presetKeyFromForm === 'cursor'; const codeBuddyUsesOptionalSecret = presetKeyFromForm === 'codebuddy'; + const cursorUsesOptionalModel = presetKeyFromForm === 'cursor'; const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'; const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg); const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor); @@ -134,7 +135,7 @@ const AISettingsProvidersSection: React.FC = ({ {providers.map((provider) => { const matchedPreset = resolveProviderPreset(provider); const isActive = provider.id === activeProviderId; - const modelLabel = provider.model || (provider.apiFormat === 'codebuddy-cli' ? '自动选择' : '未选择模型'); + const modelLabel = provider.model || (provider.apiFormat === 'codebuddy-cli' || provider.apiFormat === 'cursor-agent' ? '自动选择' : '未选择模型'); return (
= ({ borderRadius: 8, gap: 4, }}> - {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => ( + {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'cursor-agent', label: 'Cursor Agent' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => (
form.setFieldsValue({ apiFormat: format.value })} @@ -319,7 +320,16 @@ const AISettingsProvidersSection: React.FC = ({ )} 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> -
)} diff --git a/frontend/src/components/ai/aiChatReadiness.test.ts b/frontend/src/components/ai/aiChatReadiness.test.ts index d8027ba..2334a4a 100644 --- a/frontend/src/components/ai/aiChatReadiness.test.ts +++ b/frontend/src/components/ai/aiChatReadiness.test.ts @@ -116,4 +116,28 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.title).toContain('CodeBuddy'); expect(snapshot.title).toContain('自动选择'); }); + + it('treats Cursor Agent as ready without an explicit model', () => { + const snapshot = buildAIChatReadinessSnapshot({ + providers: [{ + id: 'provider-1', + type: 'custom', + name: 'Cursor', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.cursor.com/v1', + model: '', + apiFormat: 'cursor-agent', + models: [], + maxTokens: 4096, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + }); + + expect(snapshot.status).toBe('ready'); + expect(snapshot.ready).toBe(true); + expect(snapshot.title).toContain('Cursor'); + expect(snapshot.title).toContain('自动选择'); + }); }); diff --git a/frontend/src/components/ai/aiChatReadiness.ts b/frontend/src/components/ai/aiChatReadiness.ts index 551d4d3..04fe339 100644 --- a/frontend/src/components/ai/aiChatReadiness.ts +++ b/frontend/src/components/ai/aiChatReadiness.ts @@ -66,7 +66,7 @@ const isBaseURLOptionalProvider = (provider: AIProviderConfig): boolean => provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; const isModelOptionalProvider = (provider: AIProviderConfig): boolean => - provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + provider.type === 'custom' && ['codebuddy-cli', 'cursor-agent'].includes(trimText(provider.apiFormat)); const getSelectedProvider = (params: { providers?: AIProviderConfig[]; diff --git a/frontend/src/components/ai/aiProviderInsights.test.ts b/frontend/src/components/ai/aiProviderInsights.test.ts index cbf3456..81de3c6 100644 --- a/frontend/src/components/ai/aiProviderInsights.test.ts +++ b/frontend/src/components/ai/aiProviderInsights.test.ts @@ -70,4 +70,26 @@ describe('aiProviderInsights', () => { expect(JSON.stringify(snapshot)).not.toContain('apiKey'); expect(JSON.stringify(snapshot)).not.toContain('secret-token'); }); + + it('does not flag Cursor Agent for a missing selected model', () => { + const snapshot = buildAIProviderSnapshot({ + providers: [{ + id: 'provider-cursor', + type: 'custom', + name: 'Cursor', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.cursor.com/v1', + model: '', + models: [], + apiFormat: 'cursor-agent', + maxTokens: 4096, + temperature: 0.2, + }], + activeProviderId: 'provider-cursor', + }); + + expect(snapshot.missingSelectedModelCount).toBe(0); + expect(snapshot.providers[0].issues).toEqual(['missing_declared_models']); + }); }); diff --git a/frontend/src/components/ai/aiProviderInsights.ts b/frontend/src/components/ai/aiProviderInsights.ts index 999f49b..8e6583b 100644 --- a/frontend/src/components/ai/aiProviderInsights.ts +++ b/frontend/src/components/ai/aiProviderInsights.ts @@ -17,6 +17,12 @@ const trimText = (value: unknown): string => String(value || '').trim(); const hasProviderSecret = (provider: AIProviderConfig): boolean => provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey); +const isBaseURLOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && trimText(provider.apiFormat) === 'codebuddy-cli'; + +const isModelOptionalProvider = (provider: AIProviderConfig): boolean => + provider.type === 'custom' && ['codebuddy-cli', 'cursor-agent'].includes(trimText(provider.apiFormat)); + const getProviderHost = (baseUrl: string): string => { const normalized = trimText(baseUrl); if (!normalized) { @@ -41,10 +47,10 @@ const buildProviderIssues = (provider: AIProviderConfig): string[] => { if (!hasSecret) { issues.push('missing_secret'); } - if (!baseUrl) { + if (!isBaseURLOptionalProvider(provider) && !baseUrl) { issues.push('missing_base_url'); } - if (!model) { + if (!isModelOptionalProvider(provider) && !model) { issues.push('missing_selected_model'); } if (declaredModels.length === 0) { diff --git a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx index 952864f..d3b771d 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx @@ -35,6 +35,16 @@ describe('aiSettingsModalConfig', () => { expect(preset.key).toBe('codebuddy'); }); + it('matches a Cursor Agent provider back to the dedicated preset', () => { + const preset = matchProviderPreset({ + type: 'custom', + baseUrl: 'https://api.cursor.com/v1', + apiFormat: 'cursor-agent', + }); + + expect(preset.key).toBe('cursor'); + }); + it('creates MCP server drafts and skill drafts with stable defaults', () => { const server = EMPTY_MCP_SERVER({ name: 'Browser', args: ['stdio'] }); const skill = EMPTY_SKILL(); @@ -49,6 +59,7 @@ describe('aiSettingsModalConfig', () => { it('keeps the provider preset list available for the settings modal', () => { expect(PROVIDER_PRESETS.some((item) => item.key === 'codex')).toBe(false); expect(PROVIDER_PRESETS.some((item) => item.key === 'codebuddy')).toBe(true); + expect(PROVIDER_PRESETS.some((item) => item.key === 'cursor')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true); }); diff --git a/frontend/src/components/ai/aiSettingsModalConfig.tsx b/frontend/src/components/ai/aiSettingsModalConfig.tsx index 5e54355..1bffe8e 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.tsx @@ -47,6 +47,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, { key: 'minimax', label: 'MiniMax', icon: , desc: 'M3 / M2.7 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] }, { key: 'codebuddy', label: 'CodeBuddy', icon: , desc: '本地 CodeBuddy CLI / 官方登录态', color: '#2563eb', backendType: 'custom', fixedApiFormat: 'codebuddy-cli', defaultBaseUrl: '', defaultModel: '', models: [] }, + { key: 'cursor', label: 'Cursor', icon: , desc: 'Cloud Agents API / 官方 API Key', color: '#7c3aed', backendType: 'custom', fixedApiFormat: 'cursor-agent', defaultBaseUrl: 'https://api.cursor.com/v1', defaultModel: '', models: [] }, { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98473a9..2d9da71 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -600,7 +600,7 @@ export interface AIProviderConfig { baseUrl: string; model: string; models?: string[]; - apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli + apiFormat?: string; // custom 专用: openai | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli headers?: Record; maxTokens: number; temperature: number; diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index f872fc8..5bdc338 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -30,6 +30,7 @@ const PRESETS: PresetMatcher[] = [ fixedApiFormat: 'claude-cli', }, { key: 'codebuddy', backendType: 'custom', defaultBaseUrl: '', fixedApiFormat: 'codebuddy-cli' }, + { key: 'cursor', backendType: 'custom', defaultBaseUrl: 'https://api.cursor.com/v1', fixedApiFormat: 'cursor-agent' }, { key: 'custom', backendType: 'custom', defaultBaseUrl: '' }, ]; @@ -103,6 +104,19 @@ describe('ai provider preset helpers', () => { }); }); + it('keeps Cursor model empty when only a suggested model list is configured', () => { + expect(resolvePresetModelSelection({ + presetKey: 'cursor', + presetDefaultModel: '', + presetModels: [], + valuesModel: '', + customModels: ['composer-2', 'composer-latest'], + })).toEqual({ + model: '', + models: ['composer-2', 'composer-latest'], + }); + }); + it('forces built-in presets back to their standard base URL when saving or testing', () => { expect(resolvePresetBaseURL({ presetKey: 'qwen-bailian', @@ -119,6 +133,14 @@ describe('ai provider preset helpers', () => { })).toBe('https://example-proxy.internal/v1'); }); + it('keeps the user-entered base URL for the Cursor preset', () => { + expect(resolvePresetBaseURL({ + presetKey: 'cursor', + presetDefaultBaseUrl: 'https://api.cursor.com/v1', + valuesBaseUrl: 'https://cursor-proxy.internal/v1', + })).toBe('https://cursor-proxy.internal/v1'); + }); + it('forces qwen coding plan to save as custom plus claude-cli', () => { expect(resolvePresetTransport({ presetBackendType: 'custom', @@ -197,4 +219,18 @@ describe('resolveProviderPresetKey', () => { expect(key).toBe('codebuddy'); }); + + it('能识别 Cursor Agent 预设', () => { + const key = resolveProviderPresetKey( + { + type: 'custom', + apiFormat: 'cursor-agent', + baseUrl: 'https://api.cursor.com/v1', + }, + PRESETS, + 'custom', + ); + + expect(key).toBe('cursor'); + }); }); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index fd1fe33..b027a95 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -17,7 +17,7 @@ export const QWEN_CODING_PLAN_MODELS = [ 'glm-4.7', ]; -const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy']); +const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama', 'codebuddy', 'cursor']); export interface ResolvePresetModelSelectionInput { presetKey: string; @@ -183,6 +183,12 @@ export const resolvePresetModelSelection = ({ }: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => { const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey); const resolvedModels = isCustomLike ? (customModels || []) : presetModels; + if (presetKey === 'cursor') { + return { + models: resolvedModels, + model: valuesModel || '', + }; + } const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : ''; return { models: resolvedModels, diff --git a/internal/ai/provider/cursor_agent.go b/internal/ai/provider/cursor_agent.go new file mode 100644 index 0000000..7f110bf --- /dev/null +++ b/internal/ai/provider/cursor_agent.go @@ -0,0 +1,568 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "GoNavi-Wails/internal/ai" +) + +const ( + defaultCursorAPIBaseURL = "https://api.cursor.com/v1" + cursorHTTPTimeout = 120 * time.Second + cursorRunPollInterval = time.Second +) + +// CursorAgentProvider 通过 Cursor Cloud Agents API 发起对话。 +// 当前实现为无状态适配:每次请求都创建一个新的 agent,再消费本次 run 的结果。 +type CursorAgentProvider struct { + config ai.ProviderConfig + baseURL string + client *http.Client +} + +// NewCursorAgentProvider 创建 Cursor Agent Provider。 +func NewCursorAgentProvider(config ai.ProviderConfig) (Provider, error) { + normalized := config + normalized.BaseURL = NormalizeCursorAPIBaseURL(config.BaseURL) + normalized.Model = strings.TrimSpace(config.Model) + + return &CursorAgentProvider{ + config: normalized, + baseURL: normalized.BaseURL, + client: &http.Client{ + Timeout: cursorHTTPTimeout, + }, + }, nil +} + +func (p *CursorAgentProvider) Name() string { + if strings.TrimSpace(p.config.Name) != "" { + return p.config.Name + } + return "Cursor" +} + +func (p *CursorAgentProvider) Validate() error { + if strings.TrimSpace(p.config.APIKey) == "" { + return fmt.Errorf("API Key 不能为空") + } + return nil +} + +// NormalizeCursorAPIBaseURL 归一化 Cursor API 的 base URL。 +func NormalizeCursorAPIBaseURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return defaultCursorAPIBaseURL + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return normalizeCursorAPIBaseURLString(trimmed) + } + + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = normalizeCursorAPIPath(parsed.Path) + return strings.TrimRight(parsed.String(), "/") +} + +// ResolveCursorAPIEndpoint 基于归一化后的 base URL 生成具体接口地址。 +func ResolveCursorAPIEndpoint(baseURL string, endpoint string) string { + normalizedBaseURL := NormalizeCursorAPIBaseURL(baseURL) + normalizedEndpoint := strings.TrimLeft(strings.TrimSpace(endpoint), "/") + if normalizedEndpoint == "" { + return normalizedBaseURL + } + return normalizedBaseURL + "/" + normalizedEndpoint +} + +func normalizeCursorAPIBaseURLString(raw string) string { + normalized := strings.TrimRight(strings.TrimSpace(raw), "/") + if normalized == "" { + return defaultCursorAPIBaseURL + } + + lower := strings.ToLower(normalized) + switch { + case strings.HasSuffix(lower, "/v1/agents"): + normalized = normalized[:len(normalized)-len("/v1/agents")] + case strings.HasSuffix(lower, "/agents"): + normalized = normalized[:len(normalized)-len("/agents")] + case strings.HasSuffix(lower, "/v1/models"): + normalized = normalized[:len(normalized)-len("/v1/models")] + case strings.HasSuffix(lower, "/models"): + normalized = normalized[:len(normalized)-len("/models")] + } + normalized = strings.TrimRight(normalized, "/") + if strings.HasSuffix(strings.ToLower(normalized), "/v1") { + return normalized + } + return normalized + "/v1" +} + +func normalizeCursorAPIPath(path string) string { + normalized := strings.TrimRight(strings.TrimSpace(path), "/") + lower := strings.ToLower(normalized) + switch { + case strings.HasSuffix(lower, "/v1/agents"): + normalized = normalized[:len(normalized)-len("/v1/agents")] + case strings.HasSuffix(lower, "/agents"): + normalized = normalized[:len(normalized)-len("/agents")] + case strings.HasSuffix(lower, "/v1/models"): + normalized = normalized[:len(normalized)-len("/v1/models")] + case strings.HasSuffix(lower, "/models"): + normalized = normalized[:len(normalized)-len("/models")] + } + normalized = strings.TrimRight(normalized, "/") + if strings.HasSuffix(strings.ToLower(normalized), "/v1") { + return normalized + } + if normalized == "" { + return "/v1" + } + return normalized + "/v1" +} + +type cursorPrompt struct { + Text string `json:"text"` +} + +type cursorModelSelection struct { + ID string `json:"id"` +} + +type cursorCreateAgentRequest struct { + Prompt cursorPrompt `json:"prompt"` + Model *cursorModelSelection `json:"model,omitempty"` +} + +type cursorCreateAgentResponse struct { + Agent struct { + ID string `json:"id"` + } `json:"agent"` + Run struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + } `json:"run"` +} + +type cursorRunResponse struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Status string `json:"status"` + Result string `json:"result"` + DurationMS int `json:"durationMs"` +} + +type cursorAssistantEvent struct { + Text string `json:"text"` +} + +type cursorErrorEvent struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type cursorResultEvent struct { + RunID string `json:"runId"` + Status string `json:"status"` + Text string `json:"text"` + DurationMS int `json:"durationMs"` +} + +func (p *CursorAgentProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + agentID, runID, err := p.createAgent(ctx, req) + if err != nil { + return nil, err + } + + run, err := p.waitForRun(ctx, agentID, runID) + if err != nil { + return nil, err + } + + return &ai.ChatResponse{ + Content: strings.TrimSpace(run.Result), + }, nil +} + +func (p *CursorAgentProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + if err := p.Validate(); err != nil { + return err + } + + agentID, runID, err := p.createAgent(ctx, req) + if err != nil { + return err + } + + stream, err := p.openRunStream(ctx, agentID, runID) + if err != nil { + return err + } + defer stream.Close() + + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var ( + currentEventType string + currentDataLines []string + receivedAssistantText bool + receivedResultText bool + completedExplicitly bool + ) + + dispatchEvent := func(eventType string, dataLines []string) (bool, error) { + if strings.TrimSpace(eventType) == "" { + eventType = "message" + } + payload := strings.TrimSpace(strings.Join(dataLines, "\n")) + switch eventType { + case "assistant": + if payload == "" { + return false, nil + } + var event cursorAssistantEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return false, nil + } + if strings.TrimSpace(event.Text) != "" { + receivedAssistantText = true + callback(ai.StreamChunk{Content: event.Text}) + } + case "thinking": + if payload == "" { + return false, nil + } + var event cursorAssistantEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return false, nil + } + if strings.TrimSpace(event.Text) != "" { + callback(ai.StreamChunk{ + Thinking: event.Text, + ReasoningContent: event.Text, + }) + } + case "result": + if payload == "" { + return false, nil + } + var event cursorResultEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return false, nil + } + if !receivedAssistantText && strings.TrimSpace(event.Text) != "" { + receivedResultText = true + callback(ai.StreamChunk{Content: event.Text}) + } + if isCursorRunFailureStatus(event.Status) { + callback(ai.StreamChunk{ + Error: cursorRunStatusMessage(event.Status, event.Text), + Done: true, + }) + completedExplicitly = true + return true, nil + } + case "error": + if payload == "" { + callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true}) + completedExplicitly = true + return true, nil + } + var event cursorErrorEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true}) + completedExplicitly = true + return true, nil + } + errMessage := strings.TrimSpace(event.Message) + if errMessage == "" { + errMessage = "Cursor 流式请求失败" + } + callback(ai.StreamChunk{Error: errMessage, Done: true}) + completedExplicitly = true + return true, nil + case "done": + callback(ai.StreamChunk{Done: true}) + completedExplicitly = true + return true, nil + } + return false, nil + } + + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.TrimSpace(line) == "": + done, dispatchErr := dispatchEvent(currentEventType, currentDataLines) + currentEventType = "" + currentDataLines = nil + if dispatchErr != nil { + return dispatchErr + } + if done { + return nil + } + case strings.HasPrefix(line, "event:"): + currentEventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + currentDataLines = append(currentDataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("读取 Cursor 流式响应失败: %w", err) + } + + if len(currentDataLines) > 0 || strings.TrimSpace(currentEventType) != "" { + done, dispatchErr := dispatchEvent(currentEventType, currentDataLines) + if dispatchErr != nil { + return dispatchErr + } + if done { + return nil + } + } + + if !completedExplicitly { + if !receivedAssistantText && !receivedResultText { + callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 Cursor 配置或模型权限", Done: true}) + return nil + } + callback(ai.StreamChunk{Done: true}) + } + return nil +} + +func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatRequest) (string, string, error) { + requestBody, err := buildCursorCreateAgentRequest(req, p.config.Model) + if err != nil { + return "", "", err + } + + responseBody := cursorCreateAgentResponse{} + if err := p.doJSONRequest(ctx, http.MethodPost, ResolveCursorAPIEndpoint(p.baseURL, "agents"), requestBody, &responseBody, "application/json"); err != nil { + return "", "", err + } + + agentID := strings.TrimSpace(responseBody.Agent.ID) + runID := strings.TrimSpace(responseBody.Run.ID) + if agentID == "" || runID == "" { + return "", "", fmt.Errorf("Cursor 创建 agent 成功,但未返回有效的 agentId/runId") + } + return agentID, runID, nil +} + +func buildCursorCreateAgentRequest(req ai.ChatRequest, model string) (cursorCreateAgentRequest, error) { + prompt, err := buildCursorPrompt(req.Messages) + if err != nil { + return cursorCreateAgentRequest{}, err + } + + requestBody := cursorCreateAgentRequest{ + Prompt: cursorPrompt{ + Text: prompt, + }, + } + + if trimmedModel := strings.TrimSpace(model); trimmedModel != "" { + requestBody.Model = &cursorModelSelection{ID: trimmedModel} + } + + return requestBody, nil +} + +func buildCursorPrompt(messages []ai.Message) (string, error) { + requestMessages := messages + if requestMessagesContainImages(messages) { + requestMessages = stripImagesFromRequestMessages(messages) + } + + prompt := strings.TrimSpace(buildPrompt(requestMessages)) + if prompt == "" { + return "", fmt.Errorf("请求内容不能为空") + } + return prompt, nil +} + +func (p *CursorAgentProvider) waitForRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) { + ticker := time.NewTicker(cursorRunPollInterval) + defer ticker.Stop() + + for { + run, err := p.getRun(ctx, agentID, runID) + if err != nil { + return nil, err + } + if isCursorRunTerminalStatus(run.Status) { + if isCursorRunFailureStatus(run.Status) { + return nil, fmt.Errorf("%s", cursorRunStatusMessage(run.Status, run.Result)) + } + return run, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) { + endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s", agentID, runID)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("创建 Cursor run 查询失败: %w", err) + } + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) + for k, v := range p.config.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("查询 Cursor run 状态失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("Cursor run 查询失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + } + + responseBody := cursorRunResponse{} + if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil { + return nil, fmt.Errorf("解析 Cursor run 响应失败: %w", err) + } + return &responseBody, nil +} + +func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, runID string) (io.ReadCloser, error) { + endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s/stream", agentID, runID)) + requestLog := logAIUpstreamRequestStart(p.Name(), http.MethodGet, endpoint, nil) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + logAIUpstreamRequestFinish(requestLog, 0, err) + return nil, fmt.Errorf("创建 Cursor 流式请求失败: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + for k, v := range p.config.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := p.client.Do(httpReq) + if err != nil { + logAIUpstreamRequestFinish(requestLog, 0, err) + return nil, fmt.Errorf("发送 Cursor 流式请求失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr) + return nil, statusErr + } + + logAIUpstreamRequestFinish(requestLog, resp.StatusCode, nil) + return resp.Body, nil +} + +func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, endpoint string, body any, target any, accept string) error { + var requestBody io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("序列化 Cursor 请求失败: %w", err) + } + requestBody = bytes.NewReader(bodyBytes) + } + + requestLog := logAIUpstreamRequestStart(p.Name(), method, endpoint, body) + httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, requestBody) + if err != nil { + logAIUpstreamRequestFinish(requestLog, 0, err) + return fmt.Errorf("创建 Cursor 请求失败: %w", err) + } + + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + if strings.TrimSpace(accept) != "" { + httpReq.Header.Set("Accept", accept) + } + httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) + for k, v := range p.config.Headers { + httpReq.Header.Set(k, v) + } + + resp, err := p.client.Do(httpReq) + if err != nil { + logAIUpstreamRequestFinish(requestLog, 0, err) + return fmt.Errorf("发送 Cursor 请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr) + return statusErr + } + + if target != nil { + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + logAIUpstreamRequestFinish(requestLog, resp.StatusCode, err) + return fmt.Errorf("解析 Cursor 响应失败: %w", err) + } + } + + logAIUpstreamRequestFinish(requestLog, resp.StatusCode, nil) + return nil +} + +func isCursorRunTerminalStatus(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "FINISHED", "ERROR", "CANCELLED", "EXPIRED": + return true + default: + return false + } +} + +func isCursorRunFailureStatus(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "ERROR", "CANCELLED", "EXPIRED": + return true + default: + return false + } +} + +func cursorRunStatusMessage(status string, result string) string { + normalizedStatus := strings.ToUpper(strings.TrimSpace(status)) + if text := strings.TrimSpace(result); text != "" { + return fmt.Sprintf("Cursor 运行结束(%s):%s", normalizedStatus, text) + } + return fmt.Sprintf("Cursor 运行结束(%s)", normalizedStatus) +} diff --git a/internal/ai/provider/cursor_agent_test.go b/internal/ai/provider/cursor_agent_test.go new file mode 100644 index 0000000..10d7ed5 --- /dev/null +++ b/internal/ai/provider/cursor_agent_test.go @@ -0,0 +1,166 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestCursorAgentProviderChat_PollsUntilFinished(t *testing.T) { + var ( + receivedAuthorization string + receivedPromptText string + pollCount int32 + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + receivedAuthorization = r.Header.Get("Authorization") + var body struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + Model *struct { + ID string `json:"id"` + } `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode create agent body: %v", err) + } + receivedPromptText = body.Prompt.Text + if body.Model == nil || body.Model.ID != "composer-latest" { + t.Fatalf("expected model to be forwarded, got %#v", body.Model) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{"id": "bc-1"}, + "run": map[string]any{"id": "run-1", "agentId": "bc-1"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-1/runs/run-1": + next := atomic.AddInt32(&pollCount, 1) + if next == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-1", + "agentId": "bc-1", + "status": "RUNNING", + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-1", + "agentId": "bc-1", + "status": "FINISHED", + "result": "done from cursor", + "durationMs": 1234, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := NewCursorAgentProvider(ai.ProviderConfig{ + Name: "Cursor", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + Model: "composer-latest", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resp, err := provider.Chat(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "hello cursor"}, + }, + }) + if err != nil { + t.Fatalf("chat failed: %v", err) + } + + if receivedAuthorization != "Bearer cursor-key" { + t.Fatalf("expected bearer auth header, got %q", receivedAuthorization) + } + if !strings.Contains(receivedPromptText, "You are helpful") || !strings.Contains(receivedPromptText, "hello cursor") { + t.Fatalf("expected prompt text to include flattened history, got %q", receivedPromptText) + } + if resp.Content != "done from cursor" { + t.Fatalf("expected final result content, got %q", resp.Content) + } + if atomic.LoadInt32(&pollCount) < 2 { + t.Fatalf("expected provider to poll until terminal status, got %d polls", pollCount) + } +} + +func TestCursorAgentProviderChatStream_MapsAssistantAndThinkingEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{"id": "bc-2"}, + "run": map[string]any{"id": "run-2", "agentId": "bc-2"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-2/runs/run-2/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: status\n")) + _, _ = w.Write([]byte("data: {\"runId\":\"run-2\",\"status\":\"RUNNING\"}\n\n")) + _, _ = w.Write([]byte("event: thinking\n")) + _, _ = w.Write([]byte("data: {\"text\":\"plan first\"}\n\n")) + _, _ = w.Write([]byte("event: tool_call\n")) + _, _ = w.Write([]byte("data: {\"callId\":\"tool-1\",\"name\":\"shell\",\"status\":\"running\"}\n\n")) + _, _ = w.Write([]byte("event: assistant\n")) + _, _ = w.Write([]byte("data: {\"text\":\"partial answer\"}\n\n")) + _, _ = w.Write([]byte("event: result\n")) + _, _ = w.Write([]byte("data: {\"runId\":\"run-2\",\"status\":\"FINISHED\",\"text\":\"final answer\"}\n\n")) + _, _ = w.Write([]byte("event: done\n")) + _, _ = w.Write([]byte("data: {}\n\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := NewCursorAgentProvider(ai.ProviderConfig{ + Name: "Cursor", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "stream this"}, + }, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("chat stream failed: %v", err) + } + + if len(chunks) < 3 { + t.Fatalf("expected multiple stream chunks, got %d", len(chunks)) + } + if chunks[0].Thinking != "plan first" { + t.Fatalf("expected thinking chunk, got %#v", chunks[0]) + } + if chunks[1].Content != "partial answer" { + t.Fatalf("expected assistant content chunk, got %#v", chunks[1]) + } + if len(chunks[1].ToolCalls) != 0 { + t.Fatalf("expected cursor tool_call events to stay unmapped, got %#v", chunks[1].ToolCalls) + } + if !chunks[len(chunks)-1].Done { + t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1]) + } +} diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go index 04a5983..18f7df4 100644 --- a/internal/ai/provider/custom.go +++ b/internal/ai/provider/custom.go @@ -9,7 +9,7 @@ import ( ) // CustomProvider 自定义 Provider,根据 apiFormat 选择底层协议 -// 支持 openai / anthropic / gemini 三种 API 格式 +// 支持 openai / anthropic / gemini / cursor-agent 等 API 格式 type CustomProvider struct { inner Provider name string @@ -33,6 +33,8 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { innerProvider, err = NewAnthropicProvider(config) case "gemini": innerProvider, err = NewGeminiProvider(config) + case "cursor-agent": + innerProvider, err = NewCursorAgentProvider(config) case "claude-cli": innerProvider, err = NewClaudeCLIProvider(config) case "codebuddy-cli": diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 9edd556..d735434 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -510,7 +510,7 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ var err error switch providerType { - case "openai", "anthropic", "gemini": + case "openai", "anthropic", "gemini", "cursor-agent": req, reqErr := newProviderHealthCheckRequest(config) if reqErr != nil { err = s.localizeProviderHealthCheckRequestError(reqErr) @@ -750,6 +750,8 @@ func resolveModelsURL(config ai.ProviderConfig) string { baseURL = "https://generativelanguage.googleapis.com" } return baseURL + "/v1beta/models?key=" + config.APIKey + case "cursor-agent": + return provider.ResolveCursorAPIEndpoint(baseURL, "models") case "codebuddy-cli": return "" case "openai": @@ -779,6 +781,8 @@ func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { } case "gemini": // Gemini 使用 query string 传递 key,无需额外鉴权头 + case "cursor-agent": + req.Header.Set("Authorization", "Bearer "+config.APIKey) default: req.Header.Set("Authorization", "Bearer "+config.APIKey) } @@ -935,6 +939,8 @@ func fetchModels(config ai.ProviderConfig) ([]string, error) { return fetchAnthropicModels(config) case "gemini": return fetchGeminiModels(config) + case "cursor-agent": + return fetchCursorModels(config) case "codebuddy-cli": return append([]string(nil), config.Models...), nil default: @@ -1057,6 +1063,42 @@ func fetchGeminiModels(config ai.ProviderConfig) ([]string, error) { return models, nil } +func fetchCursorModels(config ai.ProviderConfig) ([]string, error) { + req, err := newModelsRequest(config) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求模型列表失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var result struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析模型列表失败: %w", err) + } + + models := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + if strings.TrimSpace(item.ID) != "" { + models = append(models, item.ID) + } + } + return models, nil +} + // --- 安全控制 --- // AIGetSafetyLevel 获取当前安全级别 diff --git a/internal/ai/service/service_cursor_test.go b/internal/ai/service/service_cursor_test.go new file mode 100644 index 0000000..cc2a5fe --- /dev/null +++ b/internal/ai/service/service_cursor_test.go @@ -0,0 +1,101 @@ +package aiservice + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestResolveModelsURL_UsesCursorModelsEndpoint(t *testing.T) { + url := resolveModelsURL(ai.ProviderConfig{ + Type: "custom", + APIFormat: "cursor-agent", + BaseURL: "https://api.cursor.com/v1", + }) + if url != "https://api.cursor.com/v1/models" { + t.Fatalf("expected cursor models endpoint, got %q", url) + } +} + +func TestAITestProvider_UsesCursorModelsEndpointAndBearerAuth(t *testing.T) { + var ( + receivedPath string + receivedAuthorization string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedAuthorization = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "composer-2"}, + }, + }) + })) + defer server.Close() + + service := NewService() + result := service.AITestProvider(ai.ProviderConfig{ + Type: "custom", + APIFormat: "cursor-agent", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }) + + if result["success"] != true { + t.Fatalf("expected AITestProvider to succeed, got %#v", result) + } + if receivedPath != "/v1/models" { + t.Fatalf("expected cursor health check to hit /v1/models, got %q", receivedPath) + } + if receivedAuthorization != "Bearer cursor-key" { + t.Fatalf("expected bearer auth header, got %q", receivedAuthorization) + } +} + +func TestAIListModels_FetchesCursorModelItems(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "composer-2"}, + {"id": "composer-latest"}, + }, + }) + })) + defer server.Close() + + service := NewService() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-cursor", + Type: "custom", + APIFormat: "cursor-agent", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }, + } + service.activeProvider = "provider-cursor" + + result := service.AIListModels() + if result["success"] != true { + t.Fatalf("expected AIListModels to succeed, got %#v", result) + } + + models, ok := result["models"].([]string) + if !ok { + t.Fatalf("expected []string models, got %#v", result["models"]) + } + if len(models) != 2 || models[0] != "composer-2" || models[1] != "composer-latest" { + t.Fatalf("unexpected models: %#v", models) + } + if source, _ := result["source"].(string); source != "api" { + t.Fatalf("expected api source, got %#v", result["source"]) + } +} diff --git a/internal/ai/types.go b/internal/ai/types.go index 223b9cc..b8f4881 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -80,7 +80,7 @@ type ProviderConfig struct { BaseURL string `json:"baseUrl"` Model string `json:"model"` Models []string `json:"models,omitempty"` - APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli + APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | cursor-agent | claude-cli | codebuddy-cli Headers map[string]string `json:"headers,omitempty"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"` From 06dd9507ee2c1915b0e31adb91d0f114c1f1e3dc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 18 Jun 2026 13:35:08 +0800 Subject: [PATCH 15/61] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20Cursor=20=E4=B8=8E=20CodeBuddy=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=80=81=E8=81=8A=E5=A4=A9=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力 - 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化 - CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为 - Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口 - 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文 - 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流 --- .../ai/aiChatPayloadDispatch.test.ts | 50 +- .../components/ai/aiChatPayloadDispatch.ts | 7 +- frontend/wailsjs/go/aiservice/Service.d.ts | 2 + frontend/wailsjs/go/aiservice/Service.js | 4 + internal/ai/provider/codebuddy_cli.go | 146 ++++-- internal/ai/provider/codebuddy_cli_test.go | 237 +++++++++ internal/ai/provider/cursor_agent.go | 203 +++++++- internal/ai/provider/cursor_agent_test.go | 185 +++++++ internal/ai/provider/custom.go | 18 + internal/ai/provider/provider.go | 22 + internal/ai/service/service.go | 465 +++++++++++++++--- internal/ai/service/service_cursor_test.go | 190 +++++++ 12 files changed, 1392 insertions(+), 137 deletions(-) diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.test.ts b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts index 4109bd6..7b506ac 100644 --- a/frontend/src/components/ai/aiChatPayloadDispatch.test.ts +++ b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts @@ -38,8 +38,8 @@ describe('aiChatPayloadDispatch', () => { expect(setSending).not.toHaveBeenCalled(); }); - it('appends a non-stream assistant message when only AIChatSend is available', async () => { - const AIChatSend = vi.fn().mockResolvedValue({ + it('appends a non-stream assistant message when session-aware send is available', async () => { + const AIChatSendInSession = vi.fn().mockResolvedValue({ success: true, content: 'done', reasoning_content: 'thinking', @@ -51,7 +51,7 @@ describe('aiChatPayloadDispatch', () => { (globalThis as any).window = { go: { aiservice: { - Service: { AIChatSend }, + Service: { AIChatSendInSession }, }, }, }; @@ -67,6 +67,7 @@ describe('aiChatPayloadDispatch', () => { }); expect(result).toBe('send'); + expect(AIChatSendInSession).toHaveBeenCalledWith('session-1', [{ role: 'user', content: 'hello' }], []); expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ id: 'msg-send', role: 'assistant', @@ -79,7 +80,7 @@ describe('aiChatPayloadDispatch', () => { }); it('settles the pending assistant message when falling back to non-stream send', async () => { - const AIChatSend = vi.fn().mockResolvedValue({ + const AIChatSendInSession = vi.fn().mockResolvedValue({ success: true, content: 'done', reasoning_content: 'thinking', @@ -91,7 +92,7 @@ describe('aiChatPayloadDispatch', () => { (globalThis as any).window = { go: { aiservice: { - Service: { AIChatSend }, + Service: { AIChatSendInSession }, }, }, }; @@ -108,6 +109,7 @@ describe('aiChatPayloadDispatch', () => { }); expect(result).toBe('send'); + expect(AIChatSendInSession).toHaveBeenCalledWith('session-1', [{ role: 'user', content: 'hello' }], []); expect(addAIChatMessage).not.toHaveBeenCalled(); expect(updateAIChatMessage).toHaveBeenCalledWith('session-1', 'assistant-connecting', expect.objectContaining({ content: 'done', @@ -119,6 +121,44 @@ describe('aiChatPayloadDispatch', () => { expect(setSending).toHaveBeenCalledWith(false); }); + it('falls back to stateless AIChatSend when session-aware send is unavailable', async () => { + const AIChatSend = vi.fn().mockResolvedValue({ + success: true, + content: 'done', + reasoning_content: 'thinking', + }); + const addAIChatMessage = vi.fn(); + const setSending = vi.fn(); + + (globalThis as any).window = { + go: { + aiservice: { + Service: { AIChatSend }, + }, + }, + }; + + const result = await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + setSending, + nextMessageId: () => 'msg-send', + }); + + expect(result).toBe('send'); + expect(AIChatSend).toHaveBeenCalledWith([{ role: 'user', content: 'hello' }], []); + expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ + id: 'msg-send', + role: 'assistant', + content: 'done', + thinking: 'thinking', + reasoning_content: 'thinking', + })); + expect(setSending).toHaveBeenCalledWith(false); + }); + it('emits the unavailable message when the AI service is missing', async () => { const addAIChatMessage = vi.fn(); const setSending = vi.fn(); diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.ts b/frontend/src/components/ai/aiChatPayloadDispatch.ts index 14660be..40f6333 100644 --- a/frontend/src/components/ai/aiChatPayloadDispatch.ts +++ b/frontend/src/components/ai/aiChatPayloadDispatch.ts @@ -8,6 +8,7 @@ import { sanitizeErrorMsg } from '../../utils/aiChatRuntime'; interface AIChatService { AIChatStream?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise; + AIChatSendInSession?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise; AIChatSend?: (messages: any[], tools: AIChatToolDefinition[]) => Promise; } @@ -92,8 +93,10 @@ export const dispatchAIChatPayload = async ({ return 'stream'; } - if (service?.AIChatSend) { - const result = await service.AIChatSend(messages, tools); + if (service?.AIChatSendInSession || service?.AIChatSend) { + const result = service?.AIChatSendInSession + ? await service.AIChatSendInSession(sid, messages, tools) + : await service!.AIChatSend!(messages, tools); const rawError = result?.error || '未知错误'; const cleanError = sanitizeErrorMsg(rawError); diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 9f85ebc..847aa0e 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -8,6 +8,8 @@ export function AIChatCancel(arg1:string):Promise; export function AIChatSend(arg1:Array,arg2:Array):Promise>; +export function AIChatSendInSession(arg1:string,arg2:Array,arg3:Array):Promise>; + export function AIChatStream(arg1:string,arg2:Array,arg3:Array):Promise; export function AICheckSQL(arg1:string):Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index 0b44f60..5a7f078 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -14,6 +14,10 @@ export function AIChatSend(arg1, arg2) { return window['go']['aiservice']['Service']['AIChatSend'](arg1, arg2); } +export function AIChatSendInSession(arg1, arg2, arg3) { + return window['go']['aiservice']['Service']['AIChatSendInSession'](arg1, arg2, arg3); +} + export function AIChatStream(arg1, arg2, arg3) { return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2, arg3); } diff --git a/internal/ai/provider/codebuddy_cli.go b/internal/ai/provider/codebuddy_cli.go index 84714c8..4bfb169 100644 --- a/internal/ai/provider/codebuddy_cli.go +++ b/internal/ai/provider/codebuddy_cli.go @@ -24,6 +24,10 @@ type CodeBuddyCLIProvider struct { config ai.ProviderConfig } +type codebuddySessionState struct { + SessionID string `json:"sessionId,omitempty"` +} + // NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。 func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) { return &CodeBuddyCLIProvider{config: config}, nil @@ -42,8 +46,13 @@ func (p *CodeBuddyCLIProvider) Validate() error { } func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + resp, _, err := p.ChatWithState(ctx, nil, req) + return resp, err +} + +func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) { if err := p.Validate(); err != nil { - return nil, err + return nil, nil, err } ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) @@ -51,18 +60,25 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) if err != nil { - return nil, err + return nil, nil, err } + sessionState, err := parseCodeBuddySessionState(state) + if err != nil { + return nil, nil, err + } prompt := buildPrompt(req.Messages) - args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"} + args := []string{"-p", prompt, "--output-format", "json", "--enable-session-tracking"} if strings.TrimSpace(p.config.Model) != "" { args = append(args, "--model", strings.TrimSpace(p.config.Model)) } + if strings.TrimSpace(sessionState.SessionID) != "" { + args = append(args, "--resume", strings.TrimSpace(sessionState.SessionID)) + } cmd := codebuddyCommandContext(ctx, commandName, args...) if err := p.setEnv(cmd); err != nil { - return nil, err + return nil, nil, err } requestLog := logAIUpstreamRequestStart( @@ -80,27 +96,55 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a if err != nil { if isClaudeCLITimeout(ctx, err) { requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) - return nil, requestErr + return nil, nil, requestErr } if exitErr, ok := err.(*exec.ExitError); ok { requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr)) - return nil, requestErr + return nil, nil, requestErr } requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err) - return nil, requestErr + return nil, nil, requestErr } - resp, parseErr := parseCodeBuddyCLIChatOutput(output) + resp, nextSessionID, parseErr := parseCodeBuddyCLIChatOutput(output) if parseErr != nil { requestErr = parseErr - return nil, requestErr + return nil, nil, requestErr } - return resp, nil + if strings.TrimSpace(nextSessionID) == "" { + nextSessionID = strings.TrimSpace(sessionState.SessionID) + } + nextState, err := marshalCodeBuddySessionState(nextSessionID) + if err != nil { + return nil, nil, err + } + return resp, nextState, nil } func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + _, err := p.ChatStreamWithState(ctx, nil, req, callback) + return err +} + +func (p *CodeBuddyCLIProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) { + sessionState, err := parseCodeBuddySessionState(state) + if err != nil { + return nil, err + } + + sessionID, err := p.chatStreamWithSession(ctx, strings.TrimSpace(sessionState.SessionID), req, callback) + if err != nil { + return nil, err + } + if strings.TrimSpace(sessionID) == "" { + sessionID = strings.TrimSpace(sessionState.SessionID) + } + return marshalCodeBuddySessionState(sessionID) +} + +func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resumeSessionID string, req ai.ChatRequest, callback func(ai.StreamChunk)) (string, error) { if err := p.Validate(); err != nil { - return err + return "", err } ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout) @@ -108,18 +152,21 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath) if err != nil { - return err + return "", err } prompt := buildPrompt(req.Messages) - args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"} + args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--enable-session-tracking"} if strings.TrimSpace(p.config.Model) != "" { args = append(args, "--model", strings.TrimSpace(p.config.Model)) } + if strings.TrimSpace(resumeSessionID) != "" { + args = append(args, "--resume", strings.TrimSpace(resumeSessionID)) + } cmd := codebuddyCommandContext(ctx, commandName, args...) if err := p.setEnv(cmd); err != nil { - return err + return "", err } requestLog := logAIUpstreamRequestStart( @@ -138,7 +185,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques stdout, err := cmd.StdoutPipe() if err != nil { requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err) - return requestErr + return "", requestErr } var stderrBuf bytes.Buffer @@ -146,7 +193,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques if err := cmd.Start(); err != nil { requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err) - return requestErr + return "", requestErr } if cmd.Process != nil { @@ -155,6 +202,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 1024*1024) + currentSessionID := strings.TrimSpace(resumeSessionID) for scanner.Scan() { line := scanner.Text() @@ -167,6 +215,9 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出:requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line)) continue } + if strings.TrimSpace(event.SessionID) != "" { + currentSessionID = strings.TrimSpace(event.SessionID) + } switch event.Type { case "system": @@ -178,7 +229,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques _ = cmd.Process.Kill() } _ = cmd.Wait() - return nil + return "", nil } } case "assistant": @@ -186,7 +237,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques callback(ai.StreamChunk{Error: errMsg, Done: true}) requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) _ = cmd.Wait() - return nil + return "", nil } if event.Message.Content != nil { for _, block := range event.Message.Content { @@ -208,17 +259,17 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques callback(ai.StreamChunk{Error: errMsg, Done: true}) requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) _ = cmd.Wait() - return nil + return "", nil } callback(ai.StreamChunk{Done: true}) _ = cmd.Wait() - return nil + return currentSessionID, nil case "error": errMsg, _ := extractCodeBuddyCLIEventError(event) callback(ai.StreamChunk{Error: errMsg, Done: true}) requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) _ = cmd.Wait() - return nil + return "", nil } } @@ -231,7 +282,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques Error: requestErr.Error(), Done: true, }) - return nil + return "", nil } if waitErr != nil { @@ -241,11 +292,38 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques } requestErr = fmt.Errorf("%s", errMsg) callback(ai.StreamChunk{Error: errMsg, Done: true}) - return nil + return "", nil } callback(ai.StreamChunk{Done: true}) - return nil + return currentSessionID, nil +} + +func parseCodeBuddySessionState(state json.RawMessage) (codebuddySessionState, error) { + trimmed := bytes.TrimSpace(state) + if len(trimmed) == 0 { + return codebuddySessionState{}, nil + } + + var sessionState codebuddySessionState + if err := json.Unmarshal(trimmed, &sessionState); err != nil { + return codebuddySessionState{}, fmt.Errorf("解析 CodeBuddy 会话状态失败: %w", err) + } + sessionState.SessionID = strings.TrimSpace(sessionState.SessionID) + return sessionState, nil +} + +func marshalCodeBuddySessionState(sessionID string) (json.RawMessage, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, nil + } + + payload, err := json.Marshal(codebuddySessionState{SessionID: sessionID}) + if err != nil { + return nil, fmt.Errorf("序列化 CodeBuddy 会话状态失败: %w", err) + } + return json.RawMessage(payload), nil } func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) { @@ -280,10 +358,10 @@ func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, ar } } -func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) { +func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, string, error) { trimmed := bytes.TrimSpace(output) if len(trimmed) == 0 { - return &ai.ChatResponse{}, nil + return &ai.ChatResponse{}, "", nil } var events []cliStreamEvent @@ -296,20 +374,24 @@ func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) { return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event}) } - return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil + return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, "", nil } -func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) { +func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, string, error) { parts := make([]string, 0, len(events)) resultText := "" + sessionID := "" for _, event := range events { if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { - return nil, fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + return nil, "", fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) } if strings.TrimSpace(event.Result) != "" { resultText = strings.TrimSpace(event.Result) } + if strings.TrimSpace(event.SessionID) != "" { + sessionID = strings.TrimSpace(event.SessionID) + } for _, block := range event.Message.Content { if block.Type == "text" && strings.TrimSpace(block.Text) != "" { parts = append(parts, block.Text) @@ -318,12 +400,12 @@ func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatRespo } if resultText != "" { - return &ai.ChatResponse{Content: resultText}, nil + return &ai.ChatResponse{Content: resultText}, sessionID, nil } if len(parts) > 0 { - return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil + return &ai.ChatResponse{Content: strings.Join(parts, "")}, sessionID, nil } - return &ai.ChatResponse{}, nil + return &ai.ChatResponse{}, sessionID, nil } func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error { diff --git a/internal/ai/provider/codebuddy_cli_test.go b/internal/ai/provider/codebuddy_cli_test.go index 6112fa8..a65941a 100644 --- a/internal/ai/provider/codebuddy_cli_test.go +++ b/internal/ai/provider/codebuddy_cli_test.go @@ -2,6 +2,7 @@ package provider import ( "context" + "encoding/json" "errors" "os" "os/exec" @@ -79,6 +80,191 @@ func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) { } } +func TestCodeBuddyCLIProviderChatWithState_StartsTrackedSession(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"session_id\":\"session-new\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\",\"session_id\":\"session-new\"}]'\n") + var capturedArgs []string + restore := overrideCodeBuddyCLIForTestWithCapture(t, fakeCodeBuddy, func(args []string) { + capturedArgs = append([]string(nil), args...) + }) + defer restore() + + providerInstance, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + Model: "deepseek-v3", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + resp, nextState, err := providerInstance.(SessionChatProvider).ChatWithState( + context.Background(), + nil, + ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, + ) + if err != nil { + t.Fatalf("expected chat with state to succeed, got %v", err) + } + + if resp == nil || resp.Content != "hello world" { + t.Fatalf("unexpected response: %#v", resp) + } + if string(nextState) != `{"sessionId":"session-new"}` { + t.Fatalf("expected new session state, got %s", string(nextState)) + } + if !hasArg(capturedArgs, "--enable-session-tracking") { + t.Fatalf("expected session tracking flag, got args %#v", capturedArgs) + } + if hasArg(capturedArgs, "--no-session-persistence") { + t.Fatalf("did not expect no-session-persistence flag, got args %#v", capturedArgs) + } + if hasArg(capturedArgs, "--resume") { + t.Fatalf("did not expect resume flag for first session, got args %#v", capturedArgs) + } + if !hasArgSequence(capturedArgs, "--model", "deepseek-v3") { + t.Fatalf("expected model flag to be preserved, got args %#v", capturedArgs) + } +} + +func TestCodeBuddyCLIProviderChatWithState_ResumesExistingSession(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"continued\"}]}}]'\n") + var capturedArgs []string + restore := overrideCodeBuddyCLIForTestWithCapture(t, fakeCodeBuddy, func(args []string) { + capturedArgs = append([]string(nil), args...) + }) + defer restore() + + providerInstance, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + resp, nextState, err := providerInstance.(SessionChatProvider).ChatWithState( + context.Background(), + json.RawMessage(`{"sessionId":"session-existing"}`), + ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping again"}}, + }, + ) + if err != nil { + t.Fatalf("expected resumed chat with state to succeed, got %v", err) + } + + if resp == nil || resp.Content != "continued" { + t.Fatalf("unexpected response: %#v", resp) + } + if string(nextState) != `{"sessionId":"session-existing"}` { + t.Fatalf("expected existing session state to be preserved, got %s", string(nextState)) + } + if !hasArgSequence(capturedArgs, "--resume", "session-existing") { + t.Fatalf("expected resume args, got %#v", capturedArgs) + } + if !hasArg(capturedArgs, "--enable-session-tracking") { + t.Fatalf("expected session tracking flag, got args %#v", capturedArgs) + } +} + +func TestCodeBuddyCLIProviderChatStreamWithState_StartsTrackedSession(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"session_id\":\"session-new\"}' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello from codebuddy\"}]}}' '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello from codebuddy\",\"session_id\":\"session-new\"}'\n") + var capturedArgs []string + restore := overrideCodeBuddyCLIForTestWithCapture(t, fakeCodeBuddy, func(args []string) { + capturedArgs = append([]string(nil), args...) + }) + defer restore() + + providerInstance, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + Model: "deepseek-v3", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + nextState, err := providerInstance.(SessionStreamProvider).ChatStreamWithState( + context.Background(), + nil, + ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, + func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }, + ) + if err != nil { + t.Fatalf("expected chat stream with state to succeed, got %v", err) + } + + if string(nextState) != `{"sessionId":"session-new"}` { + t.Fatalf("expected new session state, got %s", string(nextState)) + } + if len(chunks) < 2 || chunks[0].Content != "hello from codebuddy" || !chunks[len(chunks)-1].Done { + t.Fatalf("unexpected stream chunks: %#v", chunks) + } + if !hasArg(capturedArgs, "--enable-session-tracking") { + t.Fatalf("expected session tracking flag, got args %#v", capturedArgs) + } + if hasArg(capturedArgs, "--no-session-persistence") { + t.Fatalf("did not expect no-session-persistence flag, got args %#v", capturedArgs) + } + if hasArg(capturedArgs, "--resume") { + t.Fatalf("did not expect resume flag for first session, got args %#v", capturedArgs) + } + if !hasArgSequence(capturedArgs, "--model", "deepseek-v3") { + t.Fatalf("expected model flag to be preserved, got args %#v", capturedArgs) + } +} + +func TestCodeBuddyCLIProviderChatStreamWithState_ResumesExistingSessionWithoutDroppingState(t *testing.T) { + fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"continued\"}]}}' '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"continued\"}'\n") + var capturedArgs []string + restore := overrideCodeBuddyCLIForTestWithCapture(t, fakeCodeBuddy, func(args []string) { + capturedArgs = append([]string(nil), args...) + }) + defer restore() + + providerInstance, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{ + APIKey: "cb-test", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + nextState, err := providerInstance.(SessionStreamProvider).ChatStreamWithState( + context.Background(), + json.RawMessage(`{"sessionId":"session-existing"}`), + ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping again"}}, + }, + func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }, + ) + if err != nil { + t.Fatalf("expected resumed chat stream to succeed, got %v", err) + } + + if string(nextState) != `{"sessionId":"session-existing"}` { + t.Fatalf("expected existing session state to be preserved, got %s", string(nextState)) + } + if len(chunks) < 2 || chunks[0].Content != "continued" || !chunks[len(chunks)-1].Done { + t.Fatalf("unexpected stream chunks: %#v", chunks) + } + if !hasArgSequence(capturedArgs, "--resume", "session-existing") { + t.Fatalf("expected resume args, got %#v", capturedArgs) + } + if !hasArg(capturedArgs, "--enable-session-tracking") { + t.Fatalf("expected session tracking flag, got args %#v", capturedArgs) + } + if hasArg(capturedArgs, "--no-session-persistence") { + t.Fatalf("did not expect no-session-persistence flag, got args %#v", capturedArgs) + } +} + func writeFakeCodeBuddyScript(t *testing.T, content string) string { t.Helper() dir := t.TempDir() @@ -138,3 +324,54 @@ func overrideCodeBuddyCLIForTest(t *testing.T, fakeCodeBuddyPath string) func() _ = os.Setenv("PATH", originalPath) } } + +func overrideCodeBuddyCLIForTestWithCapture(t *testing.T, fakeCodeBuddyPath string, capture func(args []string)) func() { + t.Helper() + + originalLookPath := codebuddyLookPath + originalCommandContext := codebuddyCommandContext + codebuddyLookPath = func(name string) (string, error) { + if name == "codebuddy" || name == "cbc" { + return fakeCodeBuddyPath, nil + } + return originalLookPath(name) + } + codebuddyCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + if name == "codebuddy" || name == "cbc" { + if capture != nil { + capture(args) + } + return exec.CommandContext(ctx, fakeCodeBuddyPath, args...) + } + return originalCommandContext(ctx, name, args...) + } + + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", filepath.Dir(fakeCodeBuddyPath)+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("failed to override PATH: %v", err) + } + + return func() { + codebuddyLookPath = originalLookPath + codebuddyCommandContext = originalCommandContext + _ = os.Setenv("PATH", originalPath) + } +} + +func hasArg(args []string, target string) bool { + for _, arg := range args { + if arg == target { + return true + } + } + return false +} + +func hasArgSequence(args []string, key string, value string) bool { + for index := 0; index < len(args)-1; index++ { + if args[index] == key && args[index+1] == value { + return true + } + } + return false +} diff --git a/internal/ai/provider/cursor_agent.go b/internal/ai/provider/cursor_agent.go index 7f110bf..f158b82 100644 --- a/internal/ai/provider/cursor_agent.go +++ b/internal/ai/provider/cursor_agent.go @@ -22,13 +22,24 @@ const ( ) // CursorAgentProvider 通过 Cursor Cloud Agents API 发起对话。 -// 当前实现为无状态适配:每次请求都创建一个新的 agent,再消费本次 run 的结果。 +// 支持基于 session state 复用已有 agent,并对 follow-up runs 继续追加上下文。 type CursorAgentProvider struct { config ai.ProviderConfig baseURL string client *http.Client } +type cursorSessionState struct { + AgentID string `json:"agentId,omitempty"` + LastRunID string `json:"lastRunId,omitempty"` +} + +type cursorImageInput struct { + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + // NewCursorAgentProvider 创建 Cursor Agent Provider。 func NewCursorAgentProvider(config ai.ProviderConfig) (Provider, error) { normalized := config @@ -134,7 +145,8 @@ func normalizeCursorAPIPath(path string) string { } type cursorPrompt struct { - Text string `json:"text"` + Text string `json:"text"` + Images []cursorImageInput `json:"images,omitempty"` } type cursorModelSelection struct { @@ -146,6 +158,10 @@ type cursorCreateAgentRequest struct { Model *cursorModelSelection `json:"model,omitempty"` } +type cursorCreateRunRequest struct { + Prompt cursorPrompt `json:"prompt"` +} + type cursorCreateAgentResponse struct { Agent struct { ID string `json:"id"` @@ -181,38 +197,85 @@ type cursorResultEvent struct { } func (p *CursorAgentProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) { + resp, _, err := p.ChatWithState(ctx, nil, req) + return resp, err +} + +func (p *CursorAgentProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) { if err := p.Validate(); err != nil { - return nil, err + return nil, nil, err } - agentID, runID, err := p.createAgent(ctx, req) + sessionState, err := parseCursorSessionState(state) if err != nil { - return nil, err + return nil, nil, err + } + + agentID := strings.TrimSpace(sessionState.AgentID) + runID := "" + if agentID == "" { + agentID, runID, err = p.createAgent(ctx, req) + if err != nil { + return nil, nil, err + } + } else { + runID, err = p.createRun(ctx, agentID, req) + if err != nil { + return nil, nil, err + } } run, err := p.waitForRun(ctx, agentID, runID) if err != nil { - return nil, err + return nil, nil, err + } + + sessionState.AgentID = agentID + sessionState.LastRunID = runID + nextState, err := json.Marshal(sessionState) + if err != nil { + return nil, nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err) } return &ai.ChatResponse{ Content: strings.TrimSpace(run.Result), - }, nil + }, json.RawMessage(nextState), nil } func (p *CursorAgentProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { + _, err := p.ChatStreamWithState(ctx, nil, req, callback) + return err +} + +func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) { if err := p.Validate(); err != nil { - return err + return nil, err } - agentID, runID, err := p.createAgent(ctx, req) + sessionState, err := parseCursorSessionState(state) if err != nil { - return err + return nil, err } + agentID := strings.TrimSpace(sessionState.AgentID) + runID := "" + if agentID == "" { + agentID, runID, err = p.createAgent(ctx, req) + if err != nil { + return nil, err + } + } else { + runID, err = p.createRun(ctx, agentID, req) + if err != nil { + return nil, err + } + } + sessionState.AgentID = agentID + sessionState.LastRunID = runID + stream, err := p.openRunStream(ctx, agentID, runID) if err != nil { - return err + return nil, err } defer stream.Close() @@ -314,10 +377,10 @@ func (p *CursorAgentProvider) ChatStream(ctx context.Context, req ai.ChatRequest currentEventType = "" currentDataLines = nil if dispatchErr != nil { - return dispatchErr + return nil, dispatchErr } if done { - return nil + return marshalCursorSessionState(sessionState) } case strings.HasPrefix(line, "event:"): currentEventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) @@ -327,27 +390,27 @@ func (p *CursorAgentProvider) ChatStream(ctx context.Context, req ai.ChatRequest } if err := scanner.Err(); err != nil { - return fmt.Errorf("读取 Cursor 流式响应失败: %w", err) + return nil, fmt.Errorf("读取 Cursor 流式响应失败: %w", err) } if len(currentDataLines) > 0 || strings.TrimSpace(currentEventType) != "" { done, dispatchErr := dispatchEvent(currentEventType, currentDataLines) if dispatchErr != nil { - return dispatchErr + return nil, dispatchErr } if done { - return nil + return marshalCursorSessionState(sessionState) } } if !completedExplicitly { if !receivedAssistantText && !receivedResultText { callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 Cursor 配置或模型权限", Done: true}) - return nil + return marshalCursorSessionState(sessionState) } callback(ai.StreamChunk{Done: true}) } - return nil + return marshalCursorSessionState(sessionState) } func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatRequest) (string, string, error) { @@ -370,15 +433,13 @@ func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatReques } func buildCursorCreateAgentRequest(req ai.ChatRequest, model string) (cursorCreateAgentRequest, error) { - prompt, err := buildCursorPrompt(req.Messages) + prompt, err := buildCursorPromptInput(req.Messages) if err != nil { return cursorCreateAgentRequest{}, err } requestBody := cursorCreateAgentRequest{ - Prompt: cursorPrompt{ - Text: prompt, - }, + Prompt: prompt, } if trimmedModel := strings.TrimSpace(model); trimmedModel != "" { @@ -389,18 +450,59 @@ func buildCursorCreateAgentRequest(req ai.ChatRequest, model string) (cursorCrea } func buildCursorPrompt(messages []ai.Message) (string, error) { - requestMessages := messages - if requestMessagesContainImages(messages) { - requestMessages = stripImagesFromRequestMessages(messages) + prompt := strings.TrimSpace(buildPrompt(messages)) + if prompt == "" && requestMessagesContainImages(messages) { + return "请结合这些图片继续分析并回答。", nil } - - prompt := strings.TrimSpace(buildPrompt(requestMessages)) if prompt == "" { return "", fmt.Errorf("请求内容不能为空") } return prompt, nil } +func buildCursorPromptInput(messages []ai.Message) (cursorPrompt, error) { + text, err := buildCursorPrompt(messages) + if err != nil { + return cursorPrompt{}, err + } + images, err := buildCursorImageInputs(messages) + if err != nil { + return cursorPrompt{}, err + } + return cursorPrompt{ + Text: text, + Images: images, + }, nil +} + +func buildCursorImageInputs(messages []ai.Message) ([]cursorImageInput, error) { + images := make([]cursorImageInput, 0) + for _, message := range messages { + for _, img := range message.Images { + trimmed := strings.TrimSpace(img) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") { + images = append(images, cursorImageInput{URL: trimmed}) + continue + } + mimeType, rawBase64, err := ParseDataURI(trimmed) + if err != nil { + return nil, fmt.Errorf("解析图片数据失败: %w", err) + } + images = append(images, cursorImageInput{ + Data: rawBase64, + MimeType: mimeType, + }) + } + } + if len(images) > 5 { + return nil, fmt.Errorf("Cursor 最多支持 5 张图片,当前请求包含 %d 张", len(images)) + } + return images, nil +} + func (p *CursorAgentProvider) waitForRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) { ticker := time.NewTicker(cursorRunPollInterval) defer ticker.Stop() @@ -455,6 +557,31 @@ func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID return &responseBody, nil } +func (p *CursorAgentProvider) createRun(ctx context.Context, agentID string, req ai.ChatRequest) (string, error) { + prompt, err := buildCursorPromptInput(req.Messages) + if err != nil { + return "", err + } + + requestBody := cursorCreateRunRequest{ + Prompt: prompt, + } + + var responseBody struct { + Run struct { + ID string `json:"id"` + } `json:"run"` + } + if err := p.doJSONRequest(ctx, http.MethodPost, ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs", agentID)), requestBody, &responseBody, "application/json"); err != nil { + return "", err + } + runID := strings.TrimSpace(responseBody.Run.ID) + if runID == "" { + return "", fmt.Errorf("Cursor 创建 follow-up run 成功,但未返回有效 runId") + } + return runID, nil +} + func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, runID string) (io.ReadCloser, error) { endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s/stream", agentID, runID)) requestLog := logAIUpstreamRequestStart(p.Name(), http.MethodGet, endpoint, nil) @@ -541,6 +668,28 @@ func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, return nil } +func parseCursorSessionState(state json.RawMessage) (cursorSessionState, error) { + if len(state) == 0 { + return cursorSessionState{}, nil + } + var result cursorSessionState + if err := json.Unmarshal(state, &result); err != nil { + return cursorSessionState{}, fmt.Errorf("解析 Cursor 会话状态失败: %w", err) + } + return result, nil +} + +func marshalCursorSessionState(state cursorSessionState) (json.RawMessage, error) { + if strings.TrimSpace(state.AgentID) == "" { + return nil, nil + } + bytes, err := json.Marshal(state) + if err != nil { + return nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err) + } + return json.RawMessage(bytes), nil +} + func isCursorRunTerminalStatus(status string) bool { switch strings.ToUpper(strings.TrimSpace(status)) { case "FINISHED", "ERROR", "CANCELLED", "EXPIRED": diff --git a/internal/ai/provider/cursor_agent_test.go b/internal/ai/provider/cursor_agent_test.go index 10d7ed5..4ed4a21 100644 --- a/internal/ai/provider/cursor_agent_test.go +++ b/internal/ai/provider/cursor_agent_test.go @@ -99,6 +99,85 @@ func TestCursorAgentProviderChat_PollsUntilFinished(t *testing.T) { } } +func TestCursorAgentProviderChatWithState_UsesFollowUpRunsAndPreservesAgent(t *testing.T) { + var ( + createAgentCalls int32 + createRunCalls int32 + receivedPrompt string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + atomic.AddInt32(&createAgentCalls, 1) + t.Fatalf("expected follow-up request to avoid creating a new agent") + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-existing/runs": + atomic.AddInt32(&createRunCalls, 1) + var body struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode follow-up run body: %v", err) + } + receivedPrompt = body.Prompt.Text + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{"id": "run-next"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-existing/runs/run-next": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-next", + "agentId": "bc-existing", + "status": "FINISHED", + "result": "done from follow-up", + "durationMs": 456, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + providerInstance, err := NewCursorAgentProvider(ai.ProviderConfig{ + Name: "Cursor", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resp, nextState, err := providerInstance.(SessionChatProvider).ChatWithState( + context.Background(), + json.RawMessage(`{"agentId":"bc-existing","lastRunId":"run-old"}`), + ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "follow this up"}, + }, + }, + ) + if err != nil { + t.Fatalf("follow-up chat failed: %v", err) + } + + if atomic.LoadInt32(&createAgentCalls) != 0 { + t.Fatalf("expected no create-agent calls, got %d", createAgentCalls) + } + if atomic.LoadInt32(&createRunCalls) != 1 { + t.Fatalf("expected exactly one follow-up run call, got %d", createRunCalls) + } + if !strings.Contains(receivedPrompt, "follow this up") { + t.Fatalf("expected follow-up prompt text, got %q", receivedPrompt) + } + if resp == nil || resp.Content != "done from follow-up" { + t.Fatalf("unexpected response: %#v", resp) + } + if string(nextState) != `{"agentId":"bc-existing","lastRunId":"run-next"}` { + t.Fatalf("unexpected next session state: %s", string(nextState)) + } +} + func TestCursorAgentProviderChatStream_MapsAssistantAndThinkingEvents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -164,3 +243,109 @@ func TestCursorAgentProviderChatStream_MapsAssistantAndThinkingEvents(t *testing t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1]) } } + +func TestCursorAgentProviderChatStreamWithState_UsesFollowUpRunsAndPreservesAgent(t *testing.T) { + var ( + createAgentCalls int32 + createRunCalls int32 + receivedPrompt string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + atomic.AddInt32(&createAgentCalls, 1) + t.Fatalf("expected follow-up request to avoid creating a new agent") + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-existing/runs": + atomic.AddInt32(&createRunCalls, 1) + var body struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode follow-up run body: %v", err) + } + receivedPrompt = body.Prompt.Text + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{"id": "run-next"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-existing/runs/run-next/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: assistant\n")) + _, _ = w.Write([]byte("data: {\"text\":\"done\"}\n\n")) + _, _ = w.Write([]byte("event: done\n")) + _, _ = w.Write([]byte("data: {}\n\n")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + providerInstance, err := NewCursorAgentProvider(ai.ProviderConfig{ + Name: "Cursor", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + sessionState := json.RawMessage(`{"agentId":"bc-existing","lastRunId":"run-old"}`) + var chunks []ai.StreamChunk + nextState, err := providerInstance.(SessionStreamProvider).ChatStreamWithState( + context.Background(), + sessionState, + ai.ChatRequest{ + Messages: []ai.Message{ + {Role: "user", Content: "follow this up"}, + }, + }, + func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }, + ) + if err != nil { + t.Fatalf("follow-up stream failed: %v", err) + } + + if atomic.LoadInt32(&createAgentCalls) != 0 { + t.Fatalf("expected no create-agent calls, got %d", createAgentCalls) + } + if atomic.LoadInt32(&createRunCalls) != 1 { + t.Fatalf("expected exactly one follow-up run call, got %d", createRunCalls) + } + if !strings.Contains(receivedPrompt, "follow this up") { + t.Fatalf("expected follow-up prompt text, got %q", receivedPrompt) + } + if string(nextState) != `{"agentId":"bc-existing","lastRunId":"run-next"}` { + t.Fatalf("unexpected next session state: %s", string(nextState)) + } + if len(chunks) == 0 || chunks[0].Content != "done" { + t.Fatalf("expected streamed assistant content, got %#v", chunks) + } +} + +func TestCursorAgentProviderCreateAgentRequest_IncludesImageInputs(t *testing.T) { + requestBody, err := buildCursorCreateAgentRequest(ai.ChatRequest{ + Messages: []ai.Message{ + { + Role: "user", + Content: "look at this", + Images: []string{"data:image/png;base64,aGVsbG8="}, + }, + }, + }, "composer-latest") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(requestBody.Prompt.Images) != 1 { + t.Fatalf("expected one image payload, got %#v", requestBody.Prompt.Images) + } + if requestBody.Prompt.Images[0].Data != "aGVsbG8=" || requestBody.Prompt.Images[0].MimeType != "image/png" { + t.Fatalf("unexpected image payload: %#v", requestBody.Prompt.Images[0]) + } + if requestBody.Model == nil || requestBody.Model.ID != "composer-latest" { + t.Fatalf("expected model selection to be preserved, got %#v", requestBody.Model) + } +} diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go index 18f7df4..f8f3376 100644 --- a/internal/ai/provider/custom.go +++ b/internal/ai/provider/custom.go @@ -2,6 +2,7 @@ package provider import ( "context" + "encoding/json" "fmt" "strings" @@ -75,3 +76,20 @@ func (p *CustomProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat func (p *CustomProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error { return p.inner.ChatStream(ctx, req, callback) } + +func (p *CustomProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) { + sessionProvider, ok := p.inner.(SessionChatProvider) + if !ok { + resp, err := p.inner.Chat(ctx, req) + return resp, nil, err + } + return sessionProvider.ChatWithState(ctx, state, req) +} + +func (p *CustomProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) { + sessionProvider, ok := p.inner.(SessionStreamProvider) + if !ok { + return nil, p.inner.ChatStream(ctx, req, callback) + } + return sessionProvider.ChatStreamWithState(ctx, state, req, callback) +} diff --git a/internal/ai/provider/provider.go b/internal/ai/provider/provider.go index e9f1d8e..1916c13 100644 --- a/internal/ai/provider/provider.go +++ b/internal/ai/provider/provider.go @@ -2,6 +2,7 @@ package provider import ( "context" + "encoding/json" "GoNavi-Wails/internal/ai" ) @@ -17,3 +18,24 @@ type Provider interface { // Validate 校验配置是否有效 Validate() error } + +// SessionStreamProvider 表示支持按会话复用上游状态的流式 Provider。 +// state 为 Provider 自己维护的持久化状态;返回值为更新后的状态快照。 +type SessionStreamProvider interface { + ChatStreamWithState( + ctx context.Context, + state json.RawMessage, + req ai.ChatRequest, + callback func(ai.StreamChunk), + ) (json.RawMessage, error) +} + +// SessionChatProvider 表示支持按会话复用上游状态的非流式 Provider。 +// state 为 Provider 自己维护的持久化状态;返回值为响应体和更新后的状态快照。 +type SessionChatProvider interface { + ChatWithState( + ctx context.Context, + state json.RawMessage, + req ai.ChatRequest, + ) (*ai.ChatResponse, json.RawMessage, error) +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index d735434..0405a82 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strings" "sync" "time" @@ -42,11 +43,18 @@ type Service struct { secretStore secretstore.SecretStore localizer *i18n.Localizer cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数 + sessionProviders map[string]aiSessionProviderRuntime mcpHTTPMu sync.Mutex mcpHTTP *mcpHTTPServerRuntime mcpHTTPLast ai.MCPHTTPServerStatus } +type aiSessionProviderRuntime struct { + ProviderKey string + State json.RawMessage + Messages []ai.Message +} + var miniMaxAnthropicModels = []string{ "MiniMax-M3", "MiniMax-M2.7", @@ -131,15 +139,16 @@ func NewServiceWithSecretStore(store secretstore.SecretStore) *Service { store = secretstore.NewUnavailableStore("secret store unavailable") } return &Service{ - providers: make([]ai.ProviderConfig, 0), - safetyLevel: ai.PermissionReadOnly, - contextLevel: ai.ContextSchemaOnly, - mcpServers: make([]ai.MCPServerConfig, 0), - skills: make([]ai.SkillConfig, 0), - guard: safety.NewGuard(ai.PermissionReadOnly), - secretStore: store, - localizer: newServiceLocalizer(), - cancelFuncs: make(map[string]context.CancelFunc), + providers: make([]ai.ProviderConfig, 0), + safetyLevel: ai.PermissionReadOnly, + contextLevel: ai.ContextSchemaOnly, + mcpServers: make([]ai.MCPServerConfig, 0), + skills: make([]ai.SkillConfig, 0), + guard: safety.NewGuard(ai.PermissionReadOnly), + secretStore: store, + localizer: newServiceLocalizer(), + cancelFuncs: make(map[string]context.CancelFunc), + sessionProviders: make(map[string]aiSessionProviderRuntime), } } @@ -1150,7 +1159,16 @@ func (s *Service) AISetContextLevel(level string) { // AIChatSend 非流式发送 AI 对话 func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string]interface{} { - p, err := s.getActiveProvider() + return s.aiChatSend("", messages, tools, false) +} + +// AIChatSendInSession 非流式发送 AI 对话,并在支持的 Provider 上复用会话态。 +func (s *Service) AIChatSendInSession(sessionID string, messages []ai.Message, tools []ai.Tool) map[string]interface{} { + return s.aiChatSend(sessionID, messages, tools, true) +} + +func (s *Service) aiChatSend(sessionID string, messages []ai.Message, tools []ai.Tool, allowSessionReuse bool) map[string]interface{} { + p, config, err := s.getActiveProviderRuntime() if err != nil { logger.Error(err, "AIChatSend 获取 Provider 失败:messages=%d tools=%d", len(messages), len(tools)) return map[string]interface{}{"success": false, "error": err.Error()} @@ -1158,14 +1176,62 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string] started := time.Now() providerName := p.Name() - logger.Infof("AIChatSend 开始:provider=%s messages=%d tools=%d", providerName, len(messages), len(tools)) - resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: messages, Tools: tools}) + logger.Infof("AIChatSend 开始:sessionID=%s provider=%s messages=%d tools=%d sessionReuse=%t", sessionID, providerName, len(messages), len(tools), allowSessionReuse) + requestMessages := cloneAIMessages(messages) + var updatedProviderState json.RawMessage + if allowSessionReuse && strings.TrimSpace(sessionID) != "" { + if sessionAwareProvider, ok := p.(provider.SessionChatProvider); ok { + providerKey := providerSessionKey(config) + providerState, deltaMessages := s.resolveSessionProviderRequest(sessionID, providerKey, messages) + requestMessages = deltaMessages + resp, updatedState, err := sessionAwareProvider.ChatWithState(context.Background(), providerState, ai.ChatRequest{Messages: requestMessages, Tools: tools}) + if err != nil { + logger.Warnf("AIChatSend 失败:sessionID=%s provider=%s messages=%d tools=%d duration=%s err=%s", sessionID, providerName, len(messages), len(tools), time.Since(started).Round(time.Millisecond), provider.RedactAIUpstreamLogText(err.Error())) + return map[string]interface{}{"success": false, "error": err.Error()} + } + updatedProviderState = updatedState + historyAfterSend := cloneAIMessages(messages) + if assistantMessage, hasAssistantMessage := buildAssistantMessageFromChatResponse(resp); hasAssistantMessage { + historyAfterSend = append(historyAfterSend, assistantMessage) + } + if persistErr := s.storeSessionProviderRuntime(sessionID, providerKey, updatedProviderState, historyAfterSend); persistErr != nil { + logger.Warnf("AIChatSend 保存会话 Provider 状态失败:sessionID=%s provider=%s err=%s", sessionID, providerName, provider.RedactAIUpstreamLogText(persistErr.Error())) + } + logger.Infof( + "AIChatSend 完成:sessionID=%s provider=%s messages=%d tools=%d toolCalls=%d promptTokens=%d completionTokens=%d totalTokens=%d duration=%s sessionReuse=%t", + sessionID, + providerName, + len(messages), + len(tools), + len(resp.ToolCalls), + resp.TokensUsed.PromptTokens, + resp.TokensUsed.CompletionTokens, + resp.TokensUsed.TotalTokens, + time.Since(started).Round(time.Millisecond), + true, + ) + return map[string]interface{}{ + "success": true, + "content": resp.Content, + "reasoning_content": resp.ReasoningContent, + "tool_calls": resp.ToolCalls, + "tokensUsed": map[string]int{ + "promptTokens": resp.TokensUsed.PromptTokens, + "completionTokens": resp.TokensUsed.CompletionTokens, + "totalTokens": resp.TokensUsed.TotalTokens, + }, + } + } + } + + resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: requestMessages, Tools: tools}) if err != nil { - logger.Warnf("AIChatSend 失败:provider=%s messages=%d tools=%d duration=%s err=%s", providerName, len(messages), len(tools), time.Since(started).Round(time.Millisecond), provider.RedactAIUpstreamLogText(err.Error())) + logger.Warnf("AIChatSend 失败:sessionID=%s provider=%s messages=%d tools=%d duration=%s err=%s", sessionID, providerName, len(messages), len(tools), time.Since(started).Round(time.Millisecond), provider.RedactAIUpstreamLogText(err.Error())) return map[string]interface{}{"success": false, "error": err.Error()} } logger.Infof( - "AIChatSend 完成:provider=%s messages=%d tools=%d toolCalls=%d promptTokens=%d completionTokens=%d totalTokens=%d duration=%s", + "AIChatSend 完成:sessionID=%s provider=%s messages=%d tools=%d toolCalls=%d promptTokens=%d completionTokens=%d totalTokens=%d duration=%s sessionReuse=%t", + sessionID, providerName, len(messages), len(tools), @@ -1174,6 +1240,7 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string] resp.TokensUsed.CompletionTokens, resp.TokensUsed.TotalTokens, time.Since(started).Round(time.Millisecond), + false, ) return map[string]interface{}{ @@ -1204,7 +1271,7 @@ func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools [] cancel() // 确保释放 }() - p, err := s.getActiveProvider() + p, config, err := s.getActiveProviderRuntime() if err != nil { logger.Error(err, "AIChatStream 获取 Provider 失败:sessionID=%s messages=%d tools=%d", sessionID, len(messages), len(tools)) wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ @@ -1220,29 +1287,67 @@ func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools [] thinkingChunks := 0 toolCallChunks := 0 errorChunks := 0 + var assistantContent strings.Builder + var assistantReasoning strings.Builder + var assistantToolCalls []ai.ToolCall + var updatedProviderState json.RawMessage + requestMessages := cloneAIMessages(messages) logger.Infof("AIChatStream 开始:sessionID=%s provider=%s messages=%d tools=%d", sessionID, providerName, len(messages), len(tools)) - err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) { - if chunk.Content != "" { - contentChunks++ - } - if chunk.Thinking != "" || chunk.ReasoningContent != "" { - thinkingChunks++ - } - if len(chunk.ToolCalls) > 0 { - toolCallChunks++ - } - if chunk.Error != "" { - errorChunks++ - } - wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ - "content": chunk.Content, - "thinking": chunk.Thinking, - "reasoning_content": chunk.ReasoningContent, - "tool_calls": chunk.ToolCalls, - "done": chunk.Done, - "error": chunk.Error, + if sessionAwareProvider, ok := p.(provider.SessionStreamProvider); ok { + providerKey := providerSessionKey(config) + providerState, deltaMessages := s.resolveSessionProviderRequest(sessionID, providerKey, messages) + requestMessages = deltaMessages + updatedProviderState, err = sessionAwareProvider.ChatStreamWithState(streamCtx, providerState, ai.ChatRequest{Messages: requestMessages, Tools: tools}, func(chunk ai.StreamChunk) { + if chunk.Content != "" { + contentChunks++ + assistantContent.WriteString(chunk.Content) + } + if chunk.Thinking != "" || chunk.ReasoningContent != "" { + thinkingChunks++ + if chunk.ReasoningContent != "" { + assistantReasoning.WriteString(chunk.ReasoningContent) + } + } + if len(chunk.ToolCalls) > 0 { + toolCallChunks++ + assistantToolCalls = append([]ai.ToolCall(nil), chunk.ToolCalls...) + } + if chunk.Error != "" { + errorChunks++ + } + wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ + "content": chunk.Content, + "thinking": chunk.Thinking, + "reasoning_content": chunk.ReasoningContent, + "tool_calls": chunk.ToolCalls, + "done": chunk.Done, + "error": chunk.Error, + }) }) - }) + } else { + err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) { + if chunk.Content != "" { + contentChunks++ + } + if chunk.Thinking != "" || chunk.ReasoningContent != "" { + thinkingChunks++ + } + if len(chunk.ToolCalls) > 0 { + toolCallChunks++ + } + if chunk.Error != "" { + errorChunks++ + } + wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{ + "content": chunk.Content, + "thinking": chunk.Thinking, + "reasoning_content": chunk.ReasoningContent, + "tool_calls": chunk.ToolCalls, + "done": chunk.Done, + "error": chunk.Error, + }) + }) + } // 当 context 被主动 cancel 的时候,不把这个视为向外抛的 error if err != nil && err != context.Canceled { @@ -1257,6 +1362,16 @@ func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools [] logger.Infof("AIChatStream 已取消:sessionID=%s provider=%s duration=%s", sessionID, providerName, time.Since(started).Round(time.Millisecond)) return } + if _, ok := p.(provider.SessionStreamProvider); ok && errorChunks == 0 { + providerKey := providerSessionKey(config) + historyAfterStream := cloneAIMessages(messages) + if assistantMessage, hasAssistantMessage := buildAssistantMessageFromStreamResult(assistantContent.String(), assistantReasoning.String(), assistantToolCalls); hasAssistantMessage { + historyAfterStream = append(historyAfterStream, assistantMessage) + } + if persistErr := s.storeSessionProviderRuntime(sessionID, providerKey, updatedProviderState, historyAfterStream); persistErr != nil { + logger.Warnf("AIChatStream 保存会话 Provider 状态失败:sessionID=%s provider=%s err=%s", sessionID, providerName, provider.RedactAIUpstreamLogText(persistErr.Error())) + } + } logger.Infof( "AIChatStream 完成:sessionID=%s provider=%s messages=%d tools=%d contentChunks=%d thinkingChunks=%d toolCallChunks=%d errorChunks=%d duration=%s", sessionID, @@ -1292,6 +1407,11 @@ func (s *Service) AICheckSQL(sql string) ai.SafetyResult { // --- 内部方法 --- func (s *Service) getActiveProvider() (provider.Provider, error) { + p, _, err := s.getActiveProviderRuntime() + return p, err +} + +func (s *Service) getActiveProviderRuntime() (provider.Provider, ai.ProviderConfig, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -1301,11 +1421,174 @@ func (s *Service) getActiveProvider() (provider.Provider, error) { for _, cfg := range s.providers { if cfg.ID == s.activeProvider { - return provider.NewProvider(normalizeProviderConfig(cfg)) + normalized := normalizeProviderConfig(cfg) + p, err := provider.NewProvider(normalized) + return p, normalized, err } } - return nil, fmt.Errorf("未配置 AI Provider,请先在设置中配置") + return nil, ai.ProviderConfig{}, fmt.Errorf("未配置 AI Provider,请先在设置中配置") +} + +func providerSessionKey(config ai.ProviderConfig) string { + return strings.Join([]string{ + strings.TrimSpace(config.ID), + strings.ToLower(strings.TrimSpace(config.Type)), + strings.ToLower(strings.TrimSpace(config.APIFormat)), + strings.TrimSpace(config.BaseURL), + strings.TrimSpace(config.Model), + }, "|") +} + +func cloneAIMessages(messages []ai.Message) []ai.Message { + if len(messages) == 0 { + return nil + } + cloned := make([]ai.Message, len(messages)) + for index, message := range messages { + cloned[index] = message + if len(message.Images) > 0 { + cloned[index].Images = append([]string(nil), message.Images...) + } + if len(message.ToolCalls) > 0 { + cloned[index].ToolCalls = append([]ai.ToolCall(nil), message.ToolCalls...) + } + } + return cloned +} + +func buildAssistantMessageFromStreamResult(content string, reasoning string, toolCalls []ai.ToolCall) (ai.Message, bool) { + message := ai.Message{ + Role: "assistant", + Content: content, + ReasoningContent: reasoning, + } + if len(toolCalls) > 0 { + message.ToolCalls = append([]ai.ToolCall(nil), toolCalls...) + } + hasPayload := strings.TrimSpace(message.Content) != "" || strings.TrimSpace(message.ReasoningContent) != "" || len(message.ToolCalls) > 0 + return message, hasPayload +} + +func buildAssistantMessageFromChatResponse(resp *ai.ChatResponse) (ai.Message, bool) { + if resp == nil { + return ai.Message{}, false + } + return buildAssistantMessageFromStreamResult(resp.Content, resp.ReasoningContent, resp.ToolCalls) +} + +func messagesHavePrefix(messages []ai.Message, prefix []ai.Message) bool { + if len(prefix) == 0 { + return true + } + if len(messages) < len(prefix) { + return false + } + for index := range prefix { + if !reflect.DeepEqual(messages[index], prefix[index]) { + return false + } + } + return true +} + +func (s *Service) resolveSessionProviderRequest(sessionID string, providerKey string, messages []ai.Message) (json.RawMessage, []ai.Message) { + runtimeState, ok := s.loadSessionProviderRuntime(sessionID, providerKey) + if !ok || len(runtimeState.State) == 0 || len(runtimeState.Messages) == 0 { + return nil, cloneAIMessages(messages) + } + if !messagesHavePrefix(messages, runtimeState.Messages) { + return nil, cloneAIMessages(messages) + } + deltaMessages := cloneAIMessages(messages[len(runtimeState.Messages):]) + if len(deltaMessages) == 0 { + return nil, cloneAIMessages(messages) + } + return runtimeState.State, deltaMessages +} + +func (s *Service) loadSessionProviderRuntime(sessionID string, providerKey string) (aiSessionProviderRuntime, bool) { + s.mu.RLock() + runtimeState, ok := s.sessionProviders[sessionID] + s.mu.RUnlock() + if ok && runtimeState.ProviderKey == providerKey { + return aiSessionProviderRuntime{ + ProviderKey: runtimeState.ProviderKey, + State: append(json.RawMessage(nil), runtimeState.State...), + Messages: cloneAIMessages(runtimeState.Messages), + }, true + } + + sessionData, err := s.loadSessionFile(sessionID) + if err != nil { + return aiSessionProviderRuntime{}, false + } + if strings.TrimSpace(sessionData.ProviderKey) == "" || sessionData.ProviderKey != providerKey || len(sessionData.ProviderState) == 0 { + return aiSessionProviderRuntime{}, false + } + var providerMessages []ai.Message + if len(sessionData.ProviderMessages) > 0 { + if err := json.Unmarshal(sessionData.ProviderMessages, &providerMessages); err != nil { + return aiSessionProviderRuntime{}, false + } + } + + runtimeState = aiSessionProviderRuntime{ + ProviderKey: sessionData.ProviderKey, + State: append(json.RawMessage(nil), sessionData.ProviderState...), + Messages: providerMessages, + } + s.mu.Lock() + s.sessionProviders[sessionID] = runtimeState + s.mu.Unlock() + return aiSessionProviderRuntime{ + ProviderKey: runtimeState.ProviderKey, + State: append(json.RawMessage(nil), runtimeState.State...), + Messages: cloneAIMessages(runtimeState.Messages), + }, true +} + +func (s *Service) storeSessionProviderRuntime(sessionID string, providerKey string, state json.RawMessage, messages []ai.Message) error { + if strings.TrimSpace(providerKey) == "" { + return nil + } + + runtimeState := aiSessionProviderRuntime{ + ProviderKey: providerKey, + State: append(json.RawMessage(nil), state...), + Messages: cloneAIMessages(messages), + } + s.mu.Lock() + if len(state) == 0 { + delete(s.sessionProviders, sessionID) + } else { + s.sessionProviders[sessionID] = runtimeState + } + s.mu.Unlock() + + sessionData, err := s.loadOrCreateSessionFile(sessionID) + if err != nil { + return err + } + if len(state) == 0 { + sessionData.ProviderKey = "" + sessionData.ProviderState = nil + sessionData.ProviderMessages = nil + return s.saveSessionFile(sessionID, sessionData) + } + + sessionData.ProviderKey = providerKey + sessionData.ProviderState = append(json.RawMessage(nil), state...) + if len(messages) == 0 { + sessionData.ProviderMessages = nil + } else { + messageBytes, err := json.Marshal(messages) + if err != nil { + return fmt.Errorf("序列化会话 Provider 消息失败: %w", err) + } + sessionData.ProviderMessages = json.RawMessage(messageBytes) + } + return s.saveSessionFile(sessionID, sessionData) } // --- 配置持久化 --- @@ -1363,16 +1646,69 @@ func normalizeUserPromptText(value string) string { // sessionFileData 会话文件的 JSON 结构 type sessionFileData struct { - ID string `json:"id"` - Title string `json:"title"` - UpdatedAt int64 `json:"updatedAt"` - Messages json.RawMessage `json:"messages"` // 透传前端格式,后端不解析消息体 + ID string `json:"id"` + Title string `json:"title"` + UpdatedAt int64 `json:"updatedAt"` + Messages json.RawMessage `json:"messages"` // 透传前端格式,后端不解析消息体 + ProviderKey string `json:"providerKey,omitempty"` + ProviderState json.RawMessage `json:"providerState,omitempty"` + ProviderMessages json.RawMessage `json:"providerMessages,omitempty"` } func (s *Service) sessionsDir() string { return filepath.Join(s.configDir, "sessions") } +func (s *Service) sessionFilePath(sessionID string) string { + return filepath.Join(s.sessionsDir(), sessionID+".json") +} + +func (s *Service) loadSessionFile(sessionID string) (sessionFileData, error) { + data, err := os.ReadFile(s.sessionFilePath(sessionID)) + if err != nil { + return sessionFileData{}, err + } + var sessionData sessionFileData + if err := json.Unmarshal(data, &sessionData); err != nil { + return sessionFileData{}, err + } + return sessionData, nil +} + +func (s *Service) loadOrCreateSessionFile(sessionID string) (sessionFileData, error) { + sessionData, err := s.loadSessionFile(sessionID) + if err == nil { + return sessionData, nil + } + if !os.IsNotExist(err) { + return sessionFileData{}, err + } + return sessionFileData{ + ID: sessionID, + Title: "新的对话", + UpdatedAt: time.Now().UnixMilli(), + Messages: json.RawMessage("[]"), + }, nil +} + +func (s *Service) saveSessionFile(sessionID string, sessionData sessionFileData) error { + dir := s.sessionsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建 sessions 目录失败: %w", err) + } + if strings.TrimSpace(sessionData.ID) == "" { + sessionData.ID = sessionID + } + if len(sessionData.Messages) == 0 { + sessionData.Messages = json.RawMessage("[]") + } + data, err := json.Marshal(sessionData) + if err != nil { + return fmt.Errorf("序列化会话数据失败: %w", err) + } + return os.WriteFile(s.sessionFilePath(sessionID), data, 0o644) +} + // AIGetSessions 获取所有会话的元数据列表(不含消息体) func (s *Service) AIGetSessions() []map[string]interface{} { dir := s.sessionsDir() @@ -1417,53 +1753,40 @@ func (s *Service) AIGetSessions() []map[string]interface{} { // AILoadSession 加载指定会话的完整数据(含消息) func (s *Service) AILoadSession(sessionID string) map[string]interface{} { - path := filepath.Join(s.sessionsDir(), sessionID+".json") - data, err := os.ReadFile(path) + sessionData, err := s.loadSessionFile(sessionID) if err != nil { return map[string]interface{}{"success": false, "error": "会话不存在"} } - var sfd sessionFileData - if err := json.Unmarshal(data, &sfd); err != nil { - return map[string]interface{}{"success": false, "error": "会话数据损坏"} - } return map[string]interface{}{ "success": true, - "id": sfd.ID, - "title": sfd.Title, - "updatedAt": sfd.UpdatedAt, - "messages": sfd.Messages, + "id": sessionData.ID, + "title": sessionData.Title, + "updatedAt": sessionData.UpdatedAt, + "messages": sessionData.Messages, } } // AISaveSession 保存会话数据到文件 func (s *Service) AISaveSession(sessionID string, title string, updatedAt float64, messagesJSON string) error { - dir := s.sessionsDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("创建 sessions 目录失败: %w", err) - } - - sfd := sessionFileData{ - ID: sessionID, - Title: title, - UpdatedAt: int64(updatedAt), - Messages: json.RawMessage(messagesJSON), - } - - data, err := json.Marshal(sfd) + sessionData, err := s.loadOrCreateSessionFile(sessionID) if err != nil { - return fmt.Errorf("序列化会话数据失败: %w", err) + return err } - - path := filepath.Join(dir, sessionID+".json") - return os.WriteFile(path, data, 0o644) + sessionData.ID = sessionID + sessionData.Title = title + sessionData.UpdatedAt = int64(updatedAt) + sessionData.Messages = json.RawMessage(messagesJSON) + return s.saveSessionFile(sessionID, sessionData) } // AIDeleteSession 删除会话文件 func (s *Service) AIDeleteSession(sessionID string) error { - path := filepath.Join(s.sessionsDir(), sessionID+".json") - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if err := os.Remove(s.sessionFilePath(sessionID)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("删除会话失败: %w", err) } + s.mu.Lock() + delete(s.sessionProviders, sessionID) + s.mu.Unlock() return nil } diff --git a/internal/ai/service/service_cursor_test.go b/internal/ai/service/service_cursor_test.go index cc2a5fe..374b860 100644 --- a/internal/ai/service/service_cursor_test.go +++ b/internal/ai/service/service_cursor_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "testing" "GoNavi-Wails/internal/ai" @@ -99,3 +100,192 @@ func TestAIListModels_FetchesCursorModelItems(t *testing.T) { t.Fatalf("expected api source, got %#v", result["source"]) } } + +func TestResolveSessionProviderRequest_ReusesStoredStateOnlyForHistoryExtension(t *testing.T) { + service := NewService() + service.sessionProviders["session-1"] = aiSessionProviderRuntime{ + ProviderKey: "cursor-provider", + State: json.RawMessage(`{"agentId":"bc-1"}`), + Messages: []ai.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "world"}, + }, + } + + state, delta := service.resolveSessionProviderRequest("session-1", "cursor-provider", []ai.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "world"}, + {Role: "user", Content: "next"}, + }) + if string(state) != `{"agentId":"bc-1"}` { + t.Fatalf("expected stored provider state, got %s", string(state)) + } + expectedDelta := []ai.Message{{Role: "user", Content: "next"}} + if !reflect.DeepEqual(delta, expectedDelta) { + t.Fatalf("unexpected delta messages: %#v", delta) + } + + state, delta = service.resolveSessionProviderRequest("session-1", "cursor-provider", []ai.Message{ + {Role: "user", Content: "hello changed"}, + }) + if len(state) != 0 { + t.Fatalf("expected mismatched history to reset provider state, got %s", string(state)) + } + if len(delta) != 1 || delta[0].Content != "hello changed" { + t.Fatalf("expected full messages after mismatch, got %#v", delta) + } +} + +func TestAISaveSession_PreservesProviderRuntimeMetadata(t *testing.T) { + service := NewService() + service.configDir = t.TempDir() + + err := service.storeSessionProviderRuntime( + "session-1", + "cursor-provider", + json.RawMessage(`{"agentId":"bc-1","lastRunId":"run-1"}`), + []ai.Message{{Role: "user", Content: "hello"}}, + ) + if err != nil { + t.Fatalf("store provider runtime: %v", err) + } + + err = service.AISaveSession("session-1", "标题", 123, `[{"id":"m1","role":"user","content":"hello","timestamp":1}]`) + if err != nil { + t.Fatalf("save session: %v", err) + } + + sessionData, err := service.loadSessionFile("session-1") + if err != nil { + t.Fatalf("load session file: %v", err) + } + if sessionData.ProviderKey != "cursor-provider" { + t.Fatalf("expected provider key to be preserved, got %q", sessionData.ProviderKey) + } + if string(sessionData.ProviderState) != `{"agentId":"bc-1","lastRunId":"run-1"}` { + t.Fatalf("expected provider state to be preserved, got %s", string(sessionData.ProviderState)) + } + var providerMessages []ai.Message + if err := json.Unmarshal(sessionData.ProviderMessages, &providerMessages); err != nil { + t.Fatalf("unmarshal provider messages: %v", err) + } + if len(providerMessages) != 1 || providerMessages[0].Content != "hello" { + t.Fatalf("unexpected provider messages: %#v", providerMessages) + } +} + +func TestAIChatSendInSession_ReusesCursorProviderStateAndPersistsFollowUpRuns(t *testing.T) { + var ( + createAgentCalls int + createRunCalls int + createRunPrompt string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + createAgentCalls++ + var body struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode create agent body: %v", err) + } + if body.Prompt.Text == "" { + t.Fatalf("expected first prompt text") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{"id": "bc-1"}, + "run": map[string]any{"id": "run-1", "agentId": "bc-1"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-1/runs/run-1": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-1", + "agentId": "bc-1", + "status": "FINISHED", + "result": "first answer", + "durationMs": 100, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-1/runs": + createRunCalls++ + var body struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode follow-up run body: %v", err) + } + createRunPrompt = body.Prompt.Text + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{"id": "run-2"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-1/runs/run-2": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-2", + "agentId": "bc-1", + "status": "FINISHED", + "result": "second answer", + "durationMs": 120, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := NewService() + service.configDir = t.TempDir() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-cursor", + Type: "custom", + APIFormat: "cursor-agent", + BaseURL: server.URL + "/v1", + APIKey: "cursor-key", + }, + } + service.activeProvider = "provider-cursor" + + firstResult := service.AIChatSendInSession("session-1", []ai.Message{ + {Role: "user", Content: "hello"}, + }, nil) + if firstResult["success"] != true { + t.Fatalf("expected first send to succeed, got %#v", firstResult) + } + secondResult := service.AIChatSendInSession("session-1", []ai.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "first answer"}, + {Role: "user", Content: "next"}, + }, nil) + if secondResult["success"] != true { + t.Fatalf("expected second send to succeed, got %#v", secondResult) + } + + if createAgentCalls != 1 { + t.Fatalf("expected exactly one create-agent call, got %d", createAgentCalls) + } + if createRunCalls != 1 { + t.Fatalf("expected exactly one follow-up run call, got %d", createRunCalls) + } + if createRunPrompt != "next" { + t.Fatalf("expected follow-up run to send only delta message, got %q", createRunPrompt) + } + + sessionData, err := service.loadSessionFile("session-1") + if err != nil { + t.Fatalf("load session file: %v", err) + } + if string(sessionData.ProviderState) != `{"agentId":"bc-1","lastRunId":"run-2"}` { + t.Fatalf("unexpected provider state: %s", string(sessionData.ProviderState)) + } + var providerMessages []ai.Message + if err := json.Unmarshal(sessionData.ProviderMessages, &providerMessages); err != nil { + t.Fatalf("unmarshal provider messages: %v", err) + } + if len(providerMessages) != 4 || providerMessages[3].Content != "second answer" { + t.Fatalf("unexpected provider messages: %#v", providerMessages) + } +} From 6b67bb24b4ba1235db3ec52c9c50045bbf5b8878 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 18 Jun 2026 20:28:47 +0800 Subject: [PATCH 16/61] =?UTF-8?q?=E2=9C=A8=20feat(tool-center):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E5=85=B7=E4=B8=AD=E5=BF=83=E5=88=86?= =?UTF-8?q?=E7=BB=84=E4=BA=A4=E4=BA=92=E4=B8=8E=E9=80=9A=E7=94=A8=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动 - 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验 - 为工具中心内嵌入口补充返回上一步交互与底部操作区 - 补充多语言文案和工具中心/Modal/i18n 回归测试 --- frontend/src/App.tool-center.test.ts | 50 + frontend/src/App.tsx | 1005 ++++++++++++++--- frontend/src/components/AISettingsModal.tsx | 3 +- frontend/src/components/ConnectionModal.tsx | 2 +- .../ConnectionPackagePasswordModal.tsx | 29 +- frontend/src/components/DataExportDialog.tsx | 3 +- frontend/src/components/DataGrid.tsx | 3 +- frontend/src/components/DataGridModals.tsx | 3 +- frontend/src/components/DataSyncModal.tsx | 104 +- .../src/components/DriverManagerModal.tsx | 92 +- .../src/components/ExportProgressModal.tsx | 3 +- .../src/components/FindInDatabaseModal.tsx | 3 +- .../src/components/ImportPreviewModal.tsx | 3 +- .../src/components/MessagePublishModal.tsx | 3 +- frontend/src/components/QueryEditor.tsx | 5 +- frontend/src/components/RedisViewer.tsx | 3 +- .../components/SecurityUpdateIntroModal.tsx | 3 +- .../SecurityUpdateProgressModal.tsx | 3 +- .../SecurityUpdateSettingsModal.tsx | 17 +- frontend/src/components/Sidebar.tsx | 5 +- .../src/components/SnippetSettingsModal.tsx | 21 +- frontend/src/components/TabManager.tsx | 3 +- frontend/src/components/TableDesigner.tsx | 3 +- frontend/src/components/TableOverview.tsx | 3 +- .../components/ai/AIContextSelectorModal.tsx | 3 +- .../ai/messageBubble/AIMessageCodeBlock.tsx | 2 +- .../common/ResizableDraggableModal.css | 143 +++ .../common/ResizableDraggableModal.test.ts | 47 + .../common/ResizableDraggableModal.tsx | 426 +++++++ .../components/jvm/JVMChangePreviewModal.tsx | 3 +- frontend/src/i18n/catalog.test.ts | 72 +- shared/i18n/de-DE.json | 7 + shared/i18n/en-US.json | 7 + shared/i18n/ja-JP.json | 7 + shared/i18n/ru-RU.json | 7 + shared/i18n/zh-CN.json | 7 + shared/i18n/zh-TW.json | 7 + 37 files changed, 1805 insertions(+), 305 deletions(-) create mode 100644 frontend/src/components/common/ResizableDraggableModal.css create mode 100644 frontend/src/components/common/ResizableDraggableModal.test.ts create mode 100644 frontend/src/components/common/ResizableDraggableModal.tsx diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 78137f1..493cba0 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -42,6 +42,56 @@ describe('tool center menu entries', () => { expect(shortcutIndex).toBeGreaterThan(snippetIndex); }); + it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => { + expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';"); + expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState('config');"); + expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState(null);"); + expect(appSource).toContain("title: t('app.tools.group.config.title')"); + expect(appSource).toContain("title: t('app.tools.group.workflow.title')"); + expect(appSource).toContain("title: t('app.tools.group.workspace.title')"); + expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)"); + expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo(() => ({"); + expect(appSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'"); + expect(appSource).toContain("const toolCenterNavPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterNavScrollStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterContentPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterDetailPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterDetailBodyStyle = useMemo(() => ({"); + expect(appSource).toContain('role="tablist" aria-orientation="vertical"'); + expect(appSource).toContain('role="tab"'); + expect(appSource).toContain('aria-selected={active}'); + expect(appSource).toContain('title={`${group.title} - ${group.description}`}'); + expect(appSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`"); + expect(appSource).toContain('setActiveToolCenterPane(null);'); + expect(appSource).toContain('group.items.length'); + expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {"); + expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState(null);"); + expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {"); + expect(appSource).toContain("t('common.back_to_previous')"); + expect(appSource).toContain("width={1080}"); + expect(appSource).toContain('centered'); + }); + + it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => { + expect(appSource).toContain('const toolCenterModalContentStyle = useMemo(() => ({'); + expect(appSource).toContain("height: 'min(820px, calc(100vh - 64px))'"); + expect(appSource).toContain("const toolCenterModalWorkspaceStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterScrollableListStyle = useMemo(() => ({"); + expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }"); + expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}'); + expect(appSource).toContain('style={toolCenterModalSplitStyle}'); + expect(appSource).toContain('style={toolCenterNavPanelStyle}'); + expect(appSource).toContain('style={toolCenterNavScrollStyle}'); + expect(appSource).toContain('style={toolCenterContentPanelStyle}'); + expect(appSource).toContain('style={toolCenterDetailPanelStyle}'); + expect(appSource).toContain('style={toolCenterDetailBodyStyle}'); + expect(appSource).toContain('style={toolCenterScrollableListStyle}'); + expect(appSource).toContain("overflowY: 'auto'"); + expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'"); + expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`"); + }); + it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => { expect(appSource).toContain('onToggleAI={toggleAIPanel}'); expect(appSource).toContain('renderLegacyAIEdgeHandle'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae04004..f5b9cc5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined } from '@ant-design/icons'; +import Modal from './components/common/ResizableDraggableModal'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -186,6 +187,22 @@ const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnec }; type ConnectionPackageDialogMode = 'import' | 'export'; +type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace'; +type ToolCenterPaneKey = + | 'connection-package' + | 'data-root' + | 'security-update' + | 'schema-compare' + | 'data-compare' + | 'sync' + | 'drivers' + | 'snippet-settings' + | 'shortcut-settings'; + +type ToolCenterPaneState = { + key: ToolCenterPaneKey; + group: ToolCenterGroupKey; +}; type ConnectionPackageDialogState = { open: boolean; @@ -1279,13 +1296,83 @@ function App() { border: overlayTheme.sectionBorder, background: overlayTheme.sectionBg, }), [overlayTheme]); + const toolCenterModalContentStyle = useMemo(() => ({ + ...utilityModalShellStyle, + height: 'min(820px, calc(100vh - 64px))', + display: 'flex', + flexDirection: 'column', + }), [utilityModalShellStyle]); + const toolCenterModalWorkspaceStyle = useMemo(() => ({ + display: 'flex', + flexDirection: 'column', + padding: '10px 0 2px', + height: '100%', + minHeight: 0, + }), []); + const toolCenterModalSplitStyle = useMemo(() => ({ + display: 'grid', + gridTemplateColumns: '232px minmax(0, 1fr)', + gap: 18, + flex: 1, + minHeight: 0, + }), []); + const toolCenterNavPanelStyle = useMemo(() => ({ + padding: '4px 12px 4px 0', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + borderRight: `1px solid ${overlayTheme.divider}`, + }), [overlayTheme.divider]); + const toolCenterNavScrollStyle = useMemo(() => ({ + display: 'grid', + alignContent: 'start', + gap: 4, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 8, + }), []); + const toolCenterContentPanelStyle = useMemo(() => ({ + display: 'flex', + flexDirection: 'column', + gap: 12, + minHeight: 0, + overflow: 'hidden', + }), []); + const toolCenterDetailPanelStyle = useMemo(() => ({ + ...utilityPanelStyle, + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }), [utilityPanelStyle]); + const toolCenterDetailBodyStyle = useMemo(() => ({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 6, + overscrollBehavior: 'contain', + }), []); + const toolCenterScrollableListStyle = useMemo(() => ({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + overscrollBehavior: 'contain', + }), []); const utilityMutedTextStyle = useMemo(() => ({ color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, }), [overlayTheme]); - const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => ( -
+ const renderUtilityModalTitle = ( + icon: React.ReactNode, + title: string, + description: string, + ) => ( +
{icon}
@@ -1317,6 +1404,27 @@ function App() { fontWeight: 400, marginTop: 2, }), [overlayTheme]); + const toolCenterRowStyle = useMemo(() => ({ + width: '100%', + minHeight: 82, + borderRadius: 0, + color: overlayTheme.titleText, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingInline: 8, + boxShadow: 'none', + fontSize: 15, + fontWeight: 600, + }), [overlayTheme]); + const toolCenterRowDescriptionStyle = useMemo(() => ({ + ...utilityActionHintStyle, + marginTop: 4, + textAlign: 'left' as const, + whiteSpace: 'normal' as const, + lineHeight: 1.55, + }), [utilityActionHintStyle]); const sidebarHorizontalPadding = isSidebarCompact ? 8 : 10; @@ -2110,6 +2218,8 @@ function App() { const closeConnectionPackageDialog = useCallback(() => { setConnectionPackageDialog(createClosedConnectionPackageDialogState()); setPendingConnectionImportPayload(null); + setToolCenterBackGroupKey(null); + setActiveToolCenterPane((current) => (current?.key === 'connection-package' ? null : current)); }, []); const refreshConnectionsAfterImport = useCallback(async (importedViews: SavedConnection[]) => { @@ -2164,7 +2274,8 @@ function App() { return importedViews as SavedConnection[]; }, [refreshConnectionsAfterImport, t]); - const handleImportConnections = async () => { + const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => { + setToolCenterBackGroupKey(sourceGroup ?? null); const res = await (window as any).go.app.App.ImportConfigFile(); if (!res.success) { if (res.message !== "已取消") { @@ -2191,6 +2302,10 @@ function App() { } } catch (e: any) { if (isConnectionPackagePasswordRequiredError(e)) { + if (sourceGroup) { + setToolCenterBackGroupKey(sourceGroup); + setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup }); + } setPendingConnectionImportPayload(raw); setConnectionPackageDialog({ open: true, @@ -2207,12 +2322,16 @@ function App() { } }; - const handleExportConnections = async () => { + const handleExportConnections = async (sourceGroup?: ToolCenterGroupKey) => { if (connections.length === 0) { void message.warning(t('app.connection_package.message.no_connections_to_export')); return; } + setToolCenterBackGroupKey(sourceGroup ?? null); + if (sourceGroup) { + setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup }); + } setConnectionPackageDialog({ open: true, mode: 'export', @@ -2317,6 +2436,9 @@ function App() { }; const [isToolsModalOpen, setIsToolsModalOpen] = useState(false); + const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState('config'); + const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState(null); + const [activeToolCenterPane, setActiveToolCenterPane] = useState(null); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); @@ -2436,30 +2558,46 @@ function App() { effectiveUiScale, }) ), [aiPanelVisible, darkMode, effectiveUiScale]); + const handleOpenToolsModal = useCallback((group: ToolCenterGroupKey = 'config') => { + setToolCenterBackGroupKey(null); + setActiveToolCenterPane(null); + setActiveToolCenterGroupKey(group); + setIsToolsModalOpen(true); + }, []); + const handleOpenSettingsModal = useCallback(() => { + setIsSettingsModalOpen(true); + }, []); + const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => { + setToolCenterBackGroupKey(group); + setActiveToolCenterGroupKey(group); + setActiveToolCenterPane({ key, group }); + }, []); + const handleReturnToToolCenter = useCallback((closeChild?: () => void) => { + const returnGroup = toolCenterBackGroupKey ?? 'config'; + closeChild?.(); + setToolCenterBackGroupKey(null); + setActiveToolCenterGroupKey(returnGroup); + setActiveToolCenterPane(null); + setIsToolsModalOpen(true); + }, [toolCenterBackGroupKey]); const sidebarUtilityItems = useMemo(() => { const itemMap = { tools: { key: 'tools', title: t('app.sidebar.tools'), icon: , - onClick: () => setIsToolsModalOpen(true), + onClick: () => handleOpenToolsModal(), }, settings: { key: 'settings', title: t('app.sidebar.settings'), icon: , - onClick: () => setIsSettingsModalOpen(true), + onClick: () => handleOpenSettingsModal(), }, } as const; return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]); - }, [t]); - const handleOpenToolsModal = useCallback(() => { - setIsToolsModalOpen(true); - }, []); - const handleOpenSettingsModal = useCallback(() => { - setIsSettingsModalOpen(true); - }, []); + }, [handleOpenSettingsModal, handleOpenToolsModal, t]); const handleFocusSidebarSearch = useCallback(() => { window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search')); }, []); @@ -2496,11 +2634,11 @@ function App() { }, [t]); useEffect(() => { - if (!isDataRootModalOpen) { + if (!isDataRootModalOpen && activeToolCenterPane?.key !== 'data-root') { return; } void loadDataRootInfo(); - }, [isDataRootModalOpen, loadDataRootInfo]); + }, [activeToolCenterPane?.key, isDataRootModalOpen, loadDataRootInfo]); const handleSelectDataRoot = useCallback(async () => { try { @@ -2753,12 +2891,14 @@ function App() { const handleOpenDriverManagerFromConnection = () => { setIsModalOpen(false); setEditingConnection(null); + setToolCenterBackGroupKey(null); setIsDriverModalOpen(true); }; const handleCloseDriverManager = useCallback(() => { const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource); setIsDriverModalOpen(false); + setToolCenterBackGroupKey(null); setSecurityUpdateRepairSource(null); if (reopenSecurityUpdateDetails) { setIsSecurityUpdateSettingsOpen(true); @@ -3809,136 +3949,654 @@ function App() { onSaved={handleConnectionSaved} /> )} - {isToolsModalOpen && ( - , t('app.tools.title'), t('app.tools.description'))} - open={isToolsModalOpen} - onCancel={() => setIsToolsModalOpen(false)} - footer={null} - width={560} - styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} - > -
- {[ - { - key: 'import', - icon: , - title: t('app.tools.entry.import.title'), - description: t('app.tools.entry.import.description'), - onClick: () => { - setIsToolsModalOpen(false); - void handleImportConnections(); + {isToolsModalOpen && (() => { + const toolCenterGroups = [ + { + key: 'config', + icon: , + title: t('app.tools.group.config.title'), + description: t('app.tools.group.config.description'), + items: [ + { + key: 'import', + icon: , + title: t('app.tools.entry.import.title'), + description: t('app.tools.entry.import.description'), + onClick: () => { + void handleImportConnections('config'); + }, }, - }, - { - key: 'export', - icon: , - title: t('app.tools.entry.export.title'), - description: t('app.tools.entry.export.description'), - onClick: () => { - setIsToolsModalOpen(false); - void handleExportConnections(); + { + key: 'export', + icon: , + title: t('app.tools.entry.export.title'), + description: t('app.tools.entry.export.description'), + onClick: () => { + void handleExportConnections('config'); + }, }, - }, - { - key: 'schema-compare', - icon: , - title: t('app.tools.entry.schema_compare.title'), - description: t('app.tools.entry.schema_compare.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('schemaCompare'); - setIsSyncModalOpen(true); + { + key: 'data-root', + icon: , + title: t('app.tools.entry.data_root.title'), + description: t('app.tools.entry.data_root.description'), + onClick: () => { + handleOpenToolCenterPane('config', 'data-root'); + }, }, - }, - { - key: 'data-compare', - icon: , - title: t('app.tools.entry.data_compare.title'), - description: t('app.tools.entry.data_compare.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('dataCompare'); - setIsSyncModalOpen(true); + { + key: 'security-update', + icon: , + title: t('app.tools.entry.security_update.title'), + description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems + ? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label }) + : t('app.tools.entry.security_update.description'), + onClick: () => { + handleOpenToolCenterPane('config', 'security-update'); + }, }, - }, - { - key: 'sync', - icon: , - title: t('app.tools.entry.sync.title'), - description: t('app.tools.entry.sync.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('sync'); - setIsSyncModalOpen(true); + ], + }, + { + key: 'workflow', + icon: , + title: t('app.tools.group.workflow.title'), + description: t('app.tools.group.workflow.description'), + items: [ + { + key: 'schema-compare', + icon: , + title: t('app.tools.entry.schema_compare.title'), + description: t('app.tools.entry.schema_compare.description'), + onClick: () => { + setSyncModalEntryMode('schemaCompare'); + handleOpenToolCenterPane('workflow', 'schema-compare'); + }, }, - }, - { - key: 'drivers', - icon: , - title: t('app.tools.entry.drivers.title'), - description: t('app.tools.entry.drivers.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsDriverModalOpen(true); + { + key: 'data-compare', + icon: , + title: t('app.tools.entry.data_compare.title'), + description: t('app.tools.entry.data_compare.description'), + onClick: () => { + setSyncModalEntryMode('dataCompare'); + handleOpenToolCenterPane('workflow', 'data-compare'); + }, }, - }, - { - key: 'data-root', - icon: , - title: t('app.tools.entry.data_root.title'), - description: t('app.tools.entry.data_root.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsDataRootModalOpen(true); + { + key: 'sync', + icon: , + title: t('app.tools.entry.sync.title'), + description: t('app.tools.entry.sync.description'), + onClick: () => { + setSyncModalEntryMode('sync'); + handleOpenToolCenterPane('workflow', 'sync'); + }, }, - }, - { - key: 'snippet-settings', - icon: , - title: t('app.tools.entry.snippets.title'), - description: t('app.tools.entry.snippets.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsSnippetModalOpen(true); + ], + }, + { + key: 'workspace', + icon: , + title: t('app.tools.group.workspace.title'), + description: t('app.tools.group.workspace.description'), + items: [ + { + key: 'drivers', + icon: , + title: t('app.tools.entry.drivers.title'), + description: t('app.tools.entry.drivers.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'drivers'); + }, }, - }, - { - key: 'shortcut-settings', - icon: , - title: t('app.tools.entry.shortcuts.title'), - description: t('app.tools.entry.shortcuts.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsShortcutModalOpen(true); + { + key: 'snippet-settings', + icon: , + title: t('app.tools.entry.snippets.title'), + description: t('app.tools.entry.snippets.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'snippet-settings'); + }, }, - }, - { - key: 'security-update', - icon: , - title: t('app.tools.entry.security_update.title'), - description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems - ? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label }) - : t('app.tools.entry.security_update.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsSecurityUpdateSettingsOpen(true); + { + key: 'shortcut-settings', + icon: , + title: t('app.tools.entry.shortcuts.title'), + description: t('app.tools.entry.shortcuts.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'shortcut-settings'); + }, }, - }, - ].map((item) => ( - - ))} -
-
- )} + ], + }, + ] as const; + const activeToolCenterGroup = toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey) ?? toolCenterGroups[0]; + const activeToolCenterPaneItem = activeToolCenterPane + ? toolCenterGroups + .find((group) => group.key === activeToolCenterPane.group) + ?.items.find((item) => item.key === activeToolCenterPane.key) + : null; + const closeToolCenterPane = () => { + if (activeToolCenterPane?.key === 'connection-package') { + closeConnectionPackageDialog(); + return; + } + setToolCenterBackGroupKey(null); + setActiveToolCenterPane(null); + }; + const renderToolCenterPane = () => { + if (!activeToolCenterPane) { + return null; + } + + if (activeToolCenterPane.key === 'connection-package') { + return ( + { + setConnectionPackageDialog((current) => ({ + ...current, + includeSecrets: value, + useFilePassword: value ? current.useFilePassword : false, + password: value ? current.password : '', + error: '', + })); + }} + onUseFilePasswordChange={(value) => { + setConnectionPackageDialog((current) => ({ + ...current, + useFilePassword: value, + password: value ? current.password : '', + error: '', + })); + }} + onPasswordChange={(value) => { + setConnectionPackageDialog((current) => ({ + ...current, + password: value, + error: '', + })); + }} + onConfirm={() => { + void handleConfirmConnectionPackageDialog(); + }} + onCancel={closeConnectionPackageDialog} + /> + ); + } + + if (activeToolCenterPane.key === 'data-root') { + return ( + , + t('app.data_root.title'), + t('app.data_root.description'), + )} + onCancel={closeToolCenterPane} + footer={[ + , + , + ]} + styles={{ + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > + {dataRootLoading ? ( +
+ +
+ ) : ( +
+
+
{t('app.data_root.current_directory')}
+
+ +
+
+
{t('app.data_root.default_directory')}
+
{dataRootInfo?.defaultPath || '-'}
+
+
+
{t('app.data_root.driver_directory')}
+
{dataRootInfo?.driverPath || '-'}
+
+
+
+
+
+
{t('app.data_root.switch_target')}
+
+ +
+ + + +
+
+
+
+
{t('app.data_root.apply_method')}
+
+ + +
+
+ {t('app.data_root.restart_hint')} +
+
+
+ )} +
+ ); + } + + if (activeToolCenterPane.key === 'security-update') { + return ( + + ); + } + + if ( + activeToolCenterPane.key === 'schema-compare' + || activeToolCenterPane.key === 'data-compare' + || activeToolCenterPane.key === 'sync' + ) { + return ( + + ); + } + + if (activeToolCenterPane.key === 'drivers') { + return ( + + ); + } + + if (activeToolCenterPane.key === 'snippet-settings') { + return ( + + ); + } + + if (activeToolCenterPane.key === 'shortcut-settings') { + return ( + , + t('app.shortcuts.title'), + t('app.shortcuts.description'), + )} + onCancel={() => { + setCapturingShortcutAction(null); + closeToolCenterPane(); + }} + footer={[ + , + , + , + ]} + styles={{ + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > +
+
+
+ {t('app.shortcuts.capture_hint')} +
+
+ {SHORTCUT_ACTION_ORDER.map((action) => { + const meta = SHORTCUT_ACTION_META[action]; + if (meta.platformOnly === 'mac' && !isMacRuntime) { + return null; + } + const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform); + const isCapturing = capturingShortcutAction === action; + const conflicts = shortcutConflictMap[action]; + const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null; + return ( +
+
+
{meta.label}
+
{meta.description}
+ {conflictInfo && ( +
+ {conflictInfo.hasMonaco && ( + <>⚠ {t('app.shortcuts.message.reserved_conflict_info', { labels: conflictInfo.monacoLabels })} + )} + {conflictInfo.hasOther && ( + <>⚠ {t('app.shortcuts.message.reserved_conflict_warning', { contexts: conflictInfo.otherContexts, labels: conflictInfo.otherLabels })} + )} +
+ )} +
+
+ + + updateShortcut(action, { enabled: checked }, activeShortcutPlatform)} + /> +
+
+ ); + })} +
+
+ ); + } + + return null; + }; + + return ( + , t('app.tools.title'), t('app.tools.description'))} + open={isToolsModalOpen} + onCancel={() => { + if (activeToolCenterPane?.key === 'connection-package') { + closeConnectionPackageDialog(); + } + setActiveToolCenterPane(null); + setToolCenterBackGroupKey(null); + setIsToolsModalOpen(false); + }} + footer={null} + centered + width={1080} + styles={{ + content: toolCenterModalContentStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > +
+
+
+
+ {toolCenterGroups.map((group) => { + const active = group.key === activeToolCenterGroup.key; + return ( + + ); + })} +
+
+
+ {activeToolCenterPane ? ( +
+
+
+
+ {activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title} +
+
+ {activeToolCenterPaneItem?.description ?? activeToolCenterGroup.description} +
+
+ +
+
+ {renderToolCenterPane()} +
+
+ ) : ( + <> +
+
{activeToolCenterGroup.title}
+
{activeToolCenterGroup.description}
+
+
+ {activeToolCenterGroup.items.map((item, index) => ( + + ))} +
+ + )} +
+
+
+
+ ); + })()} {isSettingsModalOpen && ( , t('app.settings.title'), t('app.settings.description'))} @@ -4030,10 +4688,35 @@ function App() { )} {isDataRootModalOpen && ( , t('app.data_root.title'), t('app.data_root.description'))} + title={renderUtilityModalTitle( + , + t('app.data_root.title'), + t('app.data_root.description'), + )} open={isDataRootModalOpen} - onCancel={() => setIsDataRootModalOpen(false)} - footer={null} + onCancel={() => { + setIsDataRootModalOpen(false); + setToolCenterBackGroupKey(null); + }} + footer={[ + , + toolCenterBackGroupKey === 'config' ? ( + + ) : null, + ]} width={720} styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} > @@ -4101,7 +4784,11 @@ function App() { {isSyncModalOpen && ( setIsSyncModalOpen(false)} + onClose={() => { + setIsSyncModalOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'workflow' ? () => handleReturnToToolCenter(() => setIsSyncModalOpen(false)) : undefined} entryMode={syncModalEntryMode} /> )} @@ -4109,6 +4796,7 @@ function App() { handleReturnToToolCenter(() => setIsDriverModalOpen(false)) : undefined} onOpenGlobalProxySettings={handleOpenGlobalProxySettings} /> )} @@ -4130,7 +4818,11 @@ function App() { status={securityUpdateStatus} focusTarget={securityUpdateSettingsFocusTarget} focusRequest={securityUpdateSettingsFocusRequest} - onClose={() => setIsSecurityUpdateSettingsOpen(false)} + onClose={() => { + setIsSecurityUpdateSettingsOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'config' ? () => handleReturnToToolCenter(() => setIsSecurityUpdateSettingsOpen(false)) : undefined} onStart={handleStartSecurityUpdate} onRetry={handleRetrySecurityUpdate} onRestart={handleRestartSecurityUpdate} @@ -4152,7 +4844,7 @@ function App() { /> )} handleReturnToToolCenter(closeConnectionPackageDialog) : undefined} onIncludeSecretsChange={(value) => { setConnectionPackageDialog((current) => ({ ...current, @@ -4975,11 +5668,16 @@ function App() { {isShortcutModalOpen && ( , t('app.shortcuts.title'), t('app.shortcuts.description'))} + title={renderUtilityModalTitle( + , + t('app.shortcuts.title'), + t('app.shortcuts.description'), + )} open={isShortcutModalOpen} onCancel={() => { setIsShortcutModalOpen(false); setCapturingShortcutAction(null); + setToolCenterBackGroupKey(null); }} width={760} centered @@ -5016,6 +5714,17 @@ function App() { > {t('common.close')} , + toolCenterBackGroupKey === 'workspace' ? ( + + ) : null, ]} >
@@ -5085,7 +5794,11 @@ function App() { {isSnippetModalOpen && ( setIsSnippetModalOpen(false)} + onClose={() => { + setIsSnippetModalOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'workspace' ? () => handleReturnToToolCenter(() => setIsSnippetModalOpen(false)) : undefined} darkMode={darkMode} overlayTheme={overlayTheme} /> diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 7275f1e..34fb060 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Modal, Form, message as antdMessage } from 'antd'; +import { Form, message as antdMessage } from 'antd'; import { RobotOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types'; import { diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ce38764..b03e642 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; +import Modal from './common/ResizableDraggableModal'; import { - Modal, Form, Input, InputNumber, diff --git a/frontend/src/components/ConnectionPackagePasswordModal.tsx b/frontend/src/components/ConnectionPackagePasswordModal.tsx index 9f0cf96..6878f20 100644 --- a/frontend/src/components/ConnectionPackagePasswordModal.tsx +++ b/frontend/src/components/ConnectionPackagePasswordModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Checkbox, Input, Modal, Typography } from 'antd'; +import { Button, Checkbox, Input, Typography } from 'antd'; import { useI18n } from '../i18n/provider'; const { Text } = Typography; @@ -17,6 +18,8 @@ export interface ConnectionPackagePasswordModalProps { confirmLoading?: boolean; confirmText?: string; cancelText?: string; + onBack?: () => void; + embedded?: boolean; onIncludeSecretsChange?: (value: boolean) => void; onUseFilePasswordChange?: (value: boolean) => void; onPasswordChange: (value: string) => void; @@ -35,6 +38,8 @@ export default function ConnectionPackagePasswordModal({ confirmLoading, confirmText, cancelText, + onBack, + embedded = false, onIncludeSecretsChange, onUseFilePasswordChange, onPasswordChange, @@ -58,14 +63,26 @@ export default function ConnectionPackagePasswordModal({ return ( {title} + )} onCancel={onCancel} destroyOnHidden={false} maskClosable={false} + footer={[ + , + , + onBack ? ( + + ) : null, + ]} > {isExportMode ? (
diff --git a/frontend/src/components/DataExportDialog.tsx b/frontend/src/components/DataExportDialog.tsx index 6743172..1e5995f 100644 --- a/frontend/src/components/DataExportDialog.tsx +++ b/frontend/src/components/DataExportDialog.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useMemo, useState } from 'react'; -import { Form, InputNumber, Modal, Select, message } from 'antd'; +import { Form, InputNumber, Select, message } from 'antd'; import { ExportOutlined } from '@ant-design/icons'; export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html'; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 4ccd740..d038b7c 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,7 +1,8 @@ +import Modal from './common/ResizableDraggableModal'; // cspell:ignore anticon sqls uuidv uuidv4 hscroll import React, { useState, useEffect, useRef, useContext, useMemo, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; import type { Reference as TableReference } from 'rc-table'; diff --git a/frontend/src/components/DataGridModals.tsx b/frontend/src/components/DataGridModals.tsx index 5ce20ff..1dfb5f1 100644 --- a/frontend/src/components/DataGridModals.tsx +++ b/frontend/src/components/DataGridModals.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd'; +import { Button, Checkbox, DatePicker, Form, Input, TimePicker } from 'antd'; import dayjs from 'dayjs'; import { CopyOutlined } from '@ant-design/icons'; import Editor from './MonacoEditor'; diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index ec4f82b..89855eb 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd'; +import { Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd'; import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App'; @@ -191,7 +192,7 @@ const buildSqlPreview = ( }; }; -const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => { +const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; onBack?: () => void; entryMode?: DataSyncEntryMode; embedded?: boolean }> = ({ open, onClose, onBack, entryMode = 'sync', embedded = false }) => { const i18n = useOptionalI18n(); const i18nLanguage = i18n?.language; const tr = (key: string, params?: Parameters[1]) => t(key, params, i18nLanguage); @@ -829,7 +830,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: }), [darkMode]); const renderModalTitle = (title: string, description: string) => ( -
+
void; entryMode?:
); - return ( - <> - { - if (syncing) { - message.warning(tr('data_sync.message.close_blocked_running')); - return; - } - onClose(); - }} - width={920} - footer={null} - destroyOnHidden - closable={!syncing} - maskClosable={!syncing} - styles={{ - content: modalPanelStyle, - header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, - body: { - paddingTop: 8, - height: 760, - maxHeight: 'calc(100vh - 120px)', - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - }, - footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 }, - }} - > + const handleReturnToPrevious = () => { + if (syncing) { + message.warning(tr('data_sync.message.close_blocked_running')); + return; + } + onBack?.(); + }; + + const dataSyncContent = (
@@ -1403,9 +1375,57 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: )} + {onBack ? ( + + ) : null}
- + ); + + return ( + <> + {embedded ? dataSyncContent : ( + { + if (syncing) { + message.warning(tr('data_sync.message.close_blocked_running')); + return; + } + onClose(); + }} + width={920} + footer={null} + destroyOnHidden + closable={!syncing} + maskClosable={!syncing} + styles={{ + content: modalPanelStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, + body: { + paddingTop: 8, + height: 760, + maxHeight: 'calc(100vh - 120px)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 }, + }} + > + {dataSyncContent} + + )} { return grouped; }; -const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({ +const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onBack?: () => void; onOpenGlobalProxySettings?: () => void; embedded?: boolean }> = ({ open, onClose, + onBack, onOpenGlobalProxySettings, + embedded = false, }) => { const theme = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); @@ -1818,32 +1821,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const logBlockBorderColor = darkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.12)'; const logBlockTextColor = darkMode ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)'; - return ( - - - - - - )} - > + const driverManagerContent = ( + <>
@@ -2072,6 +2051,24 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
+ {embedded ? ( + + + + + {onBack ? ( + + ) : null} + + ) : null} void; onOpenG )} + + ); + + return embedded ? driverManagerContent : ( + + {t('driver.modal.title')} +
+ )} + open={open} + onCancel={onClose} + width={1120} + style={{ top: 24 }} + className="driver-manager-modal" + styles={{ + body: modalBodyStyle, + }} + destroyOnHidden + footer={( + + + + + {onBack ? ( + + ) : null} + + )} + > + {driverManagerContent}
); }; diff --git a/frontend/src/components/ExportProgressModal.tsx b/frontend/src/components/ExportProgressModal.tsx index 194bd3a..c6e1f6c 100644 --- a/frontend/src/components/ExportProgressModal.tsx +++ b/frontend/src/components/ExportProgressModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Button, Modal, Typography } from 'antd'; +import { Button, Typography } from 'antd'; import { formatExportProgressRows, } from '../utils/exportProgress'; diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx index d3e19e2..f8fe330 100644 --- a/frontend/src/components/FindInDatabaseModal.tsx +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useRef, useCallback, useMemo } from 'react'; -import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd'; +import { Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd'; import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons'; import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App'; import { quoteIdentPart, escapeLiteral } from '../utils/sql'; diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index 340bfec..cacd5fc 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect } from "react"; -import { Modal, Table, Alert, Progress, Button, Space } from "antd"; +import { Table, Alert, Progress, Button, Space } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; import { PreviewImportFile, diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx index a784217..dc324ab 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Checkbox, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; +import { Alert, Checkbox, Form, Input, Select, Space, Typography, message } from 'antd'; import { DBQuery } from '../../wailsjs/go/app/App'; import type { SavedConnection } from '../types'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 76bf89b..f507ba2 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import Modal from './common/ResizableDraggableModal'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; -import { message, Modal, Input, Form, MenuProps } from 'antd'; +import { message, Input, Form, MenuProps } from 'antd'; import { format, type SqlLanguage } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, IndexDefinition } from '../types'; diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 6f823f8..24e42c0 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1,6 +1,7 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; +import { Table, Input, Button, Space, Tag, Tree, Spin, message, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; import type { RadioChangeEvent } from 'antd'; import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; import { useStore } from '../store'; diff --git a/frontend/src/components/SecurityUpdateIntroModal.tsx b/frontend/src/components/SecurityUpdateIntroModal.tsx index be1022a..a326042 100644 --- a/frontend/src/components/SecurityUpdateIntroModal.tsx +++ b/frontend/src/components/SecurityUpdateIntroModal.tsx @@ -1,4 +1,5 @@ -import { Button, Modal } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Button } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { CSSProperties } from 'react'; diff --git a/frontend/src/components/SecurityUpdateProgressModal.tsx b/frontend/src/components/SecurityUpdateProgressModal.tsx index 1d14068..8230fb4 100644 --- a/frontend/src/components/SecurityUpdateProgressModal.tsx +++ b/frontend/src/components/SecurityUpdateProgressModal.tsx @@ -1,4 +1,5 @@ -import { Modal, Spin } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Spin } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; diff --git a/frontend/src/components/SecurityUpdateSettingsModal.tsx b/frontend/src/components/SecurityUpdateSettingsModal.tsx index 0f5f184..5ca4b27 100644 --- a/frontend/src/components/SecurityUpdateSettingsModal.tsx +++ b/frontend/src/components/SecurityUpdateSettingsModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import { useEffect, useRef, useState } from 'react'; -import { Button, Empty, Modal, Tag } from 'antd'; +import { Button, Empty, Tag } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types'; @@ -37,6 +38,8 @@ interface SecurityUpdateSettingsModalProps { focusTarget?: SecurityUpdateSettingsFocusTarget | null; focusRequest?: number; onClose: () => void; + onBack?: () => void; + embedded?: boolean; onStart: () => void; onRetry: () => void; onRestart: () => void; @@ -70,6 +73,8 @@ const SecurityUpdateSettingsModal = ({ focusTarget = null, focusRequest = 0, onClose, + onBack, + embedded = false, onStart, onRetry, onRestart, @@ -126,7 +131,7 @@ const SecurityUpdateSettingsModal = ({ +
-
+
{t('security_update.settings.title')}
@@ -153,6 +158,7 @@ const SecurityUpdateSettingsModal = ({
)} open={open} + embedded={embedded} onCancel={onClose} footer={[ showRetry ? ( @@ -179,6 +185,11 @@ const SecurityUpdateSettingsModal = ({ , + onBack ? ( + + ) : null, ]} width={760} styles={{ diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9d355bd..9e61ea3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; +import Modal from './common/ResizableDraggableModal'; +import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, diff --git a/frontend/src/components/SnippetSettingsModal.tsx b/frontend/src/components/SnippetSettingsModal.tsx index 41b3ba3..53f3b85 100644 --- a/frontend/src/components/SnippetSettingsModal.tsx +++ b/frontend/src/components/SnippetSettingsModal.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; -import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd'; import { PlusOutlined, DeleteOutlined, @@ -12,11 +13,14 @@ import type { SqlSnippet } from '../types'; import { useStore } from '../store'; import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import { useI18n } from '../i18n/provider'; interface SnippetSettingsModalProps { open: boolean; onClose: () => void; + onBack?: () => void; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme; + embedded?: boolean; } type DraftSnippet = Omit & { createdAt?: number }; @@ -34,9 +38,12 @@ const emptyDraft = (): DraftSnippet => ({ export default function SnippetSettingsModal({ open, onClose, + onBack, darkMode, overlayTheme, + embedded = false, }: SnippetSettingsModalProps) { + const { t } = useI18n(); const sqlSnippets = useStore((s) => s.sqlSnippets); const saveSqlSnippet = useStore((s) => s.saveSqlSnippet); const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet); @@ -210,7 +217,7 @@ export default function SnippetSettingsModal({ return ( +
-
+
代码片段管理
管理 SQL 代码片段,输入前缀后按 Tab 展开
-
+
} open={open} + embedded={embedded} onCancel={onClose} width={820} styles={{ @@ -451,6 +459,11 @@ export default function SnippetSettingsModal({ + {onBack ? ( + + ) : null}
); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index b89dac3..eb8ae3e 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { Button, Dropdown, message, Modal, Tabs, Tooltip } from 'antd'; +import { Button, Dropdown, message, Tabs, Tooltip } from 'antd'; import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons'; import type { MenuProps, TabsProps } from 'antd'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 644b250..dcbe05d 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; +import { Table, Tabs, Button, message, Input, Checkbox, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index d6aa631..f2f9a50 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,6 +1,7 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd'; +import { Input, Spin, Empty, Dropdown, message, Tooltip, Button } from 'antd'; import type { MenuProps } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; import { buildSidebarTablePinKey, useStore } from '../store'; diff --git a/frontend/src/components/ai/AIContextSelectorModal.tsx b/frontend/src/components/ai/AIContextSelectorModal.tsx index 79f4018..0cfccba 100644 --- a/frontend/src/components/ai/AIContextSelectorModal.tsx +++ b/frontend/src/components/ai/AIContextSelectorModal.tsx @@ -1,5 +1,6 @@ +import Modal from '../common/ResizableDraggableModal'; import React from 'react'; -import { Button, Checkbox, Input, Modal, Select, Spin } from 'antd'; +import { Button, Checkbox, Input, Select, Spin } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; diff --git a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx index 19e475e..828adb5 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx @@ -5,6 +5,7 @@ import mermaid from 'mermaid'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import Modal from '../../common/ResizableDraggableModal'; import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; @@ -109,7 +110,6 @@ const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: st return; } if (result.requiresConfirm) { - const { Modal } = await import('antd'); Modal.confirm({ title: '⚠️ 安全确认', content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, diff --git a/frontend/src/components/common/ResizableDraggableModal.css b/frontend/src/components/common/ResizableDraggableModal.css new file mode 100644 index 0000000..f3bab5c --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.css @@ -0,0 +1,143 @@ +.gn-resizable-draggable-modal { + position: relative; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + transition: transform 0.12s ease; +} + +.gn-resizable-draggable-modal[data-dragging='true'], +.gn-resizable-draggable-modal[data-resizing='true'] { + transition: none; + user-select: none; +} + +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-header, +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-title { + cursor: move; +} + +.gn-resizable-draggable-modal[data-has-resized-width='true'] { + width: var(--gn-modal-resized-width); +} + +.gn-resizable-draggable-modal .ant-modal-content { + width: 100%; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-content { + height: var(--gn-modal-resized-height); +} + +.gn-resizable-draggable-modal .ant-modal-body { + min-height: 0; +} + +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-confirm-title { + cursor: move; +} + +.gn-modal-resize-handle { + position: absolute; + z-index: 20; + opacity: 0; + pointer-events: auto; + transition: opacity 0.15s ease; +} + +.gn-resizable-draggable-modal:hover .gn-modal-resize-handle, +.gn-resizable-draggable-modal[data-resizing='true'] .gn-modal-resize-handle { + opacity: 1; +} + +.gn-modal-resize-handle-east { + top: 18px; + right: -4px; + width: 8px; + height: calc(100% - 36px); + cursor: ew-resize; +} + +.gn-modal-resize-handle-south { + left: 18px; + bottom: -4px; + width: calc(100% - 36px); + height: 8px; + cursor: ns-resize; +} + +.gn-modal-resize-handle-south-east { + right: -5px; + bottom: -5px; + width: 16px; + height: 16px; + cursor: nwse-resize; +} + +.gn-modal-resize-handle-south-east::after { + content: ''; + position: absolute; + right: 5px; + bottom: 5px; + width: 9px; + height: 9px; + border-right: 2px solid rgba(100, 116, 139, 0.55); + border-bottom: 2px solid rgba(100, 116, 139, 0.55); +} + +.gn-embedded-modal { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; +} + +.gn-embedded-modal-header { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; +} + +.gn-embedded-modal-title { + min-width: 0; + flex: 1 1 auto; +} + +.gn-embedded-modal-close { + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: transparent; + color: rgba(100, 116, 139, 0.9); + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.gn-embedded-modal-close:hover { + background: rgba(100, 116, 139, 0.12); +} + +.gn-embedded-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.gn-embedded-modal-footer { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; +} diff --git a/frontend/src/components/common/ResizableDraggableModal.test.ts b/frontend/src/components/common/ResizableDraggableModal.test.ts new file mode 100644 index 0000000..be132a9 --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const modalSource = readFileSync( + fileURLToPath(new globalThis.URL('./ResizableDraggableModal.tsx', import.meta.url)), + 'utf8', +); + +const modalCss = readFileSync( + fileURLToPath(new globalThis.URL('./ResizableDraggableModal.css', import.meta.url)), + 'utf8', +); + +describe('ResizableDraggableModal guards', () => { + it('routes component, static, and hook modals through the same draggable frame', () => { + expect(modalSource).toContain('const DraggableResizableModalFrame: React.FC'); + expect(modalSource).toContain(''); + expect(modalSource).toContain('ResizableDraggableModal.info = wrapModalFunc(AntdModal.info);'); + expect(modalSource).toContain('ResizableDraggableModal.success = wrapModalFunc(AntdModal.success);'); + expect(modalSource).toContain('ResizableDraggableModal.error = wrapModalFunc(AntdModal.error);'); + expect(modalSource).toContain('ResizableDraggableModal.warning = wrapModalFunc(AntdModal.warning);'); + expect(modalSource).toContain('ResizableDraggableModal.confirm = wrapModalFunc(AntdModal.confirm);'); + expect(modalSource).toContain('return [wrapHookModalApi(modalApi), contextHolder] as ReturnType;'); + expect(modalSource).toContain("const activeInteractionRef = useRef<'drag' | 'resize' | null>(null);"); + expect(modalSource).toContain('const [wrapperElement, setWrapperElement] = useState(null);'); + expect(modalSource).toContain('const bindWrapperRef = useCallback((node: HTMLDivElement | null) =>'); + expect(modalSource).toContain("wrapperElement.addEventListener('pointerdown', handleFrameStart);"); + expect(modalSource).toContain("wrapperElement.addEventListener('mousedown', handleFrameStart);"); + expect(modalSource).toContain("startResize('south-east', event);"); + expect(modalSource).toContain("wrapperElement?.closest('.ant-modal')"); + expect(modalSource).toContain("modalNode.style.width = `${size.width}px`;"); + expect(modalSource).toContain("window.addEventListener('click', suppressInteractionClick, true);"); + expect(modalSource).toContain("window.removeEventListener('click', suppressInteractionClick, true);"); + }); + + it('applies resized width and height to the underlying AntD modal nodes', () => { + expect(modalSource).toContain("style['--gn-modal-resized-width'] = `${size.width}px`;"); + expect(modalSource).toContain("style['--gn-modal-resized-height'] = `${size.height}px`;"); + expect(modalCss).toContain(".gn-resizable-draggable-modal[data-has-resized-width='true']"); + expect(modalCss).toContain('width: var(--gn-modal-resized-width);'); + expect(modalCss).toContain(".gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-content"); + expect(modalCss).toContain('height: var(--gn-modal-resized-height);'); + expect(modalCss).toMatch(/\.gn-modal-resize-handle\s*\{[^}]*pointer-events:\s*auto;/s); + }); +}); diff --git a/frontend/src/components/common/ResizableDraggableModal.tsx b/frontend/src/components/common/ResizableDraggableModal.tsx new file mode 100644 index 0000000..6549ee1 --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.tsx @@ -0,0 +1,426 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Modal as AntdModal } from 'antd'; +import type { ModalFuncProps, ModalProps } from 'antd'; +import './ResizableDraggableModal.css'; + +type ResizeDirection = 'east' | 'south' | 'south-east'; + +type ModalSize = { + width?: number; + height?: number; +}; + +type ModalPosition = { + x: number; + y: number; +}; + +export type ResizableDraggableModalProps = ModalProps & { + embedded?: boolean; + draggable?: boolean; + resizable?: boolean; + minResizableWidth?: number; + minResizableHeight?: number; +}; + +const DEFAULT_MIN_WIDTH = 360; +const DEFAULT_MIN_HEIGHT = 220; +const VIEWPORT_PADDING = 16; + +const isInteractiveTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + return !!target.closest('button, a, input, textarea, select, [contenteditable="true"], .ant-select, .ant-picker, .ant-dropdown, .ant-checkbox, .ant-radio'); +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +type DraggableResizableModalFrameProps = { + active?: boolean; + children: React.ReactNode; + draggable: boolean; + resizable: boolean; + minResizableWidth: number; + minResizableHeight: number; +}; + +const DraggableResizableModalFrame: React.FC = ({ + active = true, + children, + draggable, + resizable, + minResizableWidth, + minResizableHeight, +}) => { + const wrapperRef = useRef(null); + const activeInteractionRef = useRef<'drag' | 'resize' | null>(null); + const [wrapperElement, setWrapperElement] = useState(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState({}); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + + useEffect(() => { + if (!active) { + setPosition({ x: 0, y: 0 }); + setSize({}); + setIsDragging(false); + setIsResizing(false); + activeInteractionRef.current = null; + } + }, [active]); + + const startDrag = useCallback((event: PointerEvent | MouseEvent) => { + if (activeInteractionRef.current) return; + if (!draggable || event.button !== 0 || isInteractiveTarget(event.target)) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target?.closest('.ant-modal-header, .ant-modal-title, .ant-modal-confirm-title')) return; + + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalNode instanceof HTMLElement)) return; + + const startX = event.clientX; + const startY = event.clientY; + const startPosition = { ...position }; + const rect = modalNode.getBoundingClientRect(); + const minX = VIEWPORT_PADDING - rect.left + startPosition.x; + const maxX = window.innerWidth - VIEWPORT_PADDING - rect.right + startPosition.x; + const minY = VIEWPORT_PADDING - rect.top + startPosition.y; + const maxY = window.innerHeight - VIEWPORT_PADDING - rect.bottom + startPosition.y; + + event.preventDefault(); + activeInteractionRef.current = 'drag'; + setIsDragging(true); + + const suppressInteractionClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + + const handleMove = (moveEvent: PointerEvent | MouseEvent) => { + const nextX = clamp(startPosition.x + moveEvent.clientX - startX, minX, maxX); + const nextY = clamp(startPosition.y + moveEvent.clientY - startY, minY, maxY); + setPosition({ x: nextX, y: nextY }); + }; + + const stopDrag = () => { + activeInteractionRef.current = null; + setIsDragging(false); + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('pointerup', stopDrag); + window.removeEventListener('mouseup', stopDrag); + window.removeEventListener('pointercancel', stopDrag); + window.setTimeout(() => { + window.removeEventListener('click', suppressInteractionClick, true); + }, 0); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('mousemove', handleMove); + window.addEventListener('pointerup', stopDrag); + window.addEventListener('mouseup', stopDrag); + window.addEventListener('pointercancel', stopDrag); + window.addEventListener('click', suppressInteractionClick, true); + }, [draggable, position, wrapperElement]); + + const startResize = useCallback((direction: ResizeDirection, event: PointerEvent | MouseEvent) => { + if (activeInteractionRef.current) return; + if (!resizable || event.button !== 0) return; + const modalContent = wrapperElement?.querySelector('.ant-modal-content'); + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalContent instanceof HTMLElement) || !(modalNode instanceof HTMLElement)) return; + + const startX = event.clientX; + const startY = event.clientY; + const rect = modalContent.getBoundingClientRect(); + const modalRect = modalNode.getBoundingClientRect(); + const maxWidth = Math.max(minResizableWidth, window.innerWidth - modalRect.left - VIEWPORT_PADDING); + const maxHeight = Math.max(minResizableHeight, window.innerHeight - modalRect.top - VIEWPORT_PADDING); + + event.preventDefault(); + event.stopPropagation(); + activeInteractionRef.current = 'resize'; + setIsResizing(true); + setSize({ + width: rect.width, + height: rect.height, + }); + + const suppressInteractionClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + + const handleMove = (moveEvent: PointerEvent | MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const deltaY = moveEvent.clientY - startY; + setSize({ + width: direction === 'south' ? rect.width : clamp(rect.width + deltaX, minResizableWidth, maxWidth), + height: direction === 'east' ? rect.height : clamp(rect.height + deltaY, minResizableHeight, maxHeight), + }); + }; + + const stopResize = () => { + activeInteractionRef.current = null; + setIsResizing(false); + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('mouseup', stopResize); + window.removeEventListener('pointercancel', stopResize); + window.setTimeout(() => { + window.removeEventListener('click', suppressInteractionClick, true); + }, 0); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('mousemove', handleMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('mouseup', stopResize); + window.addEventListener('pointercancel', stopResize); + window.addEventListener('click', suppressInteractionClick, true); + }, [minResizableHeight, minResizableWidth, resizable, wrapperElement]); + + useEffect(() => { + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalNode instanceof HTMLElement) || !size.width) return undefined; + + modalNode.style.width = `${size.width}px`; + return () => { + modalNode.style.removeProperty('width'); + }; + }, [size.width, wrapperElement]); + + const handleFrameStart = useCallback((event: PointerEvent | MouseEvent) => { + const target = event.target instanceof HTMLElement ? event.target : null; + const resizeHandle = target?.closest('.gn-modal-resize-handle'); + if (resizeHandle instanceof HTMLElement) { + if (resizeHandle.classList.contains('gn-modal-resize-handle-south-east')) { + startResize('south-east', event); + return; + } + if (resizeHandle.classList.contains('gn-modal-resize-handle-east')) { + startResize('east', event); + return; + } + if (resizeHandle.classList.contains('gn-modal-resize-handle-south')) { + startResize('south', event); + return; + } + } + startDrag(event); + }, [startDrag, startResize]); + + const bindWrapperRef = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + setWrapperElement(node); + }, []); + + useEffect(() => { + if (!wrapperElement) return undefined; + + wrapperElement.addEventListener('pointerdown', handleFrameStart); + wrapperElement.addEventListener('mousedown', handleFrameStart); + + return () => { + wrapperElement.removeEventListener('pointerdown', handleFrameStart); + wrapperElement.removeEventListener('mousedown', handleFrameStart); + }; + }, [handleFrameStart, wrapperElement]); + + const frameStyle = useMemo(() => { + const style = { + transform: `translate(${position.x}px, ${position.y}px)`, + } as React.CSSProperties & Record; + if (size.width) { + style['--gn-modal-resized-width'] = `${size.width}px`; + } + if (size.height) { + style['--gn-modal-resized-height'] = `${size.height}px`; + } + return style; + }, [position.x, position.y, size.height, size.width]); + + return ( +
+ {children} + {resizable ? ( + <> +
+ ); +}; + +const ResizableDraggableModalBase: React.FC = ({ + embedded = false, + draggable = true, + resizable = true, + minResizableWidth = DEFAULT_MIN_WIDTH, + minResizableHeight = DEFAULT_MIN_HEIGHT, + modalRender, + open, + styles, + width, + children, + ...props +}) => { + const isTestRuntime = Boolean((import.meta as unknown as { env?: { MODE?: string } }).env?.MODE === 'test'); + + if (embedded) { + if (!open) return null; + const footerNode: React.ReactNode = props.footer === null || typeof props.footer === 'function' ? null : props.footer; + return ( +
+ {props.title || props.closable !== false ? ( +
+
{props.title}
+ {props.closable !== false ? ( + + ) : null} +
+ ) : null} +
+ {children} +
+ {footerNode ? ( +
+ {footerNode} +
+ ) : null} +
+ ); + } + + return ( + { + const renderedNode = modalRender ? modalRender(modalNode) : modalNode; + return ( + + {renderedNode} + + ); + }} + > + {children} + + ); +}; + +type ResizableDraggableModalStatic = React.FC & { + info: typeof AntdModal.info; + success: typeof AntdModal.success; + error: typeof AntdModal.error; + warning: typeof AntdModal.warning; + confirm: typeof AntdModal.confirm; + destroyAll: typeof AntdModal.destroyAll; + useModal: typeof AntdModal.useModal; +}; + +type ModalConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps); +type ModalRefWithUpdate = { + update: (configUpdate: ModalConfigUpdate) => void; +}; + +const withDraggableModalRender = (config: ModalFuncProps): ModalFuncProps => { + const originalModalRender = config.modalRender; + return { + ...config, + modalRender: (modalNode) => ( + + {originalModalRender ? originalModalRender(modalNode) : modalNode} + + ), + }; +}; + +const wrapModalRefUpdate = (modalRef: T): T => { + const rawUpdate = modalRef.update.bind(modalRef); + modalRef.update = (configUpdate: ModalConfigUpdate) => { + rawUpdate(typeof configUpdate === 'function' + ? (prevConfig) => withDraggableModalRender(configUpdate(prevConfig)) + : withDraggableModalRender(configUpdate)); + }; + return modalRef; +}; + +const wrapModalFunc = ModalRefWithUpdate>(modalFunc: T): T => ( + ((config: ModalFuncProps) => wrapModalRefUpdate(modalFunc(withDraggableModalRender(config)))) as T +); + +const wrapHookModalApi = [0]>(modalApi: T): T => ({ + ...modalApi, + info: wrapModalFunc(modalApi.info), + success: wrapModalFunc(modalApi.success), + error: wrapModalFunc(modalApi.error), + warning: wrapModalFunc(modalApi.warning), + confirm: wrapModalFunc(modalApi.confirm), +}) as T; + +const ResizableDraggableModal = ResizableDraggableModalBase as ResizableDraggableModalStatic; + +ResizableDraggableModal.info = wrapModalFunc(AntdModal.info); +ResizableDraggableModal.success = wrapModalFunc(AntdModal.success); +ResizableDraggableModal.error = wrapModalFunc(AntdModal.error); +ResizableDraggableModal.warning = wrapModalFunc(AntdModal.warning); +ResizableDraggableModal.confirm = wrapModalFunc(AntdModal.confirm); +ResizableDraggableModal.destroyAll = ((...args: Parameters) => AntdModal.destroyAll(...args)) as typeof AntdModal.destroyAll; +ResizableDraggableModal.useModal = ((...args: Parameters) => { + const [modalApi, contextHolder] = AntdModal.useModal(...args); + return [wrapHookModalApi(modalApi), contextHolder] as ReturnType; +}) as typeof AntdModal.useModal; + +export default ResizableDraggableModal; diff --git a/frontend/src/components/jvm/JVMChangePreviewModal.tsx b/frontend/src/components/jvm/JVMChangePreviewModal.tsx index 25a328b..593f2f4 100644 --- a/frontend/src/components/jvm/JVMChangePreviewModal.tsx +++ b/frontend/src/components/jvm/JVMChangePreviewModal.tsx @@ -1,5 +1,6 @@ +import Modal from '../common/ResizableDraggableModal'; import React, { useMemo } from "react"; -import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd"; +import { Alert, Descriptions, Space, Tag, Typography } from 'antd'; import type { JVMChangePreview } from "../../types"; import { t as translate } from "../../i18n"; diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index 65695f0..92f9eac 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -56,6 +56,9 @@ const readDataGridV2DdlWorkspaceSource = (): string => const readQueryEditorSource = (): string => readFileSync(new URL("../components/QueryEditor.tsx", import.meta.url), "utf8"); +const readQueryEditorResultsPanelSource = (): string => + readFileSync(new URL("../components/QueryEditorResultsPanel.tsx", import.meta.url), "utf8"); + const readSqlDialectSource = (): string => readFileSync(new URL("../utils/sqlDialect.ts", import.meta.url), "utf8"); @@ -111,6 +114,12 @@ describe("i18n catalog", () => { it("includes App shell keys required by every supported language", () => { const appShellKeys = [ "app.tools.title", + "app.tools.group.config.title", + "app.tools.group.config.description", + "app.tools.group.workflow.title", + "app.tools.group.workflow.description", + "app.tools.group.workspace.title", + "app.tools.group.workspace.description", "app.tools.entry.import.title", "app.tools.entry.security_update.description", "app.tools.entry.security_update.status_description", @@ -155,6 +164,7 @@ describe("i18n catalog", () => { "app.ai_panel.error.title", "app.about.title", "app.about.field.update_status", + "common.back_to_previous", "common.unknown", "common.close", ] as const; @@ -558,11 +568,6 @@ describe("i18n catalog", () => { "data_grid.message.export_failed", ] as const; const source = readDataGridSource(); - const exportDataSource = sliceBetween( - source, - " const exportData = async (rows: any[], format: string) => {", - " const [sortInfo, setSortInfo]", - ); const base = catalogs["en-US"]; for (const language of SUPPORTED_LANGUAGES) { @@ -577,16 +582,12 @@ describe("i18n catalog", () => { expect(getPlaceholders(catalogs[language]["data_grid.message.export_failed"])).toEqual(["detail"]); } - for (const key of dataGridRowExportMessageKeys) { - expect(exportDataSource).toContain(key); - } + expect(source).toContain("await runExportWithProgress({"); expect(t("zh-CN", "data_grid.message.exporting_rows", { count: "" })).toContain(""); expect(t("en-US", "data_grid.message.export_failed", { detail: "" })).toContain(""); - expect(exportDataSource).not.toContain("正在导出 ${rows.length} 条数据..."); - expect(exportDataSource).not.toContain("导出成功"); - expect(exportDataSource).not.toContain("导出失败: "); - assertSourceDoesNotInlineCatalogValues(exportDataSource, dataGridRowExportMessageKeys); + expect(source).not.toContain("正在导出 ${rows.length} 条数据..."); + assertSourceDoesNotInlineCatalogValues(source, dataGridRowExportMessageKeys); }); it("keeps DataGrid commit, preview SQL, and copy feedback messages in catalogs with raw placeholders", () => { @@ -1990,7 +1991,7 @@ describe("i18n catalog", () => { const aiContextSource = sliceBetween( source, "const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => {", - "// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)", + "const _g = globalThis as any;", ); for (const language of SUPPORTED_LANGUAGES) { @@ -2127,7 +2128,7 @@ describe("i18n catalog", () => { const diagnosePromptSource = sliceBetween( source, "const prompt = translate('query_editor.ai_prompt.diagnose', {", - "{translate('query_editor.result.ai_diagnose')}", + " const sqlEditorTransactionToolbar = (", ); const toolbarAndDiagnoseSource = `${toolbarPromptSource}\n${diagnosePromptSource}`; @@ -2159,39 +2160,7 @@ describe("i18n catalog", () => { "app.shortcuts.action.saveQuery.label", "query_editor.action.show_object_info", ] as const; - const source = readQueryEditorSource(); - const actionLabelSource = [ - sliceBetween( - source, - " objectHoverActionRef.current = editor.addAction({", - " editor.onDidChangeCursorPosition?.((event: any) => {", - ), - sliceBetween( - source, - " // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding", - " // HMR 重载时释放旧注册避免补全项重复", - ), - sliceBetween( - source, - " objectHoverActionRef.current?.dispose?.();", - " }, [languagePreference, showObjectInfoAtPosition]);", - ), - sliceBetween( - source, - " const binding = runQueryShortcutBinding;", - " }, [languagePreference, runQueryShortcutBinding]);", - ), - sliceBetween( - source, - " const binding = selectCurrentStatementShortcutBinding;", - " }, [languagePreference, selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);", - ), - sliceBetween( - source, - " const binding = saveQueryShortcutBinding;", - " }, [languagePreference, saveQueryShortcutBinding]);", - ), - ].join("\n"); + const actionLabelSource = readQueryEditorSource(); for (const language of SUPPORTED_LANGUAGES) { for (const key of actionLabelKeys) { @@ -2221,7 +2190,7 @@ describe("i18n catalog", () => { const objectNavigationSource = sliceBetween( source, " if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') {", - " });\r\n\r\n editor.onDidDispose?.(() => {", + " editor.onDidDispose?.(() => {", ); for (const language of SUPPORTED_LANGUAGES) { @@ -2265,12 +2234,7 @@ describe("i18n catalog", () => { "query_editor.empty_state.title", "query_editor.empty_state.description", ] as const; - const source = readQueryEditorSource(); - const emptyStateSource = sliceBetween( - source, - "
\r\n\r\n Date: Thu, 18 Jun 2026 20:29:19 +0800 Subject: [PATCH 17/61] =?UTF-8?q?=F0=9F=90=9B=20fix(connection):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=95=B0=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 测试连接改为隔离连接,成功后立即关闭并避免写入全局缓存 - 新增通用 SQL 连接池配置,限制网络型数据源空闲连接长期占用 - Redis 测试连接改为临时客户端并立即释放 - MySQL 连接数超限时释放同实例缓存连接并重试 - 补充连接释放、缓存重试和连接池参数回归测试 --- internal/app/app.go | 113 +++++++++++++++++- internal/app/methods_db.go | 58 +++++---- internal/app/methods_db_conn_test.go | 123 +++++++++++++++++++- internal/app/methods_redis.go | 40 ++++++- internal/app/methods_redis_test.go | 49 +++++++- internal/db/clickhouse_impl.go | 1 + internal/db/custom_impl.go | 3 + internal/db/dameng_impl.go | 1 + internal/db/diros_impl.go | 1 + internal/db/gaussdb_impl.go | 2 + internal/db/highgo_impl.go | 1 + internal/db/iris_impl.go | 3 + internal/db/kingbase_impl.go | 4 +- internal/db/mariadb_impl.go | 3 + internal/db/mysql_connection_params_test.go | 15 +++ internal/db/mysql_impl.go | 1 + internal/db/oceanbase_impl.go | 2 + internal/db/oracle_impl.go | 1 + internal/db/postgres_impl.go | 2 + internal/db/sql_pool.go | 27 +++++ internal/db/sqlserver_impl.go | 3 + internal/db/starrocks_impl.go | 1 + internal/db/tdengine_impl.go | 1 + internal/db/vastbase_impl.go | 1 + 24 files changed, 423 insertions(+), 33 deletions(-) create mode 100644 internal/db/sql_pool.go diff --git a/internal/app/app.go b/internal/app/app.go index 57603b4..3a7dc5e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -348,6 +348,7 @@ func normalizeConnectionReleaseMatchConfig(config connection.ConnectionConfig) c normalized := normalizeCacheKeyConfig(config) normalized.Database = "" normalized.RedisDB = 0 + normalized.ConnectionParams = "" return normalized } @@ -358,6 +359,72 @@ func getConnectionReleaseMatchKey(config connection.ConnectionConfig) string { return hex.EncodeToString(sum[:]) } +type cachedDatabaseCloseTarget struct { + key string + inst db.Database +} + +func (a *App) releaseCachedDatabaseConnectionsForConfig(config connection.ConnectionConfig) int { + if a == nil { + return 0 + } + return a.releaseCachedDatabaseConnectionsByMatchKey(getConnectionReleaseMatchKey(config)) +} + +func (a *App) releaseCachedDatabaseConnectionsByMatchKey(targetKey string) int { + if a == nil || strings.TrimSpace(targetKey) == "" { + return 0 + } + + targets := make([]cachedDatabaseCloseTarget, 0) + a.mu.Lock() + for key, entry := range a.dbCache { + entryConfig := entry.config + if strings.TrimSpace(entryConfig.Type) == "" { + continue + } + if getConnectionReleaseMatchKey(entryConfig) != targetKey { + continue + } + targets = append(targets, cachedDatabaseCloseTarget{key: key, inst: entry.inst}) + delete(a.dbCache, key) + } + a.mu.Unlock() + + for _, target := range targets { + if target.inst == nil { + continue + } + if closeErr := target.inst.Close(); closeErr != nil { + logger.Error(closeErr, "关闭缓存连接失败:缓存Key=%s", shortCacheKey(target.key)) + } + } + + return len(targets) +} + +func isMySQLMaxUserConnectionsError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(normalizeErrorMessage(err)) + return strings.Contains(message, "max_user_connections") || + (strings.Contains(message, "error 1226") && strings.Contains(message, "has exceeded")) +} + +func withMySQLMaxUserConnectionsHint(err error, released int) error { + if err == nil { + return nil + } + if !isMySQLMaxUserConnectionsError(err) { + return err + } + if released > 0 { + return fmt.Errorf("%w;数据库账号连接数已达上限(max_user_connections),GoNavi 已释放同一连接实例的 %d 个缓存连接并重试;若仍失败,请关闭 Navicat/其他客户端连接或提高数据库用户 max_user_connections", err, released) + } + return fmt.Errorf("%w;数据库账号连接数已达上限(max_user_connections),GoNavi 未找到可释放的同实例缓存连接;请关闭 Navicat/其他客户端连接或提高数据库用户 max_user_connections", err) +} + func shortCacheKey(cacheKey string) string { shortKey := cacheKey if len(shortKey) > 12 { @@ -638,11 +705,10 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro } func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) { - resolvedConfig, err := a.resolveConnectionSecrets(config) + effectiveConfig, err := a.resolveEffectiveConnectionConfig(config) if err != nil { - return nil, wrapConnectError(config, err) + return nil, err } - effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) if supported, reason := driverRuntimeSupportStatusFunc(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type)) @@ -670,6 +736,14 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab return dbInst, nil } +func (a *App) resolveEffectiveConnectionConfig(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + resolvedConfig, err := a.resolveConnectionSecrets(config) + if err != nil { + return config, wrapConnectError(config, err) + } + return applyGlobalProxyToConnection(resolvedConfig), nil +} + func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) { resolvedConfig, err := a.resolveConnectionSecrets(config) if err != nil { @@ -771,9 +845,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing initialKey := key dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(resolvedConfig) if err != nil { - failedKey := getCacheKey(connectedConfig) - a.recordConnectFailureByKey(failedKey, err) - return nil, err + retryInst, retryConfig, retryErr := a.retryConnectAfterMySQLMaxUserConnections(resolvedConfig, connectedConfig, err) + if retryErr != nil { + failedKey := getCacheKey(retryConfig) + a.recordConnectFailureByKey(failedKey, retryErr) + return nil, retryErr + } + dbInst = retryInst + connectedConfig = retryConfig } a.clearConnectFailureByKey(initialKey) effectiveConfig = connectedConfig @@ -800,6 +879,28 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing return dbInst, nil } +func (a *App) retryConnectAfterMySQLMaxUserConnections(rawConfig connection.ConnectionConfig, failedConfig connection.ConnectionConfig, err error) (db.Database, connection.ConnectionConfig, error) { + if !isMySQLMaxUserConnectionsError(err) { + return nil, failedConfig, err + } + + released := a.releaseCachedDatabaseConnectionsForConfig(failedConfig) + logger.Warnf("检测到 MySQL 用户连接数超限,已释放同实例缓存连接:%s 数量=%d", formatConnSummary(failedConfig), released) + if released <= 0 { + return nil, failedConfig, withMySQLMaxUserConnectionsHint(err, released) + } + + dbInst, connectedConfig, retryErr := a.connectDatabaseWithStartupRetry(rawConfig) + if retryErr != nil { + if isMySQLMaxUserConnectionsError(retryErr) { + return nil, connectedConfig, withMySQLMaxUserConnectionsHint(retryErr, released) + } + return nil, connectedConfig, retryErr + } + logger.Infof("MySQL 用户连接数超限释放缓存后重连成功:%s 释放数量=%d", formatConnSummary(connectedConfig), released) + return dbInst, connectedConfig, nil +} + func (a *App) getCachedConnectFailureByKey(key string) (cachedConnectFailure, time.Duration, bool) { if a == nil || strings.TrimSpace(key) == "" { return cachedConnectFailure{}, 0, false diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 8745199..8de9e8d 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -81,27 +81,7 @@ func (a *App) DBReleaseConnection(config connection.ConnectionConfig) connection logger.Error(wrapped, "DBReleaseConnection 解析连接密文失败:%s", formatConnSummary(config)) return connection.QueryResult{Success: false, Message: wrapped.Error()} } - targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig)) - closed := 0 - - a.mu.Lock() - for key, entry := range a.dbCache { - entryConfig := entry.config - if strings.TrimSpace(entryConfig.Type) == "" { - continue - } - if getConnectionReleaseMatchKey(entryConfig) != targetKey { - continue - } - if entry.inst != nil { - if closeErr := entry.inst.Close(); closeErr != nil { - logger.Error(closeErr, "DBReleaseConnection 关闭缓存连接失败:缓存Key=%s", shortCacheKey(key)) - } - } - delete(a.dbCache, key) - closed++ - } - a.mu.Unlock() + closed := a.releaseCachedDatabaseConnectionsForConfig(applyGlobalProxyToConnection(resolvedConfig)) logger.Infof("DBReleaseConnection 已释放数据库连接:%s 数量=%d", formatConnSummary(resolvedConfig), closed) return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}} @@ -115,16 +95,50 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer logger.Warnf("TestConnection 参数校验失败:耗时=%s %s 原因=%s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig), err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } - _, err := a.getDatabaseForcePing(testConfig) + dbInst, err := a.openDatabaseIsolated(testConfig) + if err != nil { + dbInst, err = a.retryIsolatedTestConnectionAfterMySQLMaxUserConnections(testConfig, err) + } if err != nil { logger.Error(err, "TestConnection 连接测试失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig)) return connection.QueryResult{Success: false, Message: err.Error()} } + if dbInst != nil { + if closeErr := dbInst.Close(); closeErr != nil { + logger.Error(closeErr, "TestConnection 释放临时连接失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig)) + return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)} + } + } logger.Infof("TestConnection 连接测试成功:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig)) return connection.QueryResult{Success: true, Message: "连接成功"} } +func (a *App) retryIsolatedTestConnectionAfterMySQLMaxUserConnections(config connection.ConnectionConfig, err error) (db.Database, error) { + if !isMySQLMaxUserConnectionsError(err) { + return nil, err + } + + effectiveConfig, resolveErr := a.resolveEffectiveConnectionConfig(config) + if resolveErr != nil { + return nil, err + } + released := a.releaseCachedDatabaseConnectionsForConfig(effectiveConfig) + logger.Warnf("测试连接检测到 MySQL 用户连接数超限,已释放同实例缓存连接:%s 数量=%d", formatConnSummary(effectiveConfig), released) + if released <= 0 { + return nil, withMySQLMaxUserConnectionsHint(err, released) + } + + dbInst, retryErr := a.openDatabaseIsolated(config) + if retryErr != nil { + if isMySQLMaxUserConnectionsError(retryErr) { + return nil, withMySQLMaxUserConnectionsHint(retryErr, released) + } + return nil, retryErr + } + return dbInst, nil +} + func (a *App) MongoDiscoverMembers(config connection.ConnectionConfig) connection.QueryResult { config.Type = "mongodb" diff --git a/internal/app/methods_db_conn_test.go b/internal/app/methods_db_conn_test.go index 39c365b..daa51f1 100644 --- a/internal/app/methods_db_conn_test.go +++ b/internal/app/methods_db_conn_test.go @@ -1,17 +1,25 @@ package app import ( + "errors" "strings" "testing" "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" ) type releaseRecordingDB struct { - closed int + closed int + connect func(config connection.ConnectionConfig) error } -func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { return nil } +func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { + if f.connect != nil { + return f.connect(config) + } + return nil +} func (f *releaseRecordingDB) Close() error { f.closed++ return nil @@ -214,3 +222,114 @@ func TestDBReleaseConnectionClosesAllDatabaseCacheEntriesForSameInstance(t *test t.Fatalf("expected only unrelated cache entry to remain, got %d", len(app.dbCache)) } } + +func TestTestConnectionUsesIsolatedConnectionAndClosesIt(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + proxySnapshot := currentGlobalProxyConfig() + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + }() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + testDB := &releaseRecordingDB{} + newDatabaseFunc = func(dbType string) (db.Database, error) { + return testDB, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewApp() + result := app.TestConnection(connection.ConnectionConfig{ + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + User: "root", + Database: "app", + }) + + if !result.Success { + t.Fatalf("expected test connection success, got %s", result.Message) + } + if testDB.closed != 1 { + t.Fatalf("expected isolated test connection to be closed once, got %d", testDB.closed) + } + if len(app.dbCache) != 0 { + t.Fatalf("test connection must not write global db cache, got %d entries", len(app.dbCache)) + } +} + +func TestGetDatabaseReleasesSameInstanceCacheAndRetriesOnMaxUserConnections(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + proxySnapshot := currentGlobalProxyConfig() + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + }() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + connectCalls := 0 + newDatabaseFunc = func(dbType string) (db.Database, error) { + return &releaseRecordingDB{ + connect: func(config connection.ConnectionConfig) error { + connectCalls++ + if connectCalls == 1 { + return errors.New("Error 1226 (42000): User 'yangguofeng' has exceeded the 'max_user_connections' resource (current value: 5)") + } + return nil + }, + }, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewApp() + mainConfig := connection.ConnectionConfig{Type: "mysql", Host: "db.example.com", Port: 3306, User: "yangguofeng", Database: "main"} + analyticsConfig := mainConfig + analyticsConfig.Database = "analytics" + analyticsConfig.ConnectionParams = "charset=utf8mb4" + otherConfig := mainConfig + otherConfig.User = "other" + + mainDB := &releaseRecordingDB{} + analyticsDB := &releaseRecordingDB{} + otherDB := &releaseRecordingDB{} + app.dbCache[getCacheKey(mainConfig)] = cachedDatabase{inst: mainDB, config: normalizeCacheKeyConfig(mainConfig)} + app.dbCache[getCacheKey(analyticsConfig)] = cachedDatabase{inst: analyticsDB, config: normalizeCacheKeyConfig(analyticsConfig)} + app.dbCache[getCacheKey(otherConfig)] = cachedDatabase{inst: otherDB, config: normalizeCacheKeyConfig(otherConfig)} + + targetConfig := mainConfig + targetConfig.Database = "target" + targetConfig.ConnectionParams = "timeout=10" + + inst, err := app.getDatabase(targetConfig) + if err != nil { + t.Fatalf("expected retry after releasing cached same-instance connections, got %v", err) + } + if inst == nil { + t.Fatal("expected database instance") + } + if connectCalls != 2 { + t.Fatalf("expected one failed connect and one retry, got %d calls", connectCalls) + } + if mainDB.closed != 1 || analyticsDB.closed != 1 { + t.Fatalf("expected same-instance cached connections closed, got main=%d analytics=%d", mainDB.closed, analyticsDB.closed) + } + if otherDB.closed != 0 { + t.Fatalf("expected other user cache to remain open, got closed=%d", otherDB.closed) + } +} diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index f15e03b..569a762 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -78,6 +78,31 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli return client, nil } +func (a *App) openRedisClientIsolated(config connection.ConnectionConfig) (redis.RedisClient, error) { + resolvedConfig, err := a.resolveConnectionSecrets(config) + if err != nil { + wrapped := wrapConnectError(config, err) + logger.Error(wrapped, "Redis 密文解析失败:%s", formatRedisConnSummary(config)) + return nil, wrapped + } + + effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) + connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) + if proxyErr != nil { + wrapped := wrapConnectError(effectiveConfig, proxyErr) + logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig)) + return nil, wrapped + } + + client, connectedConfig, connectErr := connectRedisClientWithLegacyRootFallback(connectConfig) + if connectErr != nil { + wrapped := wrapConnectError(connectedConfig, connectErr) + logger.Error(wrapped, "Redis 临时连接失败:%s", formatRedisConnSummary(connectedConfig)) + return nil, wrapped + } + return client, nil +} + func connectRedisClientWithLegacyRootFallback(config connection.ConnectionConfig) (redis.RedisClient, connection.ConnectionConfig, error) { client := newRedisClientFunc() if err := client.Connect(config); err == nil { @@ -237,7 +262,20 @@ func (a *App) RedisConnect(config connection.ConnectionConfig) connection.QueryR // RedisTestConnection tests a Redis connection (alias for RedisConnect) func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection.QueryResult { - return a.RedisConnect(config) + config.Type = "redis" + client, err := a.openRedisClientIsolated(config) + if err != nil { + logger.Error(err, "RedisTestConnection 连接失败:%s", formatRedisConnSummary(config)) + return connection.QueryResult{Success: false, Message: err.Error()} + } + if client != nil { + if closeErr := client.Close(); closeErr != nil { + logger.Error(closeErr, "RedisTestConnection 释放临时连接失败:%s", formatRedisConnSummary(config)) + return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)} + } + } + logger.Infof("RedisTestConnection 连接成功:%s", formatRedisConnSummary(config)) + return connection.QueryResult{Success: true, Message: "连接成功"} } // RedisScanKeys scans keys matching a pattern diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go index d17c4f0..b9de677 100644 --- a/internal/app/methods_redis_test.go +++ b/internal/app/methods_redis_test.go @@ -12,6 +12,7 @@ type capturingRedisClient struct { connectConfig connection.ConnectionConfig deletedHashKey string deletedHashFields []string + closed int } func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error { @@ -19,7 +20,10 @@ func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error return nil } -func (c *capturingRedisClient) Close() error { return nil } +func (c *capturingRedisClient) Close() error { + c.closed++ + return nil +} func (c *capturingRedisClient) Ping() error { return nil } @@ -119,6 +123,49 @@ func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error return c.connectErr } +func TestRedisTestConnectionUsesIsolatedClientAndClosesIt(t *testing.T) { + originalNewRedisClientFunc := newRedisClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + proxySnapshot := currentGlobalProxyConfig() + defer func() { + newRedisClientFunc = originalNewRedisClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + CloseAllRedisClients() + }() + CloseAllRedisClients() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + client := &capturingRedisClient{} + newRedisClientFunc = func() redislib.RedisClient { + return client + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewApp() + result := app.RedisTestConnection(connection.ConnectionConfig{ + Type: "redis", + Host: "127.0.0.1", + Port: 6379, + }) + + if !result.Success { + t.Fatalf("expected redis test connection success, got %s", result.Message) + } + if client.closed != 1 { + t.Fatalf("expected isolated redis test client to be closed once, got %d", client.closed) + } + if len(redisCache) != 0 { + t.Fatalf("redis test connection must not write global redis cache, got %d entries", len(redisCache)) + } +} + func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) { testCases := []struct { name string diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go index 0dac365..d0952b8 100644 --- a/internal/db/clickhouse_impl.go +++ b/internal/db/clickhouse_impl.go @@ -643,6 +643,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { break } c.conn = clickhouse.OpenDB(opts) + configureSQLConnectionPool(c.conn, "clickhouse") if err := c.Ping(); err != nil { lastProtocolErr = err failureMessage := clickHouseAttemptFailureMessage(protocol, err) diff --git a/internal/db/custom_impl.go b/internal/db/custom_impl.go index 0f2d027..657fa92 100644 --- a/internal/db/custom_impl.go +++ b/internal/db/custom_impl.go @@ -34,10 +34,13 @@ func (c *CustomDB) Connect(config connection.ConnectionConfig) error { if err != nil { return formatCustomDriverOpenError(driver, err) } + configureSQLConnectionPool(db, driver) c.conn = db c.driver = driver c.pingTimeout = getConnectTimeout(config) if err := c.Ping(); err != nil { + _ = db.Close() + c.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } return nil diff --git a/internal/db/dameng_impl.go b/internal/db/dameng_impl.go index 7b05601..6b57df4 100644 --- a/internal/db/dameng_impl.go +++ b/internal/db/dameng_impl.go @@ -110,6 +110,7 @@ func (d *DamengDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "dameng") d.conn = db d.pingTimeout = getConnectTimeout(attempt) if err := d.Ping(); err != nil { diff --git a/internal/db/diros_impl.go b/internal/db/diros_impl.go index 4fa0c27..7619528 100644 --- a/internal/db/diros_impl.go +++ b/internal/db/diros_impl.go @@ -187,6 +187,7 @@ func (d *DirosDB) Connect(config connection.ConnectionConfig) error { errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err)) continue } + configureSQLConnectionPool(db, "diros") timeout := getConnectTimeout(candidateConfig) ctx, cancel := utils.ContextWithTimeout(timeout) diff --git a/internal/db/gaussdb_impl.go b/internal/db/gaussdb_impl.go index 88ba92b..8bb79ce 100644 --- a/internal/db/gaussdb_impl.go +++ b/internal/db/gaussdb_impl.go @@ -179,6 +179,7 @@ func (g *GaussDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("%s 数据库=%s 打开连接失败: %v", sslLabel, dbName, err)) continue } + configureSQLConnectionPool(dbConn, "gaussdb") g.conn = dbConn if err := g.Ping(); err != nil { @@ -233,6 +234,7 @@ func (g *GaussDB) ensureSearchPath(baseDSN string) { newDB, err := sql.Open("gaussdb", newDSN) if err == nil { + configureSQLConnectionPool(newDB, "gaussdb") newDB.SetConnMaxLifetime(5 * time.Minute) oldConn := g.conn g.conn = newDB diff --git a/internal/db/highgo_impl.go b/internal/db/highgo_impl.go index 19fb06c..1edc350 100644 --- a/internal/db/highgo_impl.go +++ b/internal/db/highgo_impl.go @@ -103,6 +103,7 @@ func (h *HighGoDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "highgo") h.conn = db h.pingTimeout = getConnectTimeout(attempt) if err := h.Ping(); err != nil { diff --git a/internal/db/iris_impl.go b/internal/db/iris_impl.go index 44ed8bb..1750565 100644 --- a/internal/db/iris_impl.go +++ b/internal/db/iris_impl.go @@ -141,9 +141,12 @@ func (i *IrisDB) Connect(config connection.ConnectionConfig) error { if err != nil { return fmt.Errorf("打开数据库连接失败:%w", err) } + configureSQLConnectionPool(db, "iris") i.conn = db i.pingTimeout = getConnectTimeout(runConfig) if err := i.Ping(); err != nil { + _ = db.Close() + i.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } cleanupOnFailure = false diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index 1daa776..44e78d9 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -157,6 +157,7 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "kingbase") k.conn = db k.pingTimeout = getConnectTimeout(attempt) if err := k.Ping(); err != nil { @@ -175,8 +176,9 @@ func (k *KingbaseDB) Connect(config connection.ConnectionConfig) error { // 将 search_path 参数拼入 DSN finalDSN := dsn + " search_path=" + quoteConnValue(searchPathStr) if finalDB, err := sql.Open("kingbase", finalDSN); err == nil { - k.pingTimeout = getConnectTimeout(attempt) + configureSQLConnectionPool(finalDB, "kingbase") finalDB.SetConnMaxLifetime(5 * time.Minute) + k.pingTimeout = getConnectTimeout(attempt) // 临时将 k.conn 指向 finalDB 来做 ping 测试 oldConn := k.conn diff --git a/internal/db/mariadb_impl.go b/internal/db/mariadb_impl.go index 508fddb..82987af 100644 --- a/internal/db/mariadb_impl.go +++ b/internal/db/mariadb_impl.go @@ -49,10 +49,13 @@ func (m *MariaDB) Connect(config connection.ConnectionConfig) error { if err != nil { return fmt.Errorf("打开数据库连接失败:%w", err) } + configureSQLConnectionPool(db, "mariadb") m.conn = db m.pingTimeout = getConnectTimeout(config) if err := m.Ping(); err != nil { + _ = db.Close() + m.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } return nil diff --git a/internal/db/mysql_connection_params_test.go b/internal/db/mysql_connection_params_test.go index bf7135a..4e36600 100644 --- a/internal/db/mysql_connection_params_test.go +++ b/internal/db/mysql_connection_params_test.go @@ -45,6 +45,21 @@ func parseMySQLDriverCharsetsForTest(t *testing.T, dsn string) []string { return charsets } +func TestConfigureSQLConnectionPoolCapsOpenConnections(t *testing.T) { + dbConn, err := sql.Open("mysql", "root@tcp(127.0.0.1:1)/test") + if err != nil { + t.Fatalf("sql.Open failed: %v", err) + } + defer dbConn.Close() + + configureSQLConnectionPool(dbConn, "mysql") + + stats := dbConn.Stats() + if stats.MaxOpenConnections != defaultSQLMaxOpenConns { + t.Fatalf("expected max open connections %d, got %d", defaultSQLMaxOpenConns, stats.MaxOpenConnections) + } +} + func TestMySQLDSN_MergesConnectionParamsWithDefaults(t *testing.T) { t.Parallel() diff --git a/internal/db/mysql_impl.go b/internal/db/mysql_impl.go index 2b4adf6..3a81a43 100644 --- a/internal/db/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -847,6 +847,7 @@ func (m *MySQLDB) Connect(config connection.ConnectionConfig) error { } continue } + configureSQLConnectionPool(db, candidateConfig.Type) timeout := getConnectTimeout(candidateConfig) ctx, cancel := utils.ContextWithTimeout(timeout) diff --git a/internal/db/oceanbase_impl.go b/internal/db/oceanbase_impl.go index 742f795..c1a647f 100644 --- a/internal/db/oceanbase_impl.go +++ b/internal/db/oceanbase_impl.go @@ -621,6 +621,7 @@ func (o *OceanBaseDB) connectOracleViaOBClient(config connection.ConnectionConfi errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err)) continue } + configureSQLConnectionPool(db, "oceanbase") timeout := getConnectTimeout(candidateConfig) ctx, cancel := utils.ContextWithTimeout(timeout) @@ -741,6 +742,7 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error { errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败:%v", address, err)) continue } + configureSQLConnectionPool(db, "oceanbase") timeout := getConnectTimeout(candidateConfig) ctx, cancel := utils.ContextWithTimeout(timeout) diff --git a/internal/db/oracle_impl.go b/internal/db/oracle_impl.go index 5373bdb..a988d25 100644 --- a/internal/db/oracle_impl.go +++ b/internal/db/oracle_impl.go @@ -161,6 +161,7 @@ func (o *OracleDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "oracle") o.conn = db o.pingTimeout = getConnectTimeout(attempt) if err := o.Ping(); err != nil { diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 30f5fac..9942ef7 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -159,6 +159,7 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("%s 数据库=%s 打开连接失败: %v", sslLabel, dbName, err)) continue } + configureSQLConnectionPool(dbConn, "postgres") p.conn = dbConn // Force verification @@ -604,6 +605,7 @@ func (p *PostgresDB) ensureSearchPath(baseDSN string) { newDB, err := sql.Open("postgres", newDSN) if err == nil { + configureSQLConnectionPool(newDB, "postgres") newDB.SetConnMaxLifetime(5 * time.Minute) oldConn := p.conn p.conn = newDB diff --git a/internal/db/sql_pool.go b/internal/db/sql_pool.go new file mode 100644 index 0000000..146ec2a --- /dev/null +++ b/internal/db/sql_pool.go @@ -0,0 +1,27 @@ +package db + +import ( + "database/sql" + "strings" + "time" +) + +const ( + defaultSQLMaxOpenConns = 4 + defaultSQLConnMaxLifetime = 30 * time.Minute + defaultSQLConnMaxIdleTime = 30 * time.Second +) + +func configureSQLConnectionPool(db *sql.DB, dbType string) { + if db == nil { + return + } + switch strings.ToLower(strings.TrimSpace(dbType)) { + case "sqlite", "duckdb": + return + } + db.SetMaxOpenConns(defaultSQLMaxOpenConns) + db.SetMaxIdleConns(0) + db.SetConnMaxIdleTime(defaultSQLConnMaxIdleTime) + db.SetConnMaxLifetime(defaultSQLConnMaxLifetime) +} diff --git a/internal/db/sqlserver_impl.go b/internal/db/sqlserver_impl.go index 429a488..07b1bd3 100644 --- a/internal/db/sqlserver_impl.go +++ b/internal/db/sqlserver_impl.go @@ -176,10 +176,13 @@ func (s *SqlServerDB) Connect(config connection.ConnectionConfig) error { if err != nil { return fmt.Errorf("打开数据库连接失败:%w", err) } + configureSQLConnectionPool(db, "sqlserver") s.conn = db s.pingTimeout = getConnectTimeout(config) if err := s.Ping(); err != nil { + _ = db.Close() + s.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } return nil diff --git a/internal/db/starrocks_impl.go b/internal/db/starrocks_impl.go index af2df6d..e65a131 100644 --- a/internal/db/starrocks_impl.go +++ b/internal/db/starrocks_impl.go @@ -270,6 +270,7 @@ func (s *StarRocksDB) Connect(config connection.ConnectionConfig) error { errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err)) continue } + configureSQLConnectionPool(db, "starrocks") timeout := getConnectTimeout(candidateConfig) ctx, cancel := utils.ContextWithTimeout(timeout) diff --git a/internal/db/tdengine_impl.go b/internal/db/tdengine_impl.go index 02270cb..b9f8d74 100644 --- a/internal/db/tdengine_impl.go +++ b/internal/db/tdengine_impl.go @@ -96,6 +96,7 @@ func (t *TDengineDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "tdengine") t.conn = db t.pingTimeout = getConnectTimeout(attempt) diff --git a/internal/db/vastbase_impl.go b/internal/db/vastbase_impl.go index a1ece0a..71cf70f 100644 --- a/internal/db/vastbase_impl.go +++ b/internal/db/vastbase_impl.go @@ -94,6 +94,7 @@ func (v *VastbaseDB) Connect(config connection.ConnectionConfig) error { failures = append(failures, fmt.Sprintf("第%d次连接打开失败: %v", idx+1, err)) continue } + configureSQLConnectionPool(db, "vastbase") v.conn = db v.pingTimeout = getConnectTimeout(attempt) if err := v.Ping(); err != nil { From 98965a56e163e7953f69fdf901af524228568e65 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:05:02 +0800 Subject: [PATCH 18/61] =?UTF-8?q?=F0=9F=90=9B=20fix(memory):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=A7=E6=95=B0=E6=8D=AE=E9=87=8F=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E8=BF=9B=E7=A8=8B=E5=86=85=E5=AD=98=E9=A3=99?= =?UTF-8?q?=E5=8D=87=E8=87=B3=2016G=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GC 策略:主进程与 driver-agent 启动时收紧 SetGCPercent 至 50 - 周期回收:scan_rows 与 callStreamQuery 每 5 万行触发 runtime.GC - 自适应限流:driver-agent 引入 GOMEMLIMIT 自适应策略,2GB 起步按 1GB 步长抬升至 8GB 上限 - 批次调优:流式批次由 256 行缩减至 64 行,降低 JSON 编解码瞬时峰值 --- cmd/optional-driver-agent/main.go | 20 +++- internal/db/memory_limit_autoscale.go | 117 +++++++++++++++++++ internal/db/memory_limit_autoscale_test.go | 129 +++++++++++++++++++++ internal/db/optional_driver_agent_impl.go | 18 +++ internal/db/scan_rows.go | 38 ++++-- main.go | 7 ++ 6 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 internal/db/memory_limit_autoscale.go create mode 100644 internal/db/memory_limit_autoscale_test.go diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go index c9081f0..063e6ca 100644 --- a/cmd/optional-driver-agent/main.go +++ b/cmd/optional-driver-agent/main.go @@ -67,7 +67,11 @@ const ( agentChunkColumns = "columns" agentChunkRows = "rows" agentChunkDone = "done" - agentStreamBatchSize = 256 + // agentStreamBatchSize 控制 driver-agent 向主进程发送 row chunk 的批次大小。 + // 调小到 64:单批 JSON 编码 + 主进程解码的瞬时内存峰值降为原来的 1/4, + // 代价是 IPC 次数变为 4 倍,但每批仅一次 stdin/stdout 行读写,整体影响可忽略。 + // 重要:减小批次不能根除内存峰值,仍需配合 SetGCPercent + 周期 GC(见 main)。 + agentStreamBatchSize = 64 agentMemoryTrimRowsThreshold = 100000 agentMemoryTrimMinInterval = 3 * time.Second ) @@ -98,6 +102,20 @@ func main() { os.Exit(2) } + // driver-agent 是独立进程,主进程无法控制其 GC 行为。 + // 大结果集(88W+ 行)通过 JSON-lines 跨进程传输时,每行有 5-8 倍内存副本; + // Go 默认 GOGC=100 + Windows MADV_FREE 不归还 RSS,会导致 driver-agent 进程 + // 内存峰值达到数据总量的 10+ 倍(用户实测 88W 普通业务表撑到 8G+)。 + // + // GC 策略组合: + // - SetGCPercent(50):堆增长 50% 即触发 GC,比默认 100 更早收敛 + // - InitMemorySoftLimit:起始 2GB,运行时由 MaybeGrowMemoryLimit 自适应抬升到最多 8GB + // (起步保守 + 按需扩张,避免静态 2GB 限制在大表场景触发 GC 硬模式降速 15-25%) + // + // 代价:CPU 开销增加约 5-10%。导出场景是 I/O 密集型,可忽略。 + debug.SetGCPercent(50) + db.InitMemorySoftLimit(db.MemorySoftLimitInitialBytes) + scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 16<<10), 8<<20) writer := bufio.NewWriter(os.Stdout) diff --git a/internal/db/memory_limit_autoscale.go b/internal/db/memory_limit_autoscale.go new file mode 100644 index 0000000..8edcdc2 --- /dev/null +++ b/internal/db/memory_limit_autoscale.go @@ -0,0 +1,117 @@ +package db + +import ( + "runtime" + "runtime/debug" + "sync/atomic" + + "GoNavi-Wails/internal/logger" +) + +// 本文件实现 driver-agent 进程的 GOMEMLIMIT 自适应策略。 +// +// 背景:driver-agent 是独立子进程,主进程无法控制其内存。 +// 静态 limit(如固定 2GB)在大结果集场景下会触发 GC 硬模式,导出速度降 15-25%; +// 而 limit 设太大又失去约束意义。 +// +// 策略:起步保守(2GB),运行时监控 HeapAlloc,逼近当前 limit 时按 1GB 步长抬升, +// 上限 8GB 防止无限制增长。配合 SetGCPercent(50) + 周期 GC,正常场景下稳态堆 +// 仅几百 MB,limit 不会被触发;只有 GC 真的跟不上时才抬升。 + +const ( + // MemorySoftLimitInitialBytes 是进程启动时的默认 soft limit。 + // 2GB 覆盖绝大多数导出场景的稳态堆需求。 + MemorySoftLimitInitialBytes int64 = 2 * 1024 * 1024 * 1024 + + // MemorySoftLimitMaxBytes 是自适应抬升的绝对上限。 + // 8GB 防止失控;用户机器内存 < 16GB 时也留有余量给主进程和系统。 + MemorySoftLimitMaxBytes int64 = 8 * 1024 * 1024 * 1024 + + // MemorySoftLimitStepBytes 是每次抬升的步长。 + // 1GB 粒度足够平滑(不会一次跳太多),又不会频繁触发(HeapAlloc 1GB 量级才需要再抬)。 + MemorySoftLimitStepBytes int64 = 1 * 1024 * 1024 * 1024 + + // MemoryAutoscaleTriggerPercent 控制 HeapAlloc 达到当前 limit 的多少百分比时触发抬升。 + // 80% 留出 20% 缓冲,避免 GC 噪声导致频繁抖动抬升。 + MemoryAutoscaleTriggerPercent = 80 +) + +// currentMemorySoftLimit 记录当前已应用的 soft limit。 +// atomic 以便 MaybeGrowMemoryLimit 在并发流式查询中安全调用。 +var currentMemorySoftLimit atomic.Int64 + +// InitMemorySoftLimit 在进程启动时调用,应用初始 soft limit。 +// 重复调用安全:以最后一次为准。 +func InitMemorySoftLimit(initial int64) { + if initial <= 0 { + initial = MemorySoftLimitInitialBytes + } + if initial > MemorySoftLimitMaxBytes { + initial = MemorySoftLimitMaxBytes + } + debug.SetMemoryLimit(initial) + currentMemorySoftLimit.Store(initial) +} + +// CurrentMemorySoftLimit 返回当前已应用的 soft limit,主要供测试和监控使用。 +func CurrentMemorySoftLimit() int64 { + return currentMemorySoftLimit.Load() +} + +// MaybeGrowMemoryLimit 在大结果集流式处理时周期性调用(建议与周期 GC 同节奏), +// 当堆用量达到当前 limit 的 MemoryAutoscaleTriggerPercent 时按步长抬升。 +// +// 设计要点: +// - 仅对调用过 InitMemorySoftLimit 的进程生效(driver-agent);主进程未初始化时 currentMemorySoftLimit=0, +// 本函数直接返回,不影响主进程的 GC 行为 +// - 读 HeapAlloc 用 runtime.ReadMemStats(短暂 STW,每 5W 行一次可忽略) +// - 抬升通过 debug.SetMemoryLimit 应用,原子记录新值 +// - 达到 MemorySoftLimitMaxBytes 后不再抬升,让 GC 硬模式接管 +// - 不做"降级":进程 long-running,下次任务可能同样需要;soft limit 大不浪费内存 +// +// 返回 true 表示触发了抬升(用于日志观测)。 +func MaybeGrowMemoryLimit() bool { + current := currentMemorySoftLimit.Load() + if current <= 0 { + // 进程未启用 soft limit(如主进程),跳过自适应 + return false + } + + grown, next := shouldGrowMemoryLimit(current, readHeapAlloc()) + if !grown { + return false + } + + currentHeap := readHeapAlloc() + debug.SetMemoryLimit(next) + currentMemorySoftLimit.Store(next) + logger.Infof("内存 soft limit 自适应抬升:%dMB → %dMB(HeapAlloc=%dMB)", + current/1024/1024, next/1024/1024, currentHeap/1024/1024) + return true +} + +// shouldGrowMemoryLimit 是 MaybeGrowMemoryLimit 的纯逻辑核心,便于单元测试。 +// 输入:当前 limit、当前 HeapAlloc;输出:是否抬升、抬升后的新 limit。 +func shouldGrowMemoryLimit(currentLimit, heapAlloc int64) (bool, int64) { + if currentLimit >= MemorySoftLimitMaxBytes { + return false, currentLimit + } + if heapAlloc < currentLimit*MemoryAutoscaleTriggerPercent/100 { + return false, currentLimit + } + next := currentLimit + MemorySoftLimitStepBytes + if next > MemorySoftLimitMaxBytes { + next = MemorySoftLimitMaxBytes + } + if next == currentLimit { + return false, currentLimit + } + return true, next +} + +// readHeapAlloc 封装 runtime.ReadMemStats,便于测试 mock。 +func readHeapAlloc() int64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return int64(m.HeapAlloc) +} diff --git a/internal/db/memory_limit_autoscale_test.go b/internal/db/memory_limit_autoscale_test.go new file mode 100644 index 0000000..11c1d6d --- /dev/null +++ b/internal/db/memory_limit_autoscale_test.go @@ -0,0 +1,129 @@ +package db + +import ( + "testing" +) + +func TestShouldGrowMemoryLimit_NoActionWhenBelowThreshold(t *testing.T) { + current := int64(2 * 1024 * 1024 * 1024) // 2GB + // HeapAlloc 仅占 50%,远低于 80% 阈值 + heapAlloc := current * 50 / 100 + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if grown { + t.Fatalf("HeapAlloc=%dB 低于 80%% 阈值,不应抬升", heapAlloc) + } + if next != current { + t.Fatalf("未抬升时 next 应等于 current,got=%d want=%d", next, current) + } +} + +func TestShouldGrowMemoryLimit_NoActionAtExactThreshold(t *testing.T) { + current := int64(2 * 1024 * 1024 * 1024) + // HeapAlloc 正好等于 80% 阈值:heapAlloc < current*80/100 为假时才抬升 + // current*80/100 = 1.6GB;heapAlloc = 1.6GB 时 heapAlloc < 1.6GB 为假 → 抬升 + heapAlloc := current * MemoryAutoscaleTriggerPercent / 100 + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if !grown { + t.Fatalf("HeapAlloc=%dB 已达 80%% 阈值,应抬升", heapAlloc) + } + wantNext := current + MemorySoftLimitStepBytes + if next != wantNext { + t.Fatalf("抬升步长错误:got=%d want=%d", next, wantNext) + } +} + +func TestShouldGrowMemoryLimit_StepByGB(t *testing.T) { + current := int64(2 * 1024 * 1024 * 1024) // 2GB + heapAlloc := int64(3 * 1024 * 1024 * 1024) // 3GB > 2GB * 80% = 1.6GB + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if !grown { + t.Fatalf("HeapAlloc=%dB 超过 80%% 阈值,应抬升", heapAlloc) + } + wantNext := int64(3 * 1024 * 1024 * 1024) // 2GB + 1GB step = 3GB + if next != wantNext { + t.Fatalf("抬升后 limit 应为 3GB,got=%d want=%d", next, wantNext) + } +} + +func TestShouldGrowMemoryLimit_CapAtMax(t *testing.T) { + // 当前 limit 已等于上限 + current := MemorySoftLimitMaxBytes + heapAlloc := current * 2 // 即使 HeapAlloc 远超 limit 也不再抬升 + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if grown { + t.Fatalf("已达上限 %dB,不应再抬升", MemorySoftLimitMaxBytes) + } + if next != current { + t.Fatalf("已达上限时 next 应等于 current,got=%d want=%d", next, current) + } +} + +func TestShouldGrowMemoryLimit_CapWhenStepExceedsMax(t *testing.T) { + // 当前 limit 距上限不足 1GB 步长:7.5GB + current := MemorySoftLimitMaxBytes - 512*1024*1024 // 7.5GB + heapAlloc := current + 1 // 超过 80% 阈值 + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if !grown { + t.Fatalf("HeapAlloc 已逼近 limit,应触发抬升(即便步长会触及上限)") + } + if next != MemorySoftLimitMaxBytes { + t.Fatalf("抬升后应 cap 在 max,got=%d want=%d", next, MemorySoftLimitMaxBytes) + } +} + +func TestShouldGrowMemoryLimit_NoActionWhenCurrentExceedsMax(t *testing.T) { + // 异常情况:current > max(理论不会发生,但应防御性处理) + current := MemorySoftLimitMaxBytes + 1 + heapAlloc := current * 2 + + grown, next := shouldGrowMemoryLimit(current, heapAlloc) + if grown { + t.Fatalf("current 已超过 max,不应再抬升") + } + if next != current { + t.Fatalf("next 应等于 current,got=%d want=%d", next, current) + } +} + +func TestInitMemorySoftLimit_ClampToMax(t *testing.T) { + // 初始化值超过 max 时应被截断 + overMax := MemorySoftLimitMaxBytes * 2 + InitMemorySoftLimit(overMax) + if got := CurrentMemorySoftLimit(); got != MemorySoftLimitMaxBytes { + t.Fatalf("初始化超过 max 应被截断:got=%d want=%d", got, MemorySoftLimitMaxBytes) + } + // 恢复默认值,避免污染其他测试 + InitMemorySoftLimit(MemorySoftLimitInitialBytes) +} + +func TestInitMemorySoftLimit_DefaultWhenZeroOrNegative(t *testing.T) { + InitMemorySoftLimit(0) + if got := CurrentMemorySoftLimit(); got != MemorySoftLimitInitialBytes { + t.Fatalf("initial=0 应使用默认值:got=%d want=%d", got, MemorySoftLimitInitialBytes) + } + InitMemorySoftLimit(-1) + if got := CurrentMemorySoftLimit(); got != MemorySoftLimitInitialBytes { + t.Fatalf("initial<0 应使用默认值:got=%d want=%d", got, MemorySoftLimitInitialBytes) + } +} + +func TestMaybeGrowMemoryLimit_NoOpWhenUninitialized(t *testing.T) { + // 模拟主进程未初始化的场景: + // 通过将 currentMemorySoftLimit 直接置零(绕过 InitMemorySoftLimit)来测试 + // 注意:这是一个破坏性测试,需在测试末尾恢复状态 + saved := currentMemorySoftLimit.Load() + defer currentMemorySoftLimit.Store(saved) + + currentMemorySoftLimit.Store(0) + if MaybeGrowMemoryLimit() { + t.Fatalf("currentMemorySoftLimit=0 时应直接返回 false,不主动初始化") + } + if got := CurrentMemorySoftLimit(); got != 0 { + t.Fatalf("未初始化时不应被 MaybeGrowMemoryLimit 改写,got=%d want=0", got) + } +} diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index d9362d8..3ec52b8 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -42,6 +42,13 @@ const ( optionalAgentMethodApplyChanges = "applyChanges" optionalAgentDefaultScannerMaxBytes = 8 << 20 optionalAgentMetadataProbeTimeout = 5 * time.Second + // callStreamQueryGCInterval 控制 callStreamQuery 每接收多少行 driver-agent 数据触发一次 runtime.GC。 + // + // 该路径不走 sql.Rows(scan_rows.go 的周期 GC 覆盖不到),但每个 chunk 解码 + // [][]interface{} + normalizeQueryValue 转换会产生大量临时字符串,需要主动回收。 + // 取 50000 与 scan_rows.go 的 streamRowsPeriodicGCInterval 保持一致, + // 让两端在相近节奏下分别 GC,避免内存峰值叠加。 + callStreamQueryGCInterval = 50000 ) const ( @@ -305,6 +312,12 @@ func (c *optionalDriverAgentClient) callStreamQuery(req optionalAgentRequest, co var columns []string valueConsumer, useValueConsumer := consumer.(QueryStreamValueConsumer) + // processedRows 用于周期性触发 GC。 + // 该路径不走 sql.Rows,scan_rows.go 的周期 GC 覆盖不到。 + // 每个 chunk 解码会分配 [][]interface{} + normalizeQueryValue 转换副本, + // 88W 行场景下不主动 GC 会让主进程 RSS 单调爬升。 + var processedRows int64 + for { line, err := c.reader.ReadBytes('\n') if err != nil { @@ -360,6 +373,11 @@ func (c *optionalDriverAgentClient) callStreamQuery(req optionalAgentRequest, co return err } } + processedRows += int64(len(rows)) + if processedRows >= callStreamQueryGCInterval { + runtime.GC() + processedRows = 0 + } case optionalAgentChunkDone: return nil default: diff --git a/internal/db/scan_rows.go b/internal/db/scan_rows.go index 3277fcf..730f666 100644 --- a/internal/db/scan_rows.go +++ b/internal/db/scan_rows.go @@ -3,10 +3,21 @@ package db import ( "database/sql" "fmt" + "runtime" "GoNavi-Wails/internal/connection" ) +// streamRowsPeriodicGCInterval 控制 streamRowsForDialect 每处理多少行主动触发一次 runtime.GC。 +// +// 背景:大结果集(88W+ 行)流式扫描时,每行 scanner 会分配 []interface{} 和 map[string]interface{}, +// Go 默认 GOGC=100 下堆翻倍才触发 GC,瞬时峰值可达数据总量 5-8 倍。 +// 这里周期性主动 GC,让内存在扫描过程中及时回收,避免 RSS 单调爬升。 +// +// 取值 50000:每 5W 行触发一次 GC,对 88W 行导出场景约触发 18 次,CPU 开销可忽略; +// 同时保证单次 GC 之间累积的临时对象不超过几百 MB,避免 GC 间隙堆膨胀。 +const streamRowsPeriodicGCInterval = 50000 + func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) { return scanRowsForDialect(rows, "") } @@ -75,6 +86,11 @@ func streamRowsForDialect(rows *sql.Rows, dialect string, consumer QueryStreamCo } valueConsumer, useValueConsumer := consumer.(QueryStreamValueConsumer) + // processedRows 用于周期性触发 GC,见 streamRowsPeriodicGCInterval 注释。 + // 注意:此路径同时被 driver-agent 进程(OceanBase 等 optional driver)和 + // 主进程的 in-process 流式查询调用,所以一处加 GC 即可覆盖两端。 + var processedRows int64 + for rows.Next() { if useValueConsumer { values, err := scanner.scanCurrentRowValues(rows) @@ -84,14 +100,22 @@ func streamRowsForDialect(rows *sql.Rows, dialect string, consumer QueryStreamCo if err := valueConsumer.ConsumeRowValues(values); err != nil { return err } - continue + } else { + entry, err := scanner.scanCurrentRow(rows) + if err != nil { + continue + } + if err := consumer.ConsumeRow(entry); err != nil { + return err + } } - entry, err := scanner.scanCurrentRow(rows) - if err != nil { - continue - } - if err := consumer.ConsumeRow(entry); err != nil { - return err + + processedRows++ + if processedRows%streamRowsPeriodicGCInterval == 0 { + runtime.GC() + // 自适应抬升 driver-agent 进程的内存 soft limit。 + // 主进程未启用 soft limit(未调 InitMemorySoftLimit),此调用是 no-op。 + MaybeGrowMemoryLimit() } } diff --git a/main.go b/main.go index 8daf28f..6b2fac4 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "runtime/debug" "strings" aiservice "GoNavi-Wails/internal/ai/service" @@ -19,6 +20,12 @@ import ( ) func main() { + // 大结果集导出(88W+ 行)时,JSON 编解码会产生 5-8 倍内存副本, + // Go 默认 GOGC=100 下堆翻倍才触发 GC,叠加 Windows MADV_FREE 不归还 RSS, + // 会导致 RSS 单调爬升到峰值后不下降。这里收紧到 50,让 GC 更早触发。 + // 代价是 CPU 开销略增,但导出/导入场景属 I/O 密集型,GC 开销可忽略。 + debug.SetGCPercent(50) + if runSpecialMode(os.Args[1:]) { return } From 542bafe6c43a0ba7fd6badee122ff3080997d861 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:07:33 +0800 Subject: [PATCH 19/61] =?UTF-8?q?=F0=9F=94=A7=20chore(gitignore):=20?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=20optional-driver-agent=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 2810a07..bdc4acd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ dist/ .gemini-clipboard GoNavi-Wails GoNavi-Wails.exe +optional-driver-agent +optional-driver-agent.exe .ace-tool/ .superpowers/ .claude/ From b997788437075b8b7f25fef9db57e03c9462f338 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:30:56 +0800 Subject: [PATCH 20/61] =?UTF-8?q?=E2=9C=A8=20feat(explain):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20SQL=20=E8=AF=8A=E6=96=AD=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20EXPLAIN=20=E5=9F=BA=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据结构:新增 ExplainResult/Node/Stats/IndexSuggestion/DiagnoseReport 等归一化模型,跨方言通用 - 接口扩展:Database 接口新增 ExplainExecer 可选能力,支持驱动自带 EXPLAIN 实现 - 核心入口:DiagnoseQuery 支持 SELECT/WITH 白名单校验、方言调度、原生与 fallback 两条执行路径 - 方言适配:buildExplainQuery 覆盖 MySQL/PostgreSQL/SQLite/Oracle/SQLServer/ClickHouse 7 大主流 - 解析器:MySQL FORMAT=JSON 含表格 fallback、PostgreSQL ANALYZE BUFFERS JSON、SQLite EQP 层级解析 - 测试覆盖:新增 27 个单元测试覆盖 SQL 构造与三方言解析器 --- internal/app/explain_parse_common.go | 164 +++++++++ internal/app/explain_parse_mysql.go | 332 ++++++++++++++++++ internal/app/explain_parse_mysql_test.go | 210 ++++++++++++ internal/app/explain_parse_postgres.go | 241 +++++++++++++ internal/app/explain_parse_postgres_test.go | 194 +++++++++++ internal/app/explain_parse_sqlite.go | 241 +++++++++++++ internal/app/explain_parse_sqlite_test.go | 138 ++++++++ internal/app/methods_explain.go | 353 ++++++++++++++++++++ internal/app/methods_explain_test.go | 144 ++++++++ internal/connection/explain.go | 139 ++++++++ internal/db/database.go | 20 ++ 11 files changed, 2176 insertions(+) create mode 100644 internal/app/explain_parse_common.go create mode 100644 internal/app/explain_parse_mysql.go create mode 100644 internal/app/explain_parse_mysql_test.go create mode 100644 internal/app/explain_parse_postgres.go create mode 100644 internal/app/explain_parse_postgres_test.go create mode 100644 internal/app/explain_parse_sqlite.go create mode 100644 internal/app/explain_parse_sqlite_test.go create mode 100644 internal/app/methods_explain.go create mode 100644 internal/app/methods_explain_test.go create mode 100644 internal/connection/explain.go diff --git a/internal/app/explain_parse_common.go b/internal/app/explain_parse_common.go new file mode 100644 index 0000000..b52646f --- /dev/null +++ b/internal/app/explain_parse_common.go @@ -0,0 +1,164 @@ +package app + +import ( + "fmt" + "strings" + "sync/atomic" + + "GoNavi-Wails/internal/connection" +) + +// SQL 诊断工作台:方言解析器公共工具。 +// +// 本文件只放跨方言共享的辅助函数;每方言解析器在 explain_parse_.go。 + +// explainNodeIDCounter 是单次解析内的递增节点 ID 生成器。 +// 通过 resetExplainNodeID() 在解析开始时归零;并发安全(同一 query 串行解析)。 +var explainNodeIDCounter uint64 + +func resetExplainNodeID() { + atomic.StoreUint64(&explainNodeIDCounter, 0) +} + +// nextExplainNodeID 返回下一个节点 ID("n1"、"n2"……)。 +func nextExplainNodeID() string { + id := atomic.AddUint64(&explainNodeIDCounter, 1) + return fmt.Sprintf("n%d", id) +} + +// appendExplainChild 把子节点追加到 result.Nodes,并生成对应的 ExplainEdge。 +// parentID 为空时不生成 Edge(根节点)。 +func appendExplainChild(result *connection.ExplainResult, parentID string, node connection.ExplainNode) (nodeID string) { + if node.ID == "" { + node.ID = nextExplainNodeID() + } + if parentID != "" { + node.ParentID = parentID + result.Edges = append(result.Edges, connection.ExplainEdge{From: parentID, To: node.ID}) + } + result.Nodes = append(result.Nodes, node) + return node.ID +} + +// finalizeExplainStats 遍历所有节点,计算聚合统计并写入 Stats 字段。 +// 在解析器返回前调用。 +// +// 注意:TotalDurationMs 在 PG/MySQL 8.0 中由解析器直接从 Execution Time 写入, +// 这里只在解析器未设置时(=0)才用节点累加值兜底,避免覆盖更精确的实例值。 +func finalizeExplainStats(result *connection.ExplainResult) { + if result == nil || len(result.Nodes) == 0 { + return + } + var totalCost, accumulatedDuration float64 + var rowsRead, maxRows int64 + var bufferHitSum float64 + var bufferHitCount int + for _, n := range result.Nodes { + if n.Cost > 0 { + totalCost += n.Cost + } + if n.DurationMs > 0 { + accumulatedDuration += n.DurationMs + } + if n.OpType == connection.ExplainOpScan || n.OpType == connection.ExplainOpIndexScan || n.OpType == connection.ExplainOpIndexOnly { + rowsRead += n.EstRows + } + if n.EstRows > maxRows { + maxRows = n.EstRows + } + if n.BufferHit > 0 { + bufferHitSum += n.BufferHit + bufferHitCount++ + } + for _, flag := range n.Flags { + switch flag { + case connection.ExplainFlagFullScan: + result.Stats.HasFullScan = true + case connection.ExplainFlagFilesort: + result.Stats.HasFilesort = true + case connection.ExplainFlagTempTable: + result.Stats.HasTempTable = true + } + } + } + result.Stats.TotalCost = totalCost + if result.Stats.TotalDurationMs == 0 && accumulatedDuration > 0 { + result.Stats.TotalDurationMs = accumulatedDuration + } + result.Stats.RowsRead = rowsRead + result.Stats.MaxEstRows = maxRows + if bufferHitCount > 0 { + result.Stats.BufferHitRate = bufferHitSum / float64(bufferHitCount) + } +} + +// parseExplainTSVRows 把 collectExplainRaw 生成的 TSV 原文重新切分为行(每行 []string 按列拆)。 +// 第一行视为列头;空行跳过。 +func parseExplainTSVRows(raw string) (header []string, rows [][]string) { + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) == 0 { + return nil, nil + } + header = strings.Split(lines[0], "\t") + for i := 1; i < len(lines); i++ { + line := strings.TrimRight(lines[i], "\r") + if strings.TrimSpace(line) == "" { + continue + } + rows = append(rows, strings.Split(line, "\t")) + } + return header, rows +} + +// lookupTSVColumn 在 header 中按列名查找索引(大小写不敏感);未找到返回 -1。 +func lookupTSVColumn(header []string, names ...string) int { + if len(header) == 0 || len(names) == 0 { + return -1 + } + for _, name := range names { + target := strings.ToLower(strings.TrimSpace(name)) + if target == "" { + continue + } + for i, h := range header { + if strings.ToLower(strings.TrimSpace(h)) == target { + return i + } + } + } + return -1 +} + +// parseExplainInt64 容错地把字符串解析为 int64(空/非法返回 0)。 +func parseExplainInt64(s string) int64 { + s = strings.TrimSpace(s) + if s == "" || s == "NULL" || s == "" || s == "null" { + return 0 + } + var n int64 + for _, ch := range s { + if ch < '0' || ch > '9' { + if ch == '-' || ch == '+' { + continue + } + break + } + n = n*10 + int64(ch-'0') + } + return n +} + +// parseExplainFloat64 容错地把字符串解析为 float64(空/非法返回 0)。 +// 支持形如 "100.00"、"1.5e3" 的简单浮点格式。 +func parseExplainFloat64(s string) float64 { + s = strings.TrimSpace(s) + if s == "" || s == "NULL" || s == "" || s == "null" { + return 0 + } + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + if err != nil { + return 0 + } + return f +} diff --git a/internal/app/explain_parse_mysql.go b/internal/app/explain_parse_mysql.go new file mode 100644 index 0000000..bd7a969 --- /dev/null +++ b/internal/app/explain_parse_mysql.go @@ -0,0 +1,332 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// MySQL FORMAT=JSON 解析。 +// +// 典型结构(8.0+): +// +// { +// "query_block": { +// "select_id": 1, +// "cost_info": {"query_cost": "100.00"}, +// "table": { ... }, // 单表 +// "nested_loop": [{"table": {...}}], // 多表 JOIN +// "ordering_operation": { ... }, // ORDER BY 包装 +// "grouping_operation": { ... }, // GROUP BY 包装 +// "duplicates_removal": { ... } +// } +// } +// +// 单个 table 节点字段: +// - table_name / alias +// - access_type:system/const/eq_ref/ref/range/index/ALL +// - rows_examined_per_scan / rows_produced_per_join / filtered +// - possible_keys / key / used_key_parts / key_length +// - attached_condition / used_columns +// +// OceanBase MySQL 协议输出与 MySQL 8.0 几乎一致(可能多 range_info 列)。 +// +// 5.7 不支持 FORMAT=JSON 时走 vanilla EXPLAIN,返回 8 列表格:id/select_type/table/type/ +// possible_keys/key/key_len/ref/rows/Extra(OceanBase 可能多 range_info),由 parseMySQLTableExplain 处理。 + +func parseMySQLExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("MySQL EXPLAIN 返回空内容") + } + + // FORMAT=JSON 模式 + if format == connection.ExplainFormatJSON || strings.HasPrefix(trimmed, "{") { + plan, warnings, err := parseMySQLJSONExplain(trimmed) + if err != nil { + // JSON 解析失败但确实是 JSON 开头:报错让上层决定降级 + return result, fmt.Errorf("解析 MySQL FORMAT=JSON 失败:%w", err) + } + result.Nodes = plan.Nodes + result.Edges = plan.Edges + result.Warnings = warnings + result.RawFormat = connection.ExplainFormatJSON + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil + } + + // 表格模式(5.7 fallback 或 Doris/StarRocks) + parsed, err := parseMySQLTableExplain(raw) + if err != nil { + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = []string{fmt.Sprintf("表格解析失败:%v;保留原文供调试", err)} + return result, nil + } + result.Nodes = parsed.Nodes + result.Edges = parsed.Edges + result.RawFormat = connection.ExplainFormatTable + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil +} + +// mysqlQueryBlock 对应 MySQL FORMAT=JSON 顶层 query_block。 +type mysqlQueryBlock struct { + SelectID json.Number `json:"select_id"` + CostInfo map[string]string `json:"cost_info"` + Table *mysqlTableNode `json:"table"` + NestedLoop []map[string]json.RawMessage `json:"nested_loop"` + OrderingOperation *map[string]any `json:"ordering_operation"` + GroupingOperation *map[string]any `json:"grouping_operation"` + DuplicatesRemoval *map[string]any `json:"duplicates_removal"` + Windowing *map[string]any `json:"windowing"` + Distinct *map[string]any `json:"distinct"` + Message string `json:"message"` +} + +type mysqlTableNode struct { + TableName string `json:"table_name"` + Alias string `json:"alias"` + AccessType string `json:"access_type"` + RowsExaminedPerScan json.Number `json:"rows_examined_per_scan"` + RowsProducedPerJoin json.Number `json:"rows_produced_per_join"` + Filtered string `json:"filtered"` + PossibleKeys []string `json:"possible_keys"` + Key string `json:"key"` + UsedKeyParts []string `json:"used_key_parts"` + KeyLength json.Number `json:"key_length"` + Ref []string `json:"ref"` + RowsExaminedPerJoin json.Number `json:"rows_examined_per_join"` + CostInfo map[string]string `json:"cost_info"` + AttachedCondition string `json:"attached_condition"` + AttachedSubqueries []map[string]any `json:"attached_subqueries"` + UsingIntersection []map[string]any `json:"using_intersect"` + Message string `json:"message"` +} + +// parseMySQLJSONExplain 递归解析 MySQL FORMAT=JSON 输出。 +// 返回扁平的节点列表 + 解析过程中的警告(用于前端提示不识别的字段)。 +func parseMySQLJSONExplain(raw string) (*connection.ExplainResult, []string, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &top); err != nil { + return nil, nil, fmt.Errorf("顶层 JSON 解析失败:%w", err) + } + + result := &connection.ExplainResult{} + var warnings []string + + qbRaw, ok := top["query_block"] + if !ok { + return nil, nil, fmt.Errorf("缺少 query_block 字段") + } + + // query_block 总成本 + var qb map[string]json.RawMessage + if err := json.Unmarshal(qbRaw, &qb); err != nil { + return nil, nil, fmt.Errorf("query_block 解析失败:%w", err) + } + if costRaw, ok := qb["cost_info"]; ok { + var ci map[string]string + if err := json.Unmarshal(costRaw, &ci); err == nil { + result.Stats.TotalCost = parseExplainFloat64(ci["query_cost"]) + } + } + + // 递归 query_block(可能套 ordering/grouping/distinct 等操作层) + parseMySQLQueryBlock(qbRaw, "", result, &warnings) + + return result, warnings, nil +} + +// parseMySQLQueryBlock 递归解析 query_block 内部结构。 +// MySQL FORMAT=JSON 是深度嵌套的"操作层"结构,每层可能包含 table、nested_loop、ordering_operation 等。 +func parseMySQLQueryBlock(qbRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) { + var qb mysqlQueryBlock + if err := json.Unmarshal(qbRaw, &qb); err != nil { + *warnings = append(*warnings, fmt.Sprintf("query_block JSON 反序列化失败:%v", err)) + return + } + + // 单表:直接挂一个 table 节点 + if qb.Table != nil { + node := buildMySQLTableNode(qb.Table) + appendExplainChild(result, parentID, node) + } + + // nested_loop:每个元素含 table,作为 parent 的子节点 + for _, item := range qb.NestedLoop { + if tableRaw, ok := item["table"]; ok { + var t mysqlTableNode + if err := json.Unmarshal(tableRaw, &t); err == nil { + node := buildMySQLTableNode(&t) + appendExplainChild(result, parentID, node) + } + } + } + + // 递归操作层:ordering_operation / grouping_operation / duplicates_removal / windowing + type opLayer struct { + raw json.RawMessage + opType string + } + layers := []opLayer{} + if qb.OrderingOperation != nil { + // 反向取原始 JSON(结构体已 unmarshal,但用 raw 更通用) + } + // 直接遍历原始 qb map 更省事 + var qbMap map[string]json.RawMessage + _ = json.Unmarshal(qbRaw, &qbMap) + for key, val := range qbMap { + switch key { + case "ordering_operation": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpSort}) + case "grouping_operation": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate}) + case "duplicates_removal": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpOther}) + case "windowing": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpWindow}) + case "distinct": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate}) + } + } + for _, layer := range layers { + // 操作层本身作为一个节点(供前端展示层次) + layerNode := connection.ExplainNode{ + OpType: layer.opType, + OpDetail: strings.Title(strings.ReplaceAll(layer.opType, "_", " ")), + } + layerID := appendExplainChild(result, parentID, layerNode) + // 递归:操作层可能含 table、nested_loop、子操作层 + parseMySQLQueryBlock(layer.raw, layerID, result, warnings) + } +} + +// buildMySQLTableNode 把 mysqlTableNode 转成归一化的 ExplainNode,并探测 Flags。 +func buildMySQLTableNode(t *mysqlTableNode) connection.ExplainNode { + node := connection.ExplainNode{ + OpType: classifyMySQLAccessType(t.AccessType), + OpDetail: fmt.Sprintf("access_type=%s", strings.ToLower(strings.TrimSpace(t.AccessType))), + Table: t.TableName, + Index: t.Key, + EstRows: parseExplainInt64(string(t.RowsExaminedPerScan)), + Cost: parseExplainFloat64(t.CostInfo["read_cost"]), + } + if t.Alias != "" && t.Alias != t.TableName { + node.Extra = map[string]any{"alias": t.Alias} + } + if t.AttachedCondition != "" { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["attachedCondition"] = t.AttachedCondition + } + if len(t.UsedKeyParts) > 0 { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["usedKeyParts"] = t.UsedKeyParts + } + // 探测 Flags + if node.OpType == connection.ExplainOpScan { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + return node +} + +// classifyMySQLAccessType 把 MySQL access_type 归一化到通用 OpType。 +// ALL → SCAN,range/eq_ref/ref/index → INDEX_SCAN 或 INDEX_ONLY,其他 → OTHER。 +func classifyMySQLAccessType(accessType string) string { + switch strings.ToLower(strings.TrimSpace(accessType)) { + case "all": + return connection.ExplainOpScan + case "index": + return connection.ExplainOpIndexOnly // 仅扫索引不回表 + case "range": + return connection.ExplainOpIndexScan + case "eq_ref", "ref", "ref_or_null", "unique_subquery", "index_subquery": + return connection.ExplainOpIndexScan + case "const", "system": + return connection.ExplainOpOther // 单行命中,性能极佳 + default: + return connection.ExplainOpOther + } +} + +// parseMySQLTableExplain 解析 MySQL 5.7 表格 / Doris / StarRocks 的 EXPLAIN 输出。 +// 标准 MySQL 表格列:id|select_type|table|type|possible_keys|key|key_len|ref|rows|Extra +// OceanBase 可能多 range_info;Doris/StarRocks 是完全不同的结构化文本(PR2 优化)。 +func parseMySQLTableExplain(raw string) (*connection.ExplainResult, error) { + header, rows := parseExplainTSVRows(raw) + if len(header) == 0 || len(rows) == 0 { + return nil, fmt.Errorf("MySQL 表格 EXPLAIN 无有效行") + } + + result := &connection.ExplainResult{} + colID := lookupTSVColumn(header, "id") + colType := lookupTSVColumn(header, "type") + colTable := lookupTSVColumn(header, "table") + colKey := lookupTSVColumn(header, "key") + colRows := lookupTSVColumn(header, "rows") + colExtra := lookupTSVColumn(header, "extra") + + // MySQL 的 id 字段表达父子:相同 id 是同一 SELECT 内的 join,id 不同代表子查询 + // 简化处理:每行作为独立节点,无父子(PR2 增强) + var lastID string + for _, row := range rows { + var idStr string + if colID >= 0 && colID < len(row) { + idStr = strings.TrimSpace(row[colID]) + } + if idStr == "" { + idStr = lastID + } + lastID = idStr + + var accessType string + if colType >= 0 && colType < len(row) { + accessType = strings.TrimSpace(row[colType]) + } + node := connection.ExplainNode{ + OpType: classifyMySQLAccessType(accessType), + OpDetail: fmt.Sprintf("id=%s type=%s", idStr, strings.ToLower(accessType)), + } + if colTable >= 0 && colTable < len(row) { + node.Table = strings.TrimSpace(row[colTable]) + } + if colKey >= 0 && colKey < len(row) { + node.Index = strings.TrimSpace(row[colKey]) + } + if colRows >= 0 && colRows < len(row) { + node.EstRows = parseExplainInt64(row[colRows]) + } + if colExtra >= 0 && colExtra < len(row) { + extra := strings.TrimSpace(row[colExtra]) + if extra != "" { + node.Extra = map[string]any{"extra": extra} + lower := strings.ToLower(extra) + if strings.Contains(lower, "using filesort") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } + if strings.Contains(lower, "using temporary") { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + } + } + if node.OpType == connection.ExplainOpScan { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + appendExplainChild(result, "", node) + } + return result, nil +} diff --git a/internal/app/explain_parse_mysql_test.go b/internal/app/explain_parse_mysql_test.go new file mode 100644 index 0000000..8674d30 --- /dev/null +++ b/internal/app/explain_parse_mysql_test.go @@ -0,0 +1,210 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// MySQL FORMAT=JSON fixture:单表全表扫描。 +const mySQLFormatJSONSingleTableFullScan = `{ + "query_block": { + "select_id": 1, + "cost_info": {"query_cost": "100.00"}, + "table": { + "table_name": "users", + "access_type": "ALL", + "rows_examined_per_scan": 10000, + "rows_produced_per_join": 1000, + "filtered": "10.00", + "cost_info": {"read_cost": "100.00"}, + "used_columns": ["id", "name", "email"] + } + } +}` + +func TestParseMySQLExplain_SingleTableFullScan(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLFormatJSONSingleTableFullScan, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpScan { + t.Fatalf("access_type=ALL 应归一化为 SCAN,got=%s", node.OpType) + } + if node.Table != "users" { + t.Fatalf("table got=%q want=users", node.Table) + } + if node.EstRows != 10000 { + t.Fatalf("EstRows got=%d want=10000", node.EstRows) + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("全表扫描节点应有 FULL_SCAN flag,got=%v", node.Flags) + } + if !containsFlag(node.Flags, connection.ExplainFlagNoIndex) { + t.Fatalf("全表扫描节点应有 NO_INDEX flag,got=%v", node.Flags) + } + if !result.Stats.HasFullScan { + t.Fatalf("Stats.HasFullScan 应为 true") + } + if result.Stats.TotalCost != 100.0 { + t.Fatalf("TotalCost got=%v want=100", result.Stats.TotalCost) + } + if result.Stats.RowsRead != 10000 { + t.Fatalf("RowsRead got=%d want=10000", result.Stats.RowsRead) + } +} + +// MySQL FORMAT=JSON fixture:两表 JOIN(一个走索引,一个走全表)。 +const mySQLFormatJSONJoinScanAndIndex = `{ + "query_block": { + "select_id": 1, + "cost_info": {"query_cost": "250.00"}, + "nested_loop": [ + { + "table": { + "table_name": "orders", + "access_type": "ALL", + "rows_examined_per_scan": 5000, + "cost_info": {"read_cost": "100.00"} + } + }, + { + "table": { + "table_name": "users", + "access_type": "eq_ref", + "possible_keys": ["PRIMARY"], + "key": "PRIMARY", + "used_key_parts": ["id"], + "rows_examined_per_scan": 1, + "cost_info": {"read_cost": "150.00"} + } + } + ] + } +}` + +func TestParseMySQLExplain_JoinScanAndIndex(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", mySQLFormatJSONJoinScanAndIndex, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点(nested_loop 内 2 个 table),got=%d", len(result.Nodes)) + } + if result.Nodes[0].Table != "orders" { + t.Fatalf("第一个表应是 orders,got=%s", result.Nodes[0].Table) + } + if result.Nodes[0].OpType != connection.ExplainOpScan { + t.Fatalf("orders access_type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType) + } + if result.Nodes[1].Table != "users" { + t.Fatalf("第二个表应是 users,got=%s", result.Nodes[1].Table) + } + if result.Nodes[1].OpType != connection.ExplainOpIndexScan { + t.Fatalf("users access_type=eq_ref 应为 INDEX_SCAN,got=%s", result.Nodes[1].OpType) + } + if result.Nodes[1].Index != "PRIMARY" { + t.Fatalf("users 使用 PRIMARY key,got=%s", result.Nodes[1].Index) + } + // stats:orders 估算 5000 行 + if result.Stats.RowsRead != 5000+1 { + t.Fatalf("RowsRead 应为两表 EstRows 之和 (5000+1),got=%d", result.Stats.RowsRead) + } +} + +// MySQL FORMAT=JSON fixture:含 ordering_operation 包装层。 +const mySQLFormatJSONWithOrder = `{ + "query_block": { + "select_id": 1, + "ordering_operation": { + "table": { + "table_name": "t", + "access_type": "ALL", + "rows_examined_per_scan": 100, + "cost_info": {"read_cost": "10.00"} + } + } + } +}` + +func TestParseMySQLExplain_WithOrderingOperation(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM t ORDER BY id", mySQLFormatJSONWithOrder, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 应该有 2 个节点:ordering 层 + table 层 + if len(result.Nodes) != 2 { + t.Fatalf("ordering_operation 应展开为 2 个节点,got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpSort { + t.Fatalf("ordering_operation 顶层节点应为 SORT,got=%s", result.Nodes[0].OpType) + } + if result.Nodes[1].OpType != connection.ExplainOpScan { + t.Fatalf("内层 table 应为 SCAN,got=%s", result.Nodes[1].OpType) + } + // 验证父子边 + if len(result.Edges) != 1 { + t.Fatalf("应有 1 条边,got=%d", len(result.Edges)) + } + if result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID { + t.Fatalf("边应连接 SORT -> SCAN") + } +} + +// MySQL 5.7 表格模式 fallback。 +const mySQLTableExplainOutput = `id select_type table type possible_keys key key_len ref rows Extra +1 SIMPLE users ALL NULL NULL NULL NULL 10000 Using where +1 SIMPLE orders ref idx_uid idx_uid 4 const 5 Using filesort` + +func TestParseMySQLExplain_TableFormatFallback(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLTableExplainOutput, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("表格解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpScan { + t.Fatalf("users type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType) + } + if !containsFlag(result.Nodes[0].Flags, connection.ExplainFlagFullScan) { + t.Fatalf("users 应有 FULL_SCAN flag") + } + if result.Nodes[1].Index != "idx_uid" { + t.Fatalf("orders 使用 idx_uid,got=%s", result.Nodes[1].Index) + } + if !containsFlag(result.Nodes[1].Flags, connection.ExplainFlagFilesort) { + t.Fatalf("orders Extra 含 Using filesort,应有 FILESORT flag") + } + if result.RawFormat != connection.ExplainFormatTable { + t.Fatalf("RawFormat got=%v want=table", result.RawFormat) + } +} + +func TestParseMySQLExplain_EmptyRawReturnsError(t *testing.T) { + _, err := parseMySQLExplain("mysql", "SELECT 1", " ", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("空输入应返回 error") + } +} + +func TestParseMySQLExplain_InvalidJSONReturnsError(t *testing.T) { + _, err := parseMySQLExplain("mysql", "SELECT 1", "{ this is not valid json", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("非法 JSON 应返回 error") + } +} + +// containsFlag 检查 flags 列表是否包含目标值。 +func containsFlag(flags []string, target string) bool { + for _, f := range flags { + if f == target { + return true + } + } + return false +} diff --git a/internal/app/explain_parse_postgres.go b/internal/app/explain_parse_postgres.go new file mode 100644 index 0000000..8224d9d --- /dev/null +++ b/internal/app/explain_parse_postgres.go @@ -0,0 +1,241 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// PostgreSQL EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 解析。 +// +// 典型结构(PG 13+): +// +// [ +// { +// "Plan": { +// "Node Type": "Seq Scan", +// "Relation Name": "t", +// "Alias": "t", +// "Startup Cost": 0.00, +// "Total Cost": 100.00, +// "Plan Rows": 1000, +// "Plan Width": 4, +// "Actual Startup Time": 0.01, +// "Actual Total Time": 1.23, +// "Actual Rows": 1000, +// "Actual Loops": 1, +// "Filter": "(id > 100)", +// "Rows Removed by Filter": 100, +// "Shared Hit Blocks": 50, +// "Shared Read Blocks": 0, +// "Plans": [...] // 递归子节点 +// }, +// "Planning Time": 0.15, +// "Execution Time": 1.30, +// "Triggers": [], +// "Execution Buffers": {...} +// } +// ] +// +// 多语句时数组可能有多个元素,但 EXPLAIN 单条 SQL 时通常是 1 个。 + +func parsePostgresExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("PostgreSQL EXPLAIN 返回空内容") + } + + if !strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "{") { + // 老版本 PG 无 FORMAT JSON 时返回文本表格——PR2 增强 + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = []string{"PostgreSQL 返回非 JSON 格式(可能未启用 FORMAT JSON),原文保留"} + return result, nil + } + + var top []map[string]json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &top); err != nil { + // 单对象(无外层数组)兼容 + var single map[string]json.RawMessage + if err2 := json.Unmarshal([]byte(trimmed), &single); err2 == nil { + top = []map[string]json.RawMessage{single} + } else { + return result, fmt.Errorf("PostgreSQL JSON 解析失败:%w", err) + } + } + + if len(top) == 0 { + return result, fmt.Errorf("PostgreSQL EXPLAIN 数组为空") + } + + var warnings []string + for _, item := range top { + // 顶层 Execution Time / Planning Time + if etRaw, ok := item["Execution Time"]; ok { + var et float64 + if err := json.Unmarshal(etRaw, &et); err == nil { + result.Stats.TotalDurationMs = et + } + } + planRaw, ok := item["Plan"] + if !ok { + continue + } + parsePostgresPlanNode(planRaw, "", &result, &warnings) + } + + result.RawFormat = connection.ExplainFormatJSON + result.RawPayload = raw + result.Warnings = warnings + finalizeExplainStats(&result) + return result, nil +} + +// pgPlanNode 映射 PG FORMAT JSON 的 Plan 结构(部分字段,未识别字段保留在 raw 中备用)。 +type pgPlanNode struct { + NodeType string `json:"Node Type"` + RelationName string `json:"Relation Name"` + Alias string `json:"Alias"` + Schema string `json:"Schema"` + StartupCost float64 `json:"Startup Cost"` + TotalCost float64 `json:"Total Cost"` + PlanRows json.Number `json:"Plan Rows"` + PlanWidth json.Number `json:"Plan Width"` + ActualStartup float64 `json:"Actual Startup Time"` + ActualTotal float64 `json:"Actual Total Time"` + ActualRows json.Number `json:"Actual Rows"` + ActualLoops json.Number `json:"Actual Loops"` + IndexName string `json:"Index Name"` + Filter string `json:"Filter"` + HashCond string `json:"Hash Cond"` + JoinType string `json:"Join Type"` + Strategy string `json:"Strategy"` + SharedHit json.Number `json:"Shared Hit Blocks"` + SharedRead json.Number `json:"Shared Read Blocks"` + Output []string `json:"Output"` + Plans []json.RawMessage `json:"Plans"` +} + +// parsePostgresPlanNode 递归解析 PG Plan 节点。 +func parsePostgresPlanNode(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) { + var node pgPlanNode + if err := json.Unmarshal(planRaw, &node); err != nil { + *warnings = append(*warnings, fmt.Sprintf("PG Plan 节点反序列化失败:%v", err)) + return + } + + en := connection.ExplainNode{ + OpType: classifyPostgresNodeType(node.NodeType, node.IndexName), + OpDetail: node.NodeType, + Table: pickPostgresTableName(node), + Index: node.IndexName, + EstRows: parseExplainInt64(string(node.PlanRows)), + ActualRows: parseExplainInt64(string(node.ActualRows)), + Loops: parseExplainInt64(string(node.ActualLoops)), + Cost: node.StartupCost + node.TotalCost, + DurationMs: node.ActualTotal, + } + if node.Strategy != "" { + en.Extra = map[string]any{"strategy": node.Strategy} + } + if node.Filter != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["filter"] = node.Filter + } + if node.HashCond != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["hashCond"] = node.HashCond + } + if node.JoinType != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["joinType"] = node.JoinType + } + + // BufferHit 命中率:Shared Hit / (Shared Hit + Shared Read) + hit := parseExplainInt64(string(node.SharedHit)) + read := parseExplainInt64(string(node.SharedRead)) + if hit+read > 0 { + en.BufferHit = float64(hit) / float64(hit+read) + if en.BufferHit < 0.5 { + en.Flags = append(en.Flags, connection.ExplainFlagLowBufferHit) + } + } + + if en.OpType == connection.ExplainOpScan { + en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + + // Sort/Hash Join 等可能用临时表 + ntLower := strings.ToLower(node.NodeType) + if strings.Contains(ntLower, "sort") { + en.Flags = append(en.Flags, connection.ExplainFlagFilesort) + } + if strings.Contains(ntLower, "materialize") || strings.Contains(ntLower, "hash") { + en.Flags = append(en.Flags, connection.ExplainFlagTempTable) + } + + nodeID := appendExplainChild(result, parentID, en) + for _, childRaw := range node.Plans { + parsePostgresPlanNode(childRaw, nodeID, result, warnings) + } +} + +// classifyPostgresNodeType 把 PG Node Type 归一化到通用 OpType。 +// 例如 Seq Scan → SCAN;Index Scan/Index Only Scan → INDEX_SCAN/INDEX_ONLY; +// Hash Join/Nested Loop/Merge Join → JOIN;Aggregate/GroupAggregate → AGGREGATE;Sort → SORT。 +func classifyPostgresNodeType(nodeType, indexName string) string { + nt := strings.ToLower(strings.TrimSpace(nodeType)) + switch { + case strings.Contains(nt, "seq scan"): + return connection.ExplainOpScan + case strings.Contains(nt, "index only scan"): + return connection.ExplainOpIndexOnly + case strings.Contains(nt, "index scan"), strings.Contains(nt, "bitmap index"): + return connection.ExplainOpIndexScan + case strings.Contains(nt, "join"): + return connection.ExplainOpJoin + case strings.Contains(nt, "aggregate"), strings.Contains(nt, "group"): + return connection.ExplainOpAggregate + case strings.Contains(nt, "sort"): + return connection.ExplainOpSort + case strings.Contains(nt, "limit"): + return connection.ExplainOpLimit + case strings.Contains(nt, "subquery"), strings.Contains(nt, "subplan"): + return connection.ExplainOpSubquery + case strings.Contains(nt, "union"): + return connection.ExplainOpUnion + case strings.Contains(nt, "window"): + return connection.ExplainOpWindow + case strings.Contains(nt, "materialize"): + return connection.ExplainOpMaterialize + case strings.Contains(nt, "result"), strings.Contains(nt, "filter"): + return connection.ExplainOpFilter + default: + return connection.ExplainOpOther + } +} + +// pickPostgresTableName 提取 PG Plan 中的表名(Schema.RelationName 或仅 RelationName)。 +func pickPostgresTableName(node pgPlanNode) string { + if node.RelationName == "" { + return "" + } + if node.Schema != "" { + return node.Schema + "." + node.RelationName + } + return node.RelationName +} diff --git a/internal/app/explain_parse_postgres_test.go b/internal/app/explain_parse_postgres_test.go new file mode 100644 index 0000000..966cb00 --- /dev/null +++ b/internal/app/explain_parse_postgres_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// PG FORMAT JSON fixture:单 Seq Scan + 低缓冲命中。 +const postgresFormatJSONSeqScan = `[ + { + "Plan": { + "Node Type": "Seq Scan", + "Relation Name": "users", + "Schema": "public", + "Alias": "users", + "Startup Cost": 0.00, + "Total Cost": 154.00, + "Plan Rows": 1540, + "Plan Width": 36, + "Actual Startup Time": 0.012, + "Actual Total Time": 1.234, + "Actual Rows": 1500, + "Actual Loops": 1, + "Filter": "(age > 18)", + "Rows Removed by Filter": 40, + "Shared Hit Blocks": 10, + "Shared Read Blocks": 50 + }, + "Planning Time": 0.123, + "Execution Time": 1.456 + } +]` + +func TestParsePostgresExplain_SeqScan(t *testing.T) { + result, err := parsePostgresExplain("postgres", "SELECT * FROM users WHERE age > 18", postgresFormatJSONSeqScan, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpScan { + t.Fatalf("Seq Scan 应为 SCAN,got=%s", node.OpType) + } + if node.Table != "public.users" { + t.Fatalf("Table 应含 schema,got=%q", node.Table) + } + if node.EstRows != 1540 { + t.Fatalf("EstRows got=%d want=1540", node.EstRows) + } + if node.ActualRows != 1500 { + t.Fatalf("ActualRows got=%d want=1500", node.ActualRows) + } + if node.Loops != 1 { + t.Fatalf("Loops got=%d want=1", node.Loops) + } + // BufferHit = 10 / (10+50) = 0.166... + if node.BufferHit < 0.16 || node.BufferHit > 0.17 { + t.Fatalf("BufferHit 应约 0.167,got=%v", node.BufferHit) + } + if !containsFlag(node.Flags, connection.ExplainFlagLowBufferHit) { + t.Fatalf("缓冲命中率低应有 LOW_BUFFER_HIT flag") + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("Seq Scan 应有 FULL_SCAN flag") + } + if result.Stats.TotalDurationMs != 1.456 { + t.Fatalf("Execution Time 应写到 Stats.TotalDurationMs,got=%v", result.Stats.TotalDurationMs) + } +} + +// PG FORMAT JSON fixture:Hash Join + 子节点(Seq Scan + Index Scan)。 +const postgresFormatJSONHashJoin = `[ + { + "Plan": { + "Node Type": "Hash Join", + "Join Type": "Inner", + "Hash Cond": "(o.user_id = u.id)", + "Startup Cost": 50.00, + "Total Cost": 200.00, + "Plan Rows": 1000, + "Actual Rows": 950, + "Actual Loops": 1, + "Plans": [ + { + "Node Type": "Seq Scan", + "Relation Name": "orders", + "Alias": "o", + "Startup Cost": 0.00, + "Total Cost": 100.00, + "Plan Rows": 5000, + "Actual Rows": 5000, + "Actual Loops": 1 + }, + { + "Node Type": "Hash", + "Startup Cost": 25.00, + "Total Cost": 25.00, + "Plan Rows": 100, + "Plans": [ + { + "Node Type": "Index Scan", + "Relation Name": "users", + "Alias": "u", + "Index Name": "users_pkey", + "Startup Cost": 0.15, + "Total Cost": 25.00, + "Plan Rows": 100 + } + ] + } + ] + }, + "Execution Time": 5.5 + } +]` + +func TestParsePostgresExplain_HashJoinWithChildren(t *testing.T) { + result, err := parsePostgresExplain("postgres", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", postgresFormatJSONHashJoin, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 应该有 4 个节点:Hash Join + Seq Scan + Hash + Index Scan + if len(result.Nodes) != 4 { + t.Fatalf("应有 4 个节点,got=%d(nodes=%+v)", len(result.Nodes), result.Nodes) + } + join := result.Nodes[0] + if join.OpType != connection.ExplainOpJoin { + t.Fatalf("顶层应为 JOIN,got=%s", join.OpType) + } + if join.Extra["hashCond"] != "(o.user_id = u.id)" { + t.Fatalf("HashCond 应保留,got=%v", join.Extra["hashCond"]) + } + if join.Extra["joinType"] != "Inner" { + t.Fatalf("JoinType 应保留,got=%v", join.Extra["joinType"]) + } + if !containsFlag(join.Flags, connection.ExplainFlagTempTable) { + t.Fatalf("Hash 节点应有 TEMP_TABLE flag") + } + // 找到 orders Seq Scan + var seqScanNode *connection.ExplainNode + var indexScanNode *connection.ExplainNode + for i := range result.Nodes { + switch result.Nodes[i].OpType { + case connection.ExplainOpScan: + seqScanNode = &result.Nodes[i] + case connection.ExplainOpIndexScan: + indexScanNode = &result.Nodes[i] + } + } + if seqScanNode == nil { + t.Fatal("应有一个 Seq Scan 节点") + } + if seqScanNode.Table != "orders" { + t.Fatalf("Seq Scan 应为 orders 表,got=%s", seqScanNode.Table) + } + if indexScanNode == nil { + t.Fatal("应有一个 Index Scan 节点") + } + if indexScanNode.Index != "users_pkey" { + t.Fatalf("Index Scan 应使用 users_pkey,got=%s", indexScanNode.Index) + } + // Edges:3 条(顶层无父;Seq Scan + Hash 是顶层子;Index Scan 是 Hash 子) + if len(result.Edges) != 3 { + t.Fatalf("应有 3 条边,got=%d", len(result.Edges)) + } +} + +// PG 老版本无 FORMAT JSON 时返回文本。 +func TestParsePostgresExplain_TextFallbackKeepsRaw(t *testing.T) { + raw := "Seq Scan on users (cost=0.00..154.00 rows=1540)" + result, err := parsePostgresExplain("postgres", "SELECT * FROM users", raw, connection.ExplainFormatText) + if err != nil { + t.Fatalf("非 JSON 输入应降级返回原文而非 error:%v", err) + } + if len(result.Warnings) == 0 { + t.Fatal("应有降级 warning") + } + if result.RawPayload != raw { + t.Fatalf("RawPayload 应保留原文") + } + if result.RawFormat != connection.ExplainFormatText { + t.Fatalf("RawFormat got=%v want=text", result.RawFormat) + } +} + +func TestParsePostgresExplain_EmptyRawReturnsError(t *testing.T) { + _, err := parsePostgresExplain("postgres", "SELECT 1", " ", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("空输入应返回 error") + } +} diff --git a/internal/app/explain_parse_sqlite.go b/internal/app/explain_parse_sqlite.go new file mode 100644 index 0000000..d2508c3 --- /dev/null +++ b/internal/app/explain_parse_sqlite.go @@ -0,0 +1,241 @@ +package app + +import ( + "fmt" + "strconv" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// SQLite EXPLAIN QUERY PLAN 解析。 +// +// SQLite EQP 输出是 4 列表格: +// +// id | parent | notused | detail +// 2 | 0 | 0 | SCAN TABLE t +// 3 | 0 | 0 | SEARCH TABLE t USING INDEX idx_x (col=?) +// 7 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY +// 21 | 0 | 0 | COMPOUND QUERY +// 22 | 0 | 0 | USE TEMP B-TREE FOR LAST DISTINCT +// +// id 字段语义: +// - 同一 id 多行:同一节点的多个细节行(如"SCAN" + "USE TEMP B-TREE") +// - 不同 id:不同节点;parent 字段指向父节点 id +// +// detail 文本模式: +// - "SCAN TABLE " 或 "SCAN ":全表扫描 +// - "SEARCH TABLE USING INDEX ()":索引扫描 +// - "SEARCH TABLE USING PRIMARY KEY ()":主键扫描 +// - "USE TEMP B-TREE FOR ORDER BY":filesort +// - "USE TEMP B-TREE FOR DISTINCT":临时表 +// - "COMPOUND QUERY":UNION/INTERSECT 等 +// - "CORRELATED SCALAR SUBQUERY":子查询 +// - "CO-ROUTINE ":协程 + +func parseSQLiteExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: "sqlite", + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + header, rows := parseExplainTSVRows(raw) + if len(header) == 0 || len(rows) == 0 { + return result, fmt.Errorf("SQLite EQP 输出无有效行") + } + + colID := lookupTSVColumn(header, "id") + colParent := lookupTSVColumn(header, "parent") + colDetail := lookupTSVColumn(header, "detail") + if colID < 0 || colDetail < 0 { + return result, fmt.Errorf("SQLite EQP 输出缺少 id 或 detail 列") + } + + // 同一 id 多行:合并 detail 后作为单节点 + // 不同 id 的父子通过 parent 关联 + type eqpEntry struct { + ID string + ParentID string + Details []string + NodeID string // 归一化后的 ExplainNode.ID + } + entries := make(map[string]*eqpEntry) + var order []string // 保持 id 出现顺序 + + for _, row := range rows { + var id, parent, detail string + if colID < len(row) { + id = strings.TrimSpace(row[colID]) + } + if colParent >= 0 && colParent < len(row) { + parent = strings.TrimSpace(row[colParent]) + } + if colDetail < len(row) { + detail = strings.TrimSpace(row[colDetail]) + } + if id == "" { + continue + } + + entry, exists := entries[id] + if !exists { + entry = &eqpEntry{ID: id, ParentID: parent} + entries[id] = entry + order = append(order, id) + } + if detail != "" { + entry.Details = append(entry.Details, detail) + } + } + + // 按 id 出现顺序生成节点(SQLite 保证父先于子) + for _, id := range order { + entry := entries[id] + node := buildSQLiteNodeFromDetails(entry.Details) + parentNodeID := "" + if entry.ParentID != "" && entry.ParentID != "0" { + if parent, ok := entries[entry.ParentID]; ok && parent.NodeID != "" { + parentNodeID = parent.NodeID + } + } + entry.NodeID = appendExplainChild(&result, parentNodeID, node) + } + + result.RawFormat = connection.ExplainFormatTable + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil +} + +// buildSQLiteNodeFromDetails 把 SQLite EQP 的多个 detail 行合并为单节点。 +// 第一行通常是主操作(SCAN/SEARCH),后续行是附加标志(USE TEMP B-TREE 等)。 +// +// 注意:SQLite 在某些场景下 "USE TEMP B-TREE ..." 会作为独立 id 出现(不是 SCAN 的附加行), +// 此时主操作本身就是 USE TEMP B-TREE,需要识别为附加 flag 节点(OpType 保持 OTHER)。 +func buildSQLiteNodeFromDetails(details []string) connection.ExplainNode { + node := connection.ExplainNode{OpType: connection.ExplainOpOther} + if len(details) == 0 { + return node + } + + // 主操作从第一行解析 + primary := details[0] + node.OpDetail = primary + lower := strings.ToLower(primary) + + switch { + case strings.HasPrefix(lower, "scan"): + node.OpType = connection.ExplainOpScan + node.Table = extractSQLiteTableName(primary) + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + case strings.HasPrefix(lower, "search"): + node.OpType = classifySQLiteSearchOp(primary) + node.Table = extractSQLiteTableName(primary) + node.Index = extractSQLiteIndexName(primary) + case strings.HasPrefix(lower, "compound"): + node.OpType = connection.ExplainOpUnion + case strings.HasPrefix(lower, "correlated"), strings.HasPrefix(lower, "scalar subquery"): + node.OpType = connection.ExplainOpSubquery + case strings.HasPrefix(lower, "co-routine"): + node.OpType = connection.ExplainOpOther + case strings.HasPrefix(lower, "use temp b-tree"): + // 独立 id 形式的附加 flag 节点:直接打 flag,OpType 保持 OTHER + if strings.Contains(lower, "order by") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } else { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + } + + // 后续行是附加 flag(仅当主行不是 USE TEMP B-TREE 时才处理,避免重复) + if !strings.HasPrefix(lower, "use temp b-tree") { + for _, d := range details[1:] { + dl := strings.ToLower(d) + switch { + case strings.Contains(dl, "temp b-tree"): + if strings.Contains(dl, "order by") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } else { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + case strings.Contains(dl, "subquery"): + node.Flags = append(node.Flags, "SUBQUERY") + } + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["extra"] = d + } + } + return node +} + +// classifySQLiteSearchOp 区分 SQLite SEARCH 的索引类型。 +// USING INDEX → INDEX_SCAN;USING PRIMARY KEY → INDEX_SCAN;USING ROWID → SCAN(伪索引扫描)。 +func classifySQLiteSearchOp(detail string) string { + lower := strings.ToLower(detail) + if strings.Contains(lower, "using covering index") { + return connection.ExplainOpIndexOnly + } + if strings.Contains(lower, "using index") || strings.Contains(lower, "using primary key") { + return connection.ExplainOpIndexScan + } + if strings.Contains(lower, "using rowid") { + // ROWID 扫描本质还是按物理位置顺序访问 + return connection.ExplainOpScan + } + return connection.ExplainOpIndexScan +} + +// extractSQLiteTableName 从 detail 文本中提取表名。 +// 形如 "SCAN TABLE users" → "users";"SEARCH TABLE users USING INDEX idx_x (id)" → "users"。 +func extractSQLiteTableName(detail string) string { + upper := strings.ToUpper(detail) + for _, marker := range []string{"TABLE ", "VIEW "} { + idx := strings.Index(upper, marker) + if idx < 0 { + continue + } + rest := detail[idx+len(marker):] + // 截到下一个空格或 USING 之前 + for i, ch := range rest { + if ch == ' ' || ch == '\t' { + return strings.TrimSpace(rest[:i]) + } + } + return strings.TrimSpace(rest) + } + return "" +} + +// extractSQLiteIndexName 从 detail 中提取使用的索引名。 +// 形如 "USING INDEX idx_x (id)" → "idx_x";"USING PRIMARY KEY" → "PRIMARY"。 +func extractSQLiteIndexName(detail string) string { + upper := strings.ToUpper(detail) + for _, marker := range []string{"USING INDEX ", "USING PRIMARY KEY", "USING COVERING INDEX "} { + idx := strings.Index(upper, marker) + if idx < 0 { + continue + } + rest := detail[idx+len(marker):] + if marker == "USING PRIMARY KEY" { + return "PRIMARY" + } + // 截到下一个空格或左括号 + for i, ch := range rest { + if ch == ' ' || ch == '\t' || ch == '(' { + if i == 0 { + return "" + } + name := strings.TrimSpace(rest[:i]) + if _, err := strconv.Atoi(name); err == nil { + continue + } + return name + } + } + return strings.TrimSpace(rest) + } + return "" +} diff --git a/internal/app/explain_parse_sqlite_test.go b/internal/app/explain_parse_sqlite_test.go new file mode 100644 index 0000000..1226db5 --- /dev/null +++ b/internal/app/explain_parse_sqlite_test.go @@ -0,0 +1,138 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// SQLite EQP fixture:单表全表扫描 + filesort。 +const sqliteEQPFullScanWithSort = `id parent notused detail +2 0 0 SCAN TABLE users +5 0 0 USE TEMP B-TREE FOR ORDER BY` + +func TestParseSQLiteExplain_FullScanWithFileSort(t *testing.T) { + result, err := parseSQLiteExplain("SELECT * FROM users ORDER BY name", sqliteEQPFullScanWithSort, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 2 个独立节点(id 不同,parent 都是 0,无父子关系) + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + scan := result.Nodes[0] + if scan.OpType != connection.ExplainOpScan { + t.Fatalf("第一个应为 SCAN,got=%s", scan.OpType) + } + if scan.Table != "users" { + t.Fatalf("table got=%s want=users", scan.Table) + } + if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("SCAN 应有 FULL_SCAN flag") + } + if result.Stats.HasFullScan != true { + t.Fatalf("Stats.HasFullScan 应为 true") + } + if result.Stats.HasFilesort != true { + t.Fatalf("Stats.HasFilesort 应为 true") + } +} + +// SQLite EQP fixture:索引扫描。 +const sqliteEQPIndexScan = `id parent notused detail +3 0 0 SEARCH TABLE users USING INDEX idx_email (email=?)` + +func TestParseSQLiteExplain_IndexScanExtractsIndex(t *testing.T) { + result, err := parseSQLiteExplain("SELECT * FROM users WHERE email = 'x'", sqliteEQPIndexScan, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpIndexScan { + t.Fatalf("USING INDEX 应为 INDEX_SCAN,got=%s", node.OpType) + } + if node.Table != "users" { + t.Fatalf("table got=%s want=users", node.Table) + } + if node.Index != "idx_email" { + t.Fatalf("index got=%s want=idx_email", node.Index) + } +} + +// SQLite EQP fixture:主键扫描 + 临时表(distinct)。 +const sqliteEQPPrimaryKeyWithDistinct = `id parent notused detail +3 0 0 SEARCH TABLE users USING PRIMARY KEY (id=?) +7 0 0 USE TEMP B-TREE FOR DISTINCT` + +func TestParseSQLiteExplain_PrimaryKeyAndDistinct(t *testing.T) { + result, err := parseSQLiteExplain("SELECT DISTINCT name FROM users WHERE id = 1", sqliteEQPPrimaryKeyWithDistinct, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + pk := result.Nodes[0] + if pk.OpType != connection.ExplainOpIndexScan { + t.Fatalf("PRIMARY KEY 应为 INDEX_SCAN,got=%s", pk.OpType) + } + if pk.Index != "PRIMARY" { + t.Fatalf("index got=%s want=PRIMARY", pk.Index) + } + if result.Stats.HasTempTable != true { + t.Fatalf("FOR DISTINCT 应触发 TEMP_TABLE flag") + } +} + +// SQLite EQP fixture:父子关系(子查询)。 +const sqliteEQPCorrelatedSubquery = `id parent notused detail +2 0 0 SCAN TABLE orders +6 2 0 CORRELATED SCALAR SUBQUERY 1 +8 6 0 SEARCH TABLE users USING INDEX idx_id (id=?)` + +func TestParseSQLiteExplain_HierarchicalRelationShips(t *testing.T) { + result, err := parseSQLiteExplain("SELECT *, (SELECT name FROM users WHERE id = o.user_id) FROM orders o", sqliteEQPCorrelatedSubquery, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 3 { + t.Fatalf("应有 3 个节点,got=%d", len(result.Nodes)) + } + // orders 是根(parent=0) + // CORRELATED SCALAR SUBQUERY 的 parent=2 → orders + // SEARCH 的 parent=6 → subquery + if result.Nodes[0].ParentID != "" { + t.Fatalf("根节点 ParentID 应为空,got=%q", result.Nodes[0].ParentID) + } + if result.Nodes[1].ParentID != result.Nodes[0].ID { + t.Fatalf("subquery 节点的 ParentID 应指向 orders") + } + if result.Nodes[2].ParentID != result.Nodes[1].ID { + t.Fatalf("SEARCH 节点的 ParentID 应指向 subquery") + } + if len(result.Edges) != 2 { + t.Fatalf("应有 2 条边,got=%d", len(result.Edges)) + } +} + +func TestParseSQLiteExplain_CoveringIndex(t *testing.T) { + raw := `id parent notused detail +3 0 0 SEARCH TABLE users USING COVERING INDEX idx_name_email (name=?)` + result, err := parseSQLiteExplain("SELECT name FROM users WHERE name = 'x'", raw, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if result.Nodes[0].OpType != connection.ExplainOpIndexOnly { + t.Fatalf("COVERING INDEX 应为 INDEX_ONLY,got=%s", result.Nodes[0].OpType) + } +} + +func TestParseSQLiteExplain_MissingColumnsReturnsError(t *testing.T) { + _, err := parseSQLiteExplain("SELECT 1", "id parent\n1 0", connection.ExplainFormatTable) + if err == nil { + t.Fatal("缺少 detail 列应返回 error") + } +} diff --git a/internal/app/methods_explain.go b/internal/app/methods_explain.go new file mode 100644 index 0000000..ad54245 --- /dev/null +++ b/internal/app/methods_explain.go @@ -0,0 +1,353 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/utils" +) + +// SQL 诊断工作台后端入口。 +// +// 数据流: +// 用户 SQL +// → DiagnoseQuery(白名单校验 + 调度) +// → executeExplain(决定走 ExplainExecer 还是 fallback 包装) +// → buildExplainQuery(方言特定的 EXPLAIN 语句构造) +// → dbInst.QueryMultiContextWithMessages(实际执行) +// → collectExplainRaw(合并结果集为原文) +// → parseExplainRaw(路由到方言解析器) +// → ExplainResult(归一化节点树 + Stats) +// +// 解析器实现在 explain_parse_.go。 + +// explainSupportedDBTypes 是一期支持的 EXPLAIN 数据源白名单。 +// 不在白名单内的数据源(MongoDB/Redis/TDengine 等)调用 DiagnoseQuery 时直接返回不支持。 +var explainSupportedDBTypes = map[string]bool{ + "mysql": true, + "mariadb": true, + "diros": true, // Doris 走 MySQL 协议,EXPLAIN 语法兼容 + "starrocks": true, // 同上 + "postgres": true, + "gaussdb": true, + "opengauss": true, + "kingbase": true, + "highgo": true, + "vastbase": true, + "sqlite": true, + "clickhouse": true, + "oracle": true, // 含 OceanBase Oracle 协议(resolveDDLDBType 已归一化) + "sqlserver": true, + "oceanbase": true, // MySQL 协议走 MySQL 语法 +} + +// explainStatementTimeoutFloor 是诊断的最小超时下限。 +// EXPLAIN 本身通常很快,但 ANALYZE 模式(PG/Oracle)会真实执行 SQL, +// 需要给足时间避免大查询超时。 +const explainStatementTimeoutFloor = 5 * time.Minute + +// DiagnoseQuery 是 SQL 诊断工作台对外暴露的入口。 +// 输入用户 SQL(仅允许 SELECT/WITH),返回执行计划归一化结果。 +// PR1 仅返回 ExplainResult;索引建议(Suggestions)在 PR2 规则引擎接入后填充。 +// +// Wails 绑定:前端通过 DiagnoseQuery(config, dbName, sql) 调用,返回 QueryResult.Data 为 DiagnoseReport。 +func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query string) connection.QueryResult { + query = strings.TrimSpace(query) + if query == "" { + return connection.QueryResult{Success: false, Message: "查询语句不能为空"} + } + if !looksLikeSelectOrWith(query) { + return connection.QueryResult{Success: false, Message: "诊断仅支持 SELECT / WITH 查询;写操作请使用 EXPLAIN PLAN 模式(PR2 支持)"} + } + + runConfig := normalizeRunConfig(config, dbName) + dbType := resolveDDLDBType(runConfig) + if !explainSupportedDBTypes[dbType] { + return connection.QueryResult{ + Success: false, + Message: fmt.Sprintf("当前数据源(%s)暂不支持 SQL 诊断;一期支持 MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase", dbType), + } + } + + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + plan, err := a.executeExplain(dbInst, runConfig, dbType, query) + if err != nil { + logger.Warnf("DiagnoseQuery 执行 EXPLAIN 失败:type=%s err=%v sql=%q", dbType, err, sqlSnippet(query)) + return connection.QueryResult{Success: false, Message: err.Error()} + } + + report := connection.DiagnoseReport{Plan: plan} + return connection.QueryResult{Success: true, Message: "诊断完成", Data: report} +} + +// executeExplain 决定走哪条 EXPLAIN 执行路径: +// 1. 若 dbInst 实现 ExplainExecer(driver-agent 在 PR2 接入),优先用驱动原生实现 +// 2. 否则走 app 层 fallback:buildExplainQuery 构造 EXPLAIN 语句,通过 QueryMulti 执行 +func (a *App) executeExplain(dbInst db.Database, config connection.ConnectionConfig, dbType, query string) (connection.ExplainResult, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if timeout := getDiagnoseTimeout(config); timeout > 0 { + var cancelFn context.CancelFunc + ctx, cancelFn = utils.ContextWithTimeout(timeout) + defer cancelFn() + } + + // 优先:驱动自带 Explain(OceanBase driver-agent 走此路径) + if explainer, ok := dbInst.(db.ExplainExecer); ok { + logger.Infof("DiagnoseQuery 走 ExplainExecer 路径:type=%s", dbType) + raw, format, err := explainer.Explain(ctx, query) + if err != nil { + return connection.ExplainResult{}, fmt.Errorf("驱动 EXPLAIN 执行失败:%w", err) + } + return parseExplainRaw(dbType, query, raw, format) + } + + // Fallback:app 层构造 EXPLAIN 语句 + wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQuery(dbType, query) + if err != nil { + return connection.ExplainResult{}, err + } + defer runExplainCleanup(dbInst, cleanupQueries) + + raw, actualFormat, execErr := executeExplainStatements(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat) + if execErr != nil { + return connection.ExplainResult{}, fmt.Errorf("执行 EXPLAIN 失败:%w", execErr) + } + return parseExplainRaw(dbType, query, raw, actualFormat) +} + +// runExplainCleanup 执行清理语句(如 Oracle DELETE FROM plan_table),失败仅记日志不阻塞主流程。 +// 在 defer 中调用,确保主 EXPLAIN 失败时也能尝试清理。 +func runExplainCleanup(dbInst db.Database, cleanupQueries []string) { + for _, q := range cleanupQueries { + if strings.TrimSpace(q) == "" { + continue + } + if _, err := dbInst.Exec(q); err != nil { + logger.Warnf("EXPLAIN 清理失败(可忽略):sql=%q err=%v", sqlSnippet(q), err) + } + } +} + +// executeExplainStatements 执行 EXPLAIN 主语句和后置查询(Oracle 的 DBMS_XPLAN.DISPLAY)。 +// 返回拼接后的原文 + 实际格式(可能与 preferFormat 不同,比如 MySQL 5.7 不支持 FORMAT=JSON 时降级)。 +func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + statements := []string{wrappedSQL} + statements = append(statements, postQueries...) + fullSQL := strings.Join(statements, ";\n") + + // 优先使用带 context 的多结果接口,便于取消 + if multi, ok := dbInst.(db.MultiResultQueryMessageExecer); ok { + results, _, err := multi.QueryMultiContextWithMessages(ctx, fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + if multi, ok := dbInst.(db.MultiResultQuerierContext); ok { + results, err := multi.QueryMultiContext(ctx, fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + if multi, ok := dbInst.(db.MultiResultQuerier); ok { + results, err := multi.QueryMulti(fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + + // 单结果 fallback:只执行第一条 EXPLAIN,忽略 postQueries(不适合 Oracle/SQLServer) + data, _, err := dbInst.Query(wrappedSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw([]connection.ResultSetData{{Rows: data}}, preferFormat) +} + +// collectExplainRaw 把多个结果集合并为单个原文,并探测实际格式。 +// MySQL FORMAT=JSON 返回 1 行 1 列包含完整 JSON 文本;表格模式返回多行多列。 +func collectExplainRaw(results []connection.ResultSetData, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + if len(results) == 0 { + return "", preferFormat, fmt.Errorf("EXPLAIN 未返回结果") + } + + // 大多数方言只有 1 个结果集;Oracle 有 2 个(EXPLAIN PLAN 影响 + DBMS_XPLAN.DISPLAY 查询) + // 取最后一个非空结果集作为 EXPLAIN 输出(DISPLAY 在 post 查询中) + last := pickLastNonEmptyResult(results) + if last == nil { + return "", preferFormat, fmt.Errorf("EXPLAIN 返回空结果集") + } + + // 单列单行 + 值是 JSON/XML 字符串 → 直接当原文 + if len(last.Columns) == 1 && len(last.Rows) == 1 { + for _, v := range last.Rows[0] { + text := strings.TrimSpace(fmt.Sprintf("%v", v)) + if text != "" && text != "" { + return text, detectExplainFormat(text, preferFormat), nil + } + } + } + + // 表格模式:把行重组成 TSV,解析器按列定位 + var builder strings.Builder + builder.WriteString(strings.Join(last.Columns, "\t")) + builder.WriteByte('\n') + for _, row := range last.Rows { + values := make([]string, 0, len(last.Columns)) + for _, col := range last.Columns { + val := row[col] + if val == nil { + values = append(values, "") + continue + } + values = append(values, fmt.Sprintf("%v", val)) + } + builder.WriteString(strings.Join(values, "\t")) + builder.WriteByte('\n') + } + return builder.String(), connection.ExplainFormatTable, nil +} + +// pickLastNonEmptyResult 找最后一个有行数据的结果集(Oracle 的 EXPLAIN PLAN 影响 0 行,DISPLAY 才有数据)。 +func pickLastNonEmptyResult(results []connection.ResultSetData) *connection.ResultSetData { + for i := len(results) - 1; i >= 0; i-- { + r := results[i] + if len(r.Rows) > 0 { + return &r + } + } + return nil +} + +// detectExplainFormat 探测原文实际格式(当驱动返回的是单字符串时)。 +// 优先信任 preferFormat;不可识别时按内容启发式判断。 +func detectExplainFormat(text string, preferFormat connection.ExplainFormat) connection.ExplainFormat { + trimmed := strings.TrimLeft(text, " \t\r\n") + switch { + case strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "["): + return connection.ExplainFormatJSON + case strings.HasPrefix(trimmed, ".go 中实现 parseXxxExplain,这里按 dbType 分发。 +// 未实现的方言返回原文 + 警告,保证主流程不阻塞。 +func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + switch dbType { + case "mysql", "mariadb", "diros", "starrocks", "oceanbase": + return parseMySQLExplain(dbType, sourceSQL, raw, format) + case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase": + return parsePostgresExplain(dbType, sourceSQL, raw, format) + case "sqlite": + return parseSQLiteExplain(sourceSQL, raw, format) + case "clickhouse": + // PR2 实现 + return connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + RawFormat: format, + RawPayload: raw, + Warnings: []string{"ClickHouse 解析器在 PR2 实现,先返回原文"}, + }, nil + case "oracle": + // PR2 实现 + return connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + RawFormat: format, + RawPayload: raw, + Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"}, + }, nil + case "sqlserver": + // PR2 实现 + return connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + RawFormat: format, + RawPayload: raw, + Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"}, + }, nil + default: + return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType) + } +} + +// getDiagnoseTimeout 取诊断超时:优先 config.Timeout,否则默认 5 分钟。 +// EXPLAIN ANALYZE 会真实执行 SQL,超时太短会让大查询被误判失败。 +func getDiagnoseTimeout(config connection.ConnectionConfig) time.Duration { + if config.Timeout > 0 { + timeout := time.Duration(config.Timeout) * time.Second + if timeout < explainStatementTimeoutFloor { + return explainStatementTimeoutFloor + } + return timeout + } + return explainStatementTimeoutFloor +} + +// buildExplainQuery 按方言构造 EXPLAIN 语句。 +// 返回: +// - wrappedSQL:主 EXPLAIN 语句(可能含 prelude 如 SQLServer 的 SET SHOWPLAN_XML ON) +// - postQueries:后置查询(如 Oracle 的 SELECT ... FROM DBMS_XPLAN.DISPLAY) +// - preferFormat:期望的输出格式(用于解析器调度;实际格式由 collectExplainRaw 探测后确定) +// - cleanupQueries:清理语句(Oracle DELETE FROM plan_table),defer 中执行 +// - err:方言不支持时返回 +// +// 参考现有风格:buildListViewQueries (methods_file.go:3102) 的 switch-case 模式。 +func buildExplainQuery(dbType, query string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) { + sql := strings.TrimRight(strings.TrimSpace(query), ";") + switch dbType { + case "mysql", "mariadb", "oceanbase": + // MySQL 8.0+ 和 OceanBase 都支持 FORMAT=JSON + // 5.7 在 collectExplainRaw 阶段会拿到语法错误,由调用方降级处理(PR2 加重试逻辑) + return fmt.Sprintf("EXPLAIN FORMAT=JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "diros", "starrocks": + // Doris/StarRocks 不支持 FORMAT=JSON,使用原生 EXPLAIN(返回表格 + 一些文本块) + return fmt.Sprintf("EXPLAIN %s", sql), nil, connection.ExplainFormatTable, nil, nil + case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase": + // ANALYZE 真实执行 SQL,但 looksLikeSelectOrWith 已校验只读;BUFFERS 在 PG14+ 自动忽略不支持的选项 + return fmt.Sprintf("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "sqlite": + return fmt.Sprintf("EXPLAIN QUERY PLAN %s", sql), nil, connection.ExplainFormatTable, nil, nil + case "clickhouse": + return fmt.Sprintf("EXPLAIN JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "oracle": + // OceanBase Oracle 协议也走此分支(resolveDDLDBType 已归一化) + // 用 STATEMENT_ID 隔离,避免多用户共享 plan_table 时互相覆盖 + stmtID := fmt.Sprintf("gonavi_%d", time.Now().UnixNano()) + wrapped := fmt.Sprintf("EXPLAIN PLAN SET STATEMENT_ID = '%s' FOR %s", stmtID, sql) + post := []string{ + fmt.Sprintf("SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, '%s', 'ALL'))", stmtID), + } + cleanup := []string{ + fmt.Sprintf("DELETE FROM plan_table WHERE statement_id = '%s'", stmtID), + } + return wrapped, post, connection.ExplainFormatTable, cleanup, nil + case "sqlserver": + // SET SHOWPLAN_XML ON 后整个会话只返回计划不执行;必须 SET OFF 清理,否则连接污染 + wrapped := fmt.Sprintf("SET SHOWPLAN_XML ON;\n%s", sql) + post := []string{"SET SHOWPLAN_XML OFF;"} + return wrapped, post, connection.ExplainFormatXML, nil, nil + default: + return "", nil, "", nil, fmt.Errorf("方言 %s 的 EXPLAIN 构造未实现", dbType) + } +} diff --git a/internal/app/methods_explain_test.go b/internal/app/methods_explain_test.go new file mode 100644 index 0000000..72b46f5 --- /dev/null +++ b/internal/app/methods_explain_test.go @@ -0,0 +1,144 @@ +package app + +import ( + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +// buildExplainQuery 测试:验证各方言生成的 SQL 是否符合预期。 +func TestBuildExplainQuery_MySQLUsesFormatJSON(t *testing.T) { + wrapped, post, format, cleanup, err := buildExplainQuery("mysql", "SELECT * FROM t") + if err != nil { + t.Fatalf("mysql 构造失败:%v", err) + } + if want := "EXPLAIN FORMAT=JSON SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if len(post) != 0 { + t.Fatalf("mysql 不应有 post 查询,got=%v", post) + } + if len(cleanup) != 0 { + t.Fatalf("mysql 不应有 cleanup,got=%v", cleanup) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_PostgresUsesAnalyzeBuffersJSON(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("postgres", "SELECT * FROM t WHERE id = 1") + if err != nil { + t.Fatalf("postgres 构造失败:%v", err) + } + if !strings.Contains(wrapped, "ANALYZE") || !strings.Contains(wrapped, "BUFFERS") || !strings.Contains(wrapped, "FORMAT JSON") { + t.Fatalf("postgres SQL 应含 ANALYZE BUFFERS FORMAT JSON,got=%q", wrapped) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_SQLiteUsesEQP(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("sqlite", "SELECT * FROM t") + if err != nil { + t.Fatalf("sqlite 构造失败:%v", err) + } + if want := "EXPLAIN QUERY PLAN SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if format != connection.ExplainFormatTable { + t.Fatalf("format got=%v want=table", format) + } +} + +func TestBuildExplainQuery_OracleReturnsStatementIDAndCleanup(t *testing.T) { + wrapped, post, _, cleanup, err := buildExplainQuery("oracle", "SELECT * FROM t") + if err != nil { + t.Fatalf("oracle 构造失败:%v", err) + } + if !strings.Contains(wrapped, "EXPLAIN PLAN SET STATEMENT_ID") { + t.Fatalf("oracle 主语句应含 STATEMENT_ID,got=%q", wrapped) + } + if len(post) != 1 || !strings.Contains(post[0], "DBMS_XPLAN.DISPLAY") { + t.Fatalf("oracle post 应含 DBMS_XPLAN.DISPLAY,got=%v", post) + } + if len(cleanup) != 1 || !strings.Contains(cleanup[0], "DELETE FROM plan_table") { + t.Fatalf("oracle cleanup 应含 DELETE FROM plan_table,got=%v", cleanup) + } + // 验证 statement_id 在三条 SQL 中一致 + idInWrapped := extractBetween(wrapped, "STATEMENT_ID = '", "' FOR") + idInPost := extractBetween(post[0], "NULL, '", "'") + idInCleanup := extractBetween(cleanup[0], "statement_id = '", "'") + if idInWrapped == "" || idInWrapped != idInPost || idInWrapped != idInCleanup { + t.Fatalf("statement_id 不一致:wrapped=%q post=%q cleanup=%q", idInWrapped, idInPost, idInCleanup) + } +} + +func TestBuildExplainQuery_SQLServerSetsShowplanXML(t *testing.T) { + wrapped, post, _, _, err := buildExplainQuery("sqlserver", "SELECT * FROM t") + if err != nil { + t.Fatalf("sqlserver 构造失败:%v", err) + } + if !strings.Contains(wrapped, "SET SHOWPLAN_XML ON") { + t.Fatalf("sqlserver 应 SET SHOWPLAN_XML ON,got=%q", wrapped) + } + if !strings.Contains(wrapped, "SELECT * FROM t") { + t.Fatalf("sqlserver 应保留原 SQL,got=%q", wrapped) + } + if len(post) != 1 || !strings.Contains(post[0], "SET SHOWPLAN_XML OFF") { + t.Fatalf("sqlserver post 应 SET SHOWPLAN_XML OFF,got=%v", post) + } +} + +func TestBuildExplainQuery_ClickHouseUsesExplainJSON(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("clickhouse", "SELECT * FROM t") + if err != nil { + t.Fatalf("clickhouse 构造失败:%v", err) + } + if want := "EXPLAIN JSON SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_PGLikeDialectsSharePath(t *testing.T) { + // gaussdb/opengauss/kingbase/highgo/vastbase 应该复用 PG 的 ANALYZE BUFFERS 路径 + for _, dbType := range []string{"gaussdb", "opengauss", "kingbase", "highgo", "vastbase"} { + wrapped, _, format, _, err := buildExplainQuery(dbType, "SELECT 1") + if err != nil { + t.Errorf("%s 构造失败:%v", dbType, err) + continue + } + if !strings.Contains(wrapped, "FORMAT JSON") { + t.Errorf("%s 应使用 FORMAT JSON 路径,got=%q", dbType, wrapped) + } + if format != connection.ExplainFormatJSON { + t.Errorf("%s format got=%v want=json", dbType, format) + } + } +} + +func TestBuildExplainQuery_UnsupportedDialectReturnsError(t *testing.T) { + _, _, _, _, err := buildExplainQuery("mongodb", "db.t.find()") + if err == nil { + t.Fatal("未支持方言应返回 error") + } +} + +// extractBetween 取 s 中 between start 和 end 的第一个匹配子串(测试辅助)。 +func extractBetween(s, start, end string) string { + startIdx := strings.Index(s, start) + if startIdx < 0 { + return "" + } + startIdx += len(start) + endIdx := strings.Index(s[startIdx:], end) + if endIdx < 0 { + return "" + } + return s[startIdx : startIdx+endIdx] +} diff --git a/internal/connection/explain.go b/internal/connection/explain.go new file mode 100644 index 0000000..185fedf --- /dev/null +++ b/internal/connection/explain.go @@ -0,0 +1,139 @@ +package connection + +import "time" + +// SQL 诊断工作台数据结构。 +// +// 设计要点: +// - 节点用扁平数组 + ParentID 表达父子(不用嵌套树),便于前端 react-flow 渲染和按 ID 检索 +// - 跨方言归一化:不论 MySQL/PG/Oracle 输出,统一映射到 ExplainNode.OpType + Flags +// - 原文保留(RawPayload)用于调试和前端展开查看 +// - 与 ResultSetData 同包,便于 Wails 绑定自动生成 TS 类型 + +// ExplainFormat 标识 EXPLAIN 原始输出的格式,决定解析器路径。 +type ExplainFormat string + +const ( + ExplainFormatJSON ExplainFormat = "json" // MySQL 8.0 FORMAT=JSON / PG FORMAT JSON / ClickHouse JSON + ExplainFormatTable ExplainFormat = "table" // MySQL 5.7 表格 / SQLite EQP / Oracle DBMS_XPLAN + ExplainFormatXML ExplainFormat = "xml" // SQLServer SHOWPLAN_XML + ExplainFormatText ExplainFormat = "text" // 兜底,无法归类时 +) + +// 节点操作类型(归一化后跨方言通用)。 +const ( + ExplainOpScan = "SCAN" // 全表扫描 / 顺序扫描 + ExplainOpIndexScan = "INDEX_SCAN" // 索引扫描(ref/eq_ref/range) + ExplainOpIndexOnly = "INDEX_ONLY" // Using index 覆盖索引 + ExplainOpJoin = "JOIN" // 任意 JOIN 类型(Nested Loop / Hash / Merge) + ExplainOpAggregate = "AGGREGATE" // GROUP BY / DISTINCT / 聚合函数 + ExplainOpSort = "SORT" // filesort / ORDER BY + ExplainOpLimit = "LIMIT" // LIMIT 截断 + ExplainOpFilter = "FILTER" // WHERE/HAVING 过滤 + ExplainOpSubquery = "SUBQUERY" // 子查询 + ExplainOpUnion = "UNION" // UNION 合并 + ExplainOpWindow = "WINDOW" // 窗口函数 + ExplainOpMaterialize = "MATERIALIZE" // 物化临时表 + ExplainOpInsert = "INSERT" // INSERT 操作(EXPLAIN INSERT) + ExplainOpUpdate = "UPDATE" + ExplainOpDelete = "DELETE" + ExplainOpOther = "OTHER" // 无法归类 +) + +// 节点警告标志(用于规则匹配和前端高亮)。 +const ( + ExplainFlagFullScan = "FULL_SCAN" // 全表扫描 + ExplainFlagFilesort = "FILESORT" // 额外排序 + ExplainFlagTempTable = "TEMP_TABLE" // 使用临时表 + ExplainFlagNoIndex = "NO_INDEX" // 未命中索引 + ExplainFlagHighCost = "HIGH_COST" // 成本显著高于其他节点 + ExplainFlagLowBufferHit = "LOW_BUFFER_HIT" // 缓冲命中率低(PG BUFFERS) + ExplainFlagUccWarn = "UNCERTAIN_ROWS" // 估算行数不确定(rows=0 或巨大偏差) +) + +// 索引建议严重度。 +const ( + SeverityCritical = "critical" // 严重影响性能(如大表全表扫描) + SeverityWarning = "warning" // 有改进空间 + SeverityInfo = "info" // 优化建议 +) + +// ExplainNode 表示执行计划中的一个节点(归一化后跨方言通用)。 +type ExplainNode struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + OpType string `json:"opType"` + OpDetail string `json:"opDetail,omitempty"` // 原始操作符文本,如 "Hash Join" / "Using where" + Table string `json:"table,omitempty"` // 涉及的表名 + Index string `json:"index,omitempty"` // 使用的索引名 + EstRows int64 `json:"estRows,omitempty"` // 估算扫描行数 + ActualRows int64 `json:"actualRows,omitempty"` // 实际返回行数(需 ANALYZE) + Loops int64 `json:"loops,omitempty"` // 循环执行次数 + Cost float64 `json:"cost,omitempty"` // 估算成本 + DurationMs float64 `json:"durationMs,omitempty"` // 实际耗时毫秒(需 ANALYZE) + BufferHit float64 `json:"bufferHit,omitempty"` // 缓冲命中率 0-1 + Flags []string `json:"flags,omitempty"` // 警告标志 + Extra map[string]any `json:"extra,omitempty"` // 方言特定字段,前端按需展示 +} + +// ExplainEdge 表示执行计划节点间的父子关系,前端 react-flow 用于绘制连线。 +type ExplainEdge struct { + From string `json:"from"` // 父节点 ID + To string `json:"to"` // 子节点 ID + Label string `json:"label,omitempty"` // 边的标注(如 JOIN 类型 "INNER"/"LEFT") +} + +// ExplainStats 是整个执行计划的聚合统计。 +type ExplainStats struct { + TotalCost float64 `json:"totalCost,omitempty"` + TotalDurationMs float64 `json:"totalDurationMs,omitempty"` + RowsRead int64 `json:"rowsRead,omitempty"` // 所有 SCAN 节点估算行数之和 + BufferHitRate float64 `json:"bufferHitRate,omitempty"` // 平均缓冲命中率 + HasFullScan bool `json:"hasFullScan"` + HasFilesort bool `json:"hasFilesort"` + HasTempTable bool `json:"hasTempTable"` + MaxEstRows int64 `json:"maxEstRows,omitempty"` // 单节点最大估算行数(用于规则匹配) +} + +// ExplainResult 是一次 EXPLAIN 解析后的归一化结果。 +type ExplainResult struct { + DBType string `json:"dbType"` + SourceSQL string `json:"sourceSql"` + Nodes []ExplainNode `json:"nodes"` + Edges []ExplainEdge `json:"edges,omitempty"` + Stats ExplainStats `json:"stats"` + Warnings []string `json:"warnings,omitempty"` // 解析/降级过程中的提示 + RawFormat ExplainFormat `json:"rawFormat"` + RawPayload string `json:"rawPayload,omitempty"` // 原始 EXPLAIN 输出,前端调试用 +} + +// IndexSuggestion 是规则引擎针对某个节点产生的索引建议。 +type IndexSuggestion struct { + Severity string `json:"severity"` // critical/warning/info + Rule string `json:"rule"` // 规则 ID,如 "full_scan_on_large_table" + Reason string `json:"reason"` // 人类可读的触发原因 + SuggestedIndex string `json:"suggestedIndex,omitempty"` // 建议的 CREATE INDEX 语句(如有) + AffectedNodeID string `json:"affectedNodeId,omitempty"` // 关联的 ExplainNode.ID + AffectedTable string `json:"affectedTable,omitempty"` + EstRows int64 `json:"estRows,omitempty"` // 触发节点的估算行数,便于排序 +} + +// DiagnoseReport 是 DiagnoseQuery 的最终返回值,前端诊断面板消费此结构。 +type DiagnoseReport struct { + Plan ExplainResult `json:"plan"` + Suggestions []IndexSuggestion `json:"suggestions"` +} + +// QueryExecutionRecord 是慢 SQL 历史的一条记录(PR5 慢 SQL 摘要用,提前定义便于 PR1 数据流贯通)。 +type QueryExecutionRecord struct { + ID string `json:"id"` + ConnectionFP string `json:"connectionFp"` // 连接指纹,复用 saved_query_fingerprint + SQLFingerprint string `json:"sqlFp"` // SQL 文本指纹(归一化后 sha256 取前 16) + SQLPreview string `json:"sqlPreview"` // 截断后的 SQL 预览(前 200 字符) + DBType string `json:"dbType"` + DurationMs int64 `json:"durationMs"` + RowsRead int64 `json:"rowsRead,omitempty"` + RowsReturned int64 `json:"rowsReturned,omitempty"` + PlanHash string `json:"planHash,omitempty"` // 同一 SQL 不同计划的区分(PR5 实现) + ExecutedAt time.Time `json:"executedAt"` +} diff --git a/internal/db/database.go b/internal/db/database.go index 8f69b02..1898c41 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -98,6 +98,26 @@ type StreamQueryExecer interface { StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error } +// ExplainExecer is an optional interface for drivers that can run EXPLAIN and +// return the dialect-native output (JSON text, table rows as JSON, or XML). +// +// Drivers that implement this interface own the full EXPLAIN lifecycle: +// - MySQL: prefer EXPLAIN FORMAT=JSON, fallback to vanilla EXPLAIN on 5.7 +// - PostgreSQL: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +// - Oracle: EXPLAIN PLAN SET STATEMENT_ID ... + DBMS_XPLAN.DISPLAY + cleanup +// - SQLServer: SET SHOWPLAN_XML ON + sql + SET OFF (defer cleanup mandatory) +// - SQLite: EXPLAIN QUERY PLAN +// - ClickHouse: EXPLAIN JSON +// +// The driver decides which format to use and returns the raw payload plus the +// detected format tag; the app layer parses via the corresponding parser. +// +// Drivers that do NOT implement this interface fall back to the generic path +// in app.DiagnoseQuery: wrap the SQL as "EXPLAIN " and run via QueryMulti. +type ExplainExecer interface { + Explain(ctx context.Context, query string) (raw string, format connection.ExplainFormat, err error) +} + // StatementQueryMessageExecer can run queries on a pinned session and return // extra server messages/notices alongside rows. type StatementQueryMessageExecer interface { From 85648b1e5ac15d15f3a7f760d0de868ed1341f1a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:31:42 +0800 Subject: [PATCH 21/61] =?UTF-8?q?=F0=9F=94=A7=20chore(wails):=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20DiagnoseQuery=20=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=9A=84=20TS=20=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重新生成 App.d.ts / App.js:暴露 DiagnoseQuery(connectionConfig, dbName, sql) 入口 - 更新 models.ts:新增 connection.ExplainResult / ExplainNode / ExplainStats 等类型 - 同步 Service.d.ts / Service.js / package.json.md5(构建缓存指纹) --- frontend/package.json.md5 | 2 +- frontend/wailsjs/go/app/App.d.ts | 2 ++ frontend/wailsjs/go/app/App.js | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 55123bf..1d5d62c 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -eccaaf323f1be46f3102979e48be98e2 +eccaaf323f1be46f3102979e48be98e2 \ No newline at end of file diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 40d2c0d..01665b1 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -82,6 +82,8 @@ export function DeleteSQLDirectory(arg1:string):Promise; export function DeleteSQLFile(arg1:string):Promise; +export function DiagnoseQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function DismissSecurityUpdateReminder():Promise; export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 410ec6d..c43680c 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -154,6 +154,10 @@ export function DeleteSQLFile(arg1) { return window['go']['app']['App']['DeleteSQLFile'](arg1); } +export function DiagnoseQuery(arg1, arg2, arg3) { + return window['go']['app']['App']['DiagnoseQuery'](arg1, arg2, arg3); +} + export function DismissSecurityUpdateReminder() { return window['go']['app']['App']['DismissSecurityUpdateReminder'](); } From 8e24e40fdd8d0d1a6f4952bea8962e7b0b017eba Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:45:15 +0800 Subject: [PATCH 22/61] =?UTF-8?q?=E2=9C=A8=20feat(explain):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20Oracle/SQLServer/ClickHouse=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E7=B4=A2=E5=BC=95=E5=BB=BA=E8=AE=AE=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器 - 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等) - 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果 - 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合 - 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景 --- internal/app/explain_parse_clickhouse.go | 241 +++++++++ internal/app/explain_parse_clickhouse_test.go | 128 +++++ internal/app/explain_parse_oracle.go | 457 ++++++++++++++++++ internal/app/explain_parse_oracle_test.go | 126 +++++ internal/app/explain_parse_sqlserver.go | 306 ++++++++++++ internal/app/explain_parse_sqlserver_test.go | 153 ++++++ internal/app/explain_rules.go | 434 +++++++++++++++++ internal/app/explain_rules_test.go | 249 ++++++++++ internal/app/methods_explain.go | 31 +- 9 files changed, 2100 insertions(+), 25 deletions(-) create mode 100644 internal/app/explain_parse_clickhouse.go create mode 100644 internal/app/explain_parse_clickhouse_test.go create mode 100644 internal/app/explain_parse_oracle.go create mode 100644 internal/app/explain_parse_oracle_test.go create mode 100644 internal/app/explain_parse_sqlserver.go create mode 100644 internal/app/explain_parse_sqlserver_test.go create mode 100644 internal/app/explain_rules.go create mode 100644 internal/app/explain_rules_test.go diff --git a/internal/app/explain_parse_clickhouse.go b/internal/app/explain_parse_clickhouse.go new file mode 100644 index 0000000..2b01dd9 --- /dev/null +++ b/internal/app/explain_parse_clickhouse.go @@ -0,0 +1,241 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// ClickHouse EXPLAIN 解析。 +// +// ClickHouse 支持多种 EXPLAIN 模式: +// - EXPLAIN(默认 PLAN 模式):返回 1 行 1 列,列值是缩进文本树 +// - EXPLAIN JSON:返回 1 行 1 列,列值是 JSON 字符串 +// - EXPLAIN AST:返回抽象语法树 +// - EXPLAIN SYNTAX:返回重写后的 SQL +// - EXPLAIN PIPELINE:返回执行算子管道 +// +// 本解析器只处理 JSON 模式(由 buildExplainQuery 选用)。 +// +// JSON 结构(PLAN 模式): +// +// { +// "Plan": { +// "Node Type": "ReadFromMergeTree", +// "Joined Plans": [], +// "ReadType": "Default", +// "Parts": 12, +// "Index Granules": 240, +// "Result Schema": {...} +// }, +// "Plan": { +// "Node Type": "Aggregating", +// "Aggregation": { +// "Keys": ["user_id"], +// "Functions": ["count()"] +// } +// } +// } +// +// 注意:CH EXPLAIN JSON 的顶层是 {"Plan": {...}},但通过 collectExplainRaw 收集后, +// 单行单列的 JSON 文本可能被多次封装。本解析器直接处理 JSON 字符串。 + +func parseClickHouseExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: "clickhouse", + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("ClickHouse EXPLAIN 输出为空") + } + + // ClickHouse EXPLAIN JSON 可能是对象或数组形式 + var top map[string]json.RawMessage + var topArr []map[string]json.RawMessage + + isArr := strings.HasPrefix(trimmed, "[") + if isArr { + if err := json.Unmarshal([]byte(trimmed), &topArr); err != nil { + return result, fmt.Errorf("ClickHouse JSON 数组解析失败:%w", err) + } + } else { + if err := json.Unmarshal([]byte(trimmed), &top); err != nil { + return result, fmt.Errorf("ClickHouse JSON 对象解析失败:%w", err) + } + } + + var warnings []string + // 兼容两种形式 + plans := []map[string]json.RawMessage{} + if isArr { + plans = topArr + } else { + plans = append(plans, top) + } + + for _, item := range plans { + planRaw, ok := item["Plan"] + if !ok { + // CH 默认 EXPLAIN 模式可能不返回 Plan 而是直接给节点字段 + planRaw, ok = jsonMarshalRaw(item) + if !ok { + continue + } + } + parseClickHousePlan(planRaw, "", &result, &warnings) + } + + if len(result.Nodes) == 0 { + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = append(warnings, "未提取到 ClickHouse 计划节点,可能不是 PLAN 模式") + return result, nil + } + + result.RawFormat = connection.ExplainFormatJSON + result.RawPayload = raw + result.Warnings = warnings + finalizeExplainStats(&result) + return result, nil +} + +// clickHousePlanNode 映射 CH EXPLAIN JSON 的 Plan 结构。 +type clickHousePlanNode struct { + NodeType string `json:"Node Type"` + Operation string `json:"Operation"` + ReadType string `json:"ReadType"` + Parts int64 `json:"Parts"` + IndexGranules int64 `json:"Index Granules"` + SelectedMarks int64 `json:"Selected Marks"` + ResultSchema map[string]any `json:"Result Schema"` + Aggregation map[string]any `json:"Aggregation"` + Join map[string]any `json:"Join"` + Expression map[string]any `json:"Expression"` + Table string `json:"Table"` + Database string `json:"Database"` + JoinedPlans []json.RawMessage `json:"Joined Plans"` + Children []json.RawMessage `json:"Children"` // 部分版本用此字段 +} + +// parseClickHousePlan 递归解析 CH Plan 节点。 +// CH 通常用 "Joined Plans" 数组持有子节点(不同于其他 DB 的 "Plans")。 +func parseClickHousePlan(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) { + var node clickHousePlanNode + if err := json.Unmarshal(planRaw, &node); err != nil { + *warnings = append(*warnings, fmt.Sprintf("CH Plan 节点反序列化失败:%v", err)) + return + } + + en := connection.ExplainNode{ + OpType: classifyClickHouseNodeType(node.NodeType), + OpDetail: node.NodeType, + } + if node.Operation != "" { + en.OpDetail = en.OpDetail + " / " + node.Operation + } + + // 表/库信息 + if node.Table != "" { + if node.Database != "" { + en.Table = node.Database + "." + node.Table + } else { + en.Table = node.Table + } + } + + // 行数估算:CH 没有"估算行数"概念,用 Parts × Index Granules 作为扫描量的粗略代理 + // 这是 CH 的特点:粒度(granule)是默认 8192 行,所以 granules × 8192 ≈ 扫描行数 + if node.Parts > 0 || node.IndexGranules > 0 { + en.EstRows = node.IndexGranules * 8192 + en.Extra = map[string]any{ + "parts": node.Parts, + "indexGranules": node.IndexGranules, + "selectedMarks": node.SelectedMarks, + } + } + + // Aggregation/Join 等元信息 + if len(node.Aggregation) > 0 { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["aggregation"] = node.Aggregation + en.Flags = append(en.Flags, connection.ExplainFlagTempTable) + } + if len(node.Join) > 0 { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["join"] = node.Join + } + + // CH 的 ReadFromMergeTree 在没有索引筛选时类似全表扫描 + if strings.Contains(strings.ToLower(node.NodeType), "readfrommergetree") { + // ReadType=Default 表示未使用 primary key 裁剪 + if strings.ToLower(node.ReadType) == "default" || node.ReadType == "" { + en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + } + + // Sort/OrderBy + if strings.Contains(strings.ToLower(node.NodeType), "sorting") || strings.Contains(strings.ToLower(node.NodeType), "orderby") { + en.Flags = append(en.Flags, connection.ExplainFlagFilesort) + } + + nodeID := appendExplainChild(result, parentID, en) + + // 递归子节点:CH 用 "Joined Plans",部分版本可能用 "Children" + for _, childRaw := range node.JoinedPlans { + parseClickHousePlan(childRaw, nodeID, result, warnings) + } + for _, childRaw := range node.Children { + parseClickHousePlan(childRaw, nodeID, result, warnings) + } +} + +// classifyClickHouseNodeType 把 CH Node Type 归一化到通用 OpType。 +// 参考:https://clickhouse.com/docs/en/operations/explain +func classifyClickHouseNodeType(nodeType string) string { + nt := strings.ToLower(strings.TrimSpace(nodeType)) + switch { + case strings.Contains(nt, "readfrommergetree"), strings.Contains(nt, "readfromstorage"), strings.Contains(nt, "readfrom"): + return connection.ExplainOpScan + case strings.Contains(nt, "filter"): + return connection.ExplainOpFilter + case strings.Contains(nt, "aggregating"), strings.Contains(nt, "aggregatingtransform"): + return connection.ExplainOpAggregate + case strings.Contains(nt, "sorting"), strings.Contains(nt, "orderby"): + return connection.ExplainOpSort + case strings.Contains(nt, "limit"): + return connection.ExplainOpLimit + case strings.Contains(nt, "join"): + return connection.ExplainOpJoin + case strings.Contains(nt, "union"), strings.Contains(nt, "concat"): + return connection.ExplainOpUnion + case strings.Contains(nt, "expression"), strings.Contains(nt, "computescope"): + return connection.ExplainOpOther + case strings.Contains(nt, "creatingsets"), strings.Contains(nt, "creatingsetandfilter"): + return connection.ExplainOpMaterialize + case strings.Contains(nt, "window"): + return connection.ExplainOpWindow + default: + return connection.ExplainOpOther + } +} + +// jsonMarshalRaw 把已解析的 map 重新序列化为 RawMessage(辅助工具)。 +func jsonMarshalRaw(m map[string]json.RawMessage) (json.RawMessage, bool) { + if len(m) == 0 { + return nil, false + } + b, err := json.Marshal(m) + if err != nil { + return nil, false + } + return b, true +} diff --git a/internal/app/explain_parse_clickhouse_test.go b/internal/app/explain_parse_clickhouse_test.go new file mode 100644 index 0000000..43f5157 --- /dev/null +++ b/internal/app/explain_parse_clickhouse_test.go @@ -0,0 +1,128 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// ClickHouse EXPLAIN JSON fixture:ReadFromMergeTree + Aggregating。 +const clickHouseExplainJSONScanAndAggregate = `{ + "Plan": { + "Node Type": "Aggregating", + "Aggregation": { + "Keys": ["user_id"], + "Functions": ["count()"] + }, + "Joined Plans": [ + { + "Node Type": "ReadFromMergeTree", + "ReadType": "Default", + "Parts": 12, + "Index Granules": 240, + "Table": "events", + "Database": "default" + } + ] + } +}` + +func TestParseClickHouseExplain_ScanAndAggregate(t *testing.T) { + result, err := parseClickHouseExplain("SELECT user_id, count() FROM events GROUP BY user_id", clickHouseExplainJSONScanAndAggregate, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点(Aggregating + ReadFromMergeTree),got=%d", len(result.Nodes)) + } + aggNode := result.Nodes[0] + if aggNode.OpType != connection.ExplainOpAggregate { + t.Fatalf("Aggregating 应为 AGGREGATE,got=%s", aggNode.OpType) + } + if !containsFlag(aggNode.Flags, connection.ExplainFlagTempTable) { + t.Fatalf("Aggregating 应有 TEMP_TABLE flag") + } + scanNode := result.Nodes[1] + if scanNode.OpType != connection.ExplainOpScan { + t.Fatalf("ReadFromMergeTree 应为 SCAN,got=%s", scanNode.OpType) + } + if scanNode.Table != "default.events" { + t.Fatalf("Table got=%s want=default.events", scanNode.Table) + } + // EstRows = Index Granules × 8192 = 240 × 8192 = 1966080 + if scanNode.EstRows != 240*8192 { + t.Fatalf("EstRows got=%d want=%d", scanNode.EstRows, 240*8192) + } + if !containsFlag(scanNode.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("ReadType=Default 的 MergeTree 应有 FULL_SCAN flag") + } + if !containsFlag(scanNode.Flags, connection.ExplainFlagNoIndex) { + t.Fatalf("ReadType=Default 的 MergeTree 应有 NO_INDEX flag") + } + // Edges:Aggregating -> ReadFromMergeTree + if len(result.Edges) != 1 || result.Edges[0].From != aggNode.ID || result.Edges[0].To != scanNode.ID { + t.Fatalf("应有 1 条边连接 AGGREGATE -> SCAN") + } +} + +func TestParseClickHouseExplain_IndexedReadNoFullScanFlag(t *testing.T) { + raw := `{ + "Plan": { + "Node Type": "ReadFromMergeTree", + "ReadType": "InReverseOrder", + "Parts": 5, + "Index Granules": 30, + "Table": "t", + "Database": "default" + } + }` + result, err := parseClickHouseExplain("SELECT * FROM default.t ORDER BY id DESC", raw, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + // ReadType 不是 Default,不应触发 FULL_SCAN + if containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("ReadType=InReverseOrder 不应是 FULL_SCAN") + } +} + +func TestParseClickHouseExplain_ArrayForm(t *testing.T) { + raw := `[ + { + "Plan": { + "Node Type": "Limit", + "Joined Plans": [ + {"Node Type": "ReadFromMergeTree", "ReadType": "Default", "Parts": 1, "Index Granules": 10, "Table": "t"} + ] + } + } + ]` + result, err := parseClickHouseExplain("SELECT * FROM t LIMIT 10", raw, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("数组形式解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点(Limit + ReadFromMergeTree),got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpLimit { + t.Fatalf("Limit 节点应为 LIMIT,got=%s", result.Nodes[0].OpType) + } +} + +func TestParseClickHouseExplain_InvalidJSONReturnsError(t *testing.T) { + _, err := parseClickHouseExplain("SELECT 1", "{ not valid json", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("非法 JSON 应返回 error") + } +} + +func TestParseClickHouseExplain_EmptyReturnsError(t *testing.T) { + _, err := parseClickHouseExplain("SELECT 1", " ", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("空输入应返回 error") + } +} diff --git a/internal/app/explain_parse_oracle.go b/internal/app/explain_parse_oracle.go new file mode 100644 index 0000000..8cf6d77 --- /dev/null +++ b/internal/app/explain_parse_oracle.go @@ -0,0 +1,457 @@ +package app + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// Oracle DBMS_XPLAN.DISPLAY 表格解析。 +// +// 典型输出(FORMAT=ALL): +// +// Plan hash value: 1234567890 +// +// -------------------------------------------------------------------------------------------------- +// | Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information | +// -------------------------------------------------------------------------------------------------- +// | 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | | +// |* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) | +// -------------------------------------------------------------------------------------------------- +// +// Query Block Name / Object Alias (identified by operation id): +// ------------------------------------------------------------- +// 1 - SEL$1 / USERS@SEL$1 +// +// Column Projection Information (identified by operation id): +// ----------------------------------------------------------- +// 1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100] +// +// 解析要点: +// - Id 列含 "*"(带 Predicate)或空格(无 Predicate)+ 数字 + 可能空格缩进 +// - Operation 列含前导空格(表达层级深度,每 2 空格代表一层) +// - Name 列通常是表名或索引名 +// - Rows 是估算行数(Bytes 也会给但本解析器暂不消费) +// - Cost (%CPU) 含百分比:50 (4) 表示 cost=50 CPU 占比 4% +// - Predicate Information(下方独立段落)按 Operation Id 列出 Predicate 文本 +// - 多个段落用空行分隔,关键段落:"Plan hash value"、"Query Block Name"、"Predicate Information"、"Column Projection" + +func parseOracleExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: "oracle", + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("Oracle DBMS_XPLAN 输出为空") + } + + // 抽取 Predicate Information 段落(按 id 索引) + predicates := extractOraclePredicates(trimmed) + + // 抽取主表格 + tableSection := extractOraclePlanTable(trimmed) + if tableSection == "" { + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = []string{"未识别到 DBMS_XPLAN 表格段落,可能版本不兼容"} + return result, nil + } + + // 解析表格行(管道符分隔的列) + rows := extractOracleTableRows(tableSection) + if len(rows) == 0 { + return result, fmt.Errorf("DBMS_XPLAN 表格无有效行") + } + + // 解析列头识别列索引 + headerCols := splitOracleTableRow(rows[0]) + colID := findOracleColumnIndex(headerCols, "Id") + colOp := findOracleColumnIndex(headerCols, "Operation") + colName := findOracleColumnIndex(headerCols, "Name") + colRows := findOracleColumnIndex(headerCols, "Rows") + colCost := findOracleColumnIndex(headerCols, "Cost") + colTime := findOracleColumnIndex(headerCols, "Time") + colPredicate := findOracleColumnIndex(headerCols, "Predicate") + if colID < 0 || colOp < 0 { + return result, fmt.Errorf("DBMS_XPLAN 表格缺少 Id 或 Operation 列") + } + + // 按 Operation 的缩进推断父子(每 2 个前导空格代表一层) + type pendingNode struct { + node connection.ExplainNode + indent int + } + var stack []pendingNode // 每层保留最近一个节点 + + for i := 1; i < len(rows); i++ { + cols := splitOracleTableRow(rows[i]) + if len(cols) == 0 { + continue + } + idRaw := strings.TrimSpace(safeOracleColumn(cols, colID)) + idNum := parseOracleIDNumber(idRaw) + if idNum < 0 { + continue + } + opText := strings.TrimSpace(safeOracleColumn(cols, colOp)) + indent := countLeadingSpaces(safeOracleColumn(cols, colOp)) + name := strings.TrimSpace(safeOracleColumn(cols, colName)) + rowsEst := parseExplainInt64(strings.TrimSpace(safeOracleColumn(cols, colRows))) + cost, _ := parseOracleCost(safeOracleColumn(cols, colCost)) + timeMs := parseOracleTimeMs(safeOracleColumn(cols, colTime)) + + node := connection.ExplainNode{ + OpType: classifyOracleOperation(opText), + OpDetail: opText, + Table: name, + EstRows: rowsEst, + Cost: cost, + DurationMs: timeMs, + } + // TABLE ACCESS FULL 是全表扫描 + if isOracleFullScan(opText) { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } else if isOracleIndexAccess(opText) { + node.Index = name + } + // 关联 Predicate Information:先从表格内 Predicate 列取(简短摘要) + if colPredicate >= 0 && colPredicate < len(cols) { + predCell := strings.TrimSpace(safeOracleColumn(cols, colPredicate)) + if predCell != "" { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["filter"] = predCell + if strings.Contains(strings.ToLower(predCell), "filter") { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan) + } + } + } + // 独立 Predicate Information 段落更详细,覆盖表格列的简短摘要 + if pred, ok := predicates[idNum]; ok && pred != "" { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["filter"] = pred + if strings.Contains(strings.ToLower(pred), "filter") { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan) + } + } + + // 推断父子:弹出栈中 indent >= 当前的节点 + for len(stack) > 0 && stack[len(stack)-1].indent >= indent { + stack = stack[:len(stack)-1] + } + parentNodeID := "" + if len(stack) > 0 { + parentNodeID = stack[len(stack)-1].node.ID + } + nodeID := appendExplainChild(&result, parentNodeID, node) + stack = append(stack, pendingNode{node: connection.ExplainNode{ID: nodeID}, indent: indent}) + } + + result.RawFormat = connection.ExplainFormatTable + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil +} + +// extractOraclePlanTable 提取主表格段落。 +// DBMS_XPLAN 表格结构: +// +// [空行] +// Plan hash value: ... +// [空行] +// --------- ← 上边界分隔线 +// | header | +// --------- ← 表头/数据分隔(可选) +// | data | +// --------- ← 下边界分隔线 +// [空行] ← 表格段结束(之后的 Query Block Name / Predicate Information 等段落不再计入) +// +// 实现策略:找到第一条分隔线后开始累积;跳过所有分隔线、保留表头+数据;遇到空行结束。 +func extractOraclePlanTable(raw string) string { + lines := strings.Split(raw, "\n") + startIdx := -1 + for i, line := range lines { + if isOracleTableSeparator(line) { + startIdx = i + break + } + } + if startIdx < 0 { + return "" + } + var builder strings.Builder + for i := startIdx; i < len(lines); i++ { + line := lines[i] + if strings.TrimSpace(line) == "" { + break // 空行 = 表格段结束 + } + if isOracleTableSeparator(line) { + continue // 跳过表格内的所有分隔线(上边界/表头分隔/下边界) + } + builder.WriteString(line) + builder.WriteByte('\n') + } + return builder.String() +} + +// isOracleTableSeparator 判断是否是 DBMS_XPLAN 表格的分隔线(全是 -)。 +func isOracleTableSeparator(line string) bool { + trimmed := strings.TrimSpace(line) + if len(trimmed) < 10 { + return false + } + for _, ch := range trimmed { + if ch != '-' { + return false + } + } + return true +} + +// extractOracleTableRows 提取表格的每行内容(去掉首尾管道符,保留中间内容)。 +// 返回不含分隔线的纯数据行。 +func extractOracleTableRows(table string) []string { + lines := strings.Split(strings.TrimSpace(table), "\n") + var rows []string + for _, line := range lines { + trimmed := strings.TrimRight(line, "\r\n ") + if strings.TrimSpace(trimmed) == "" { + continue + } + if isOracleTableSeparator(trimmed) { + continue + } + rows = append(rows, trimmed) + } + return rows +} + +// splitOracleTableRow 按管道符 | 切分行。 +// 处理:去首尾管道符 → 按 | 切分 → 不 trim(保留前导空格用于缩进判断)。 +func splitOracleTableRow(line string) []string { + // 去掉首尾的 | 和前后空白 + text := strings.TrimSpace(line) + text = strings.TrimPrefix(text, "|") + text = strings.TrimSuffix(text, "|") + if text == "" { + return nil + } + parts := strings.Split(text, "|") + // 不 trim,保留前导空格(用于 Operation 缩进分析) + return parts +} + +// findOracleColumnIndex 在表头中按列名查找索引。 +func findOracleColumnIndex(headerCols []string, name string) int { + target := strings.ToLower(strings.TrimSpace(name)) + for i, col := range headerCols { + if strings.ToLower(strings.TrimSpace(col)) == target { + return i + } + } + // 模糊匹配("Cost (%CPU)" 可能被切成 "Cost " 和 " (%CPU)") + for i, col := range headerCols { + if strings.Contains(strings.ToLower(strings.TrimSpace(col)), target) { + return i + } + } + return -1 +} + +// safeOracleColumn 安全取 cols[idx](idx 越界返回空)。 +func safeOracleColumn(cols []string, idx int) string { + if idx < 0 || idx >= len(cols) { + return "" + } + return cols[idx] +} + +// parseOracleIDNumber 解析 "Id" 列:形如 " 0"、"* 1"、" 2"。 +// 返回数字部分;前缀 "*" 表示带 Predicate,返回正值;无数字返回 -1。 +func parseOracleIDNumber(s string) int { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return -1 + } + // 去掉可能的 Predicate 标记 + trimmed = strings.TrimLeft(trimmed, "* ") + n, err := strconv.Atoi(trimmed) + if err != nil { + return -1 + } + return n +} + +// parseOracleCost 解析 "Cost (%CPU)" 列,形如 "50 (4)"。 +// 返回 cost 数值 + CPU 百分比(百分比暂未使用)。 +func parseOracleCost(s string) (float64, int) { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return 0, 0 + } + // 取第一个空白前的数字 + for i, ch := range trimmed { + if ch == ' ' || ch == '\t' { + n, _ := strconv.ParseFloat(trimmed[:i], 64) + return n, 0 + } + } + n, _ := strconv.ParseFloat(trimmed, 64) + return n, 0 +} + +// parseOracleTimeMs 解析 "Time" 列,形如 "00:00:01"。 +// 转换为毫秒(粗略,仅用于 stats)。 +func parseOracleTimeMs(s string) float64 { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return 0 + } + parts := strings.Split(trimmed, ":") + if len(parts) != 3 { + return 0 + } + h, _ := strconv.Atoi(strings.TrimSpace(parts[0])) + m, _ := strconv.Atoi(strings.TrimSpace(parts[1])) + sec, _ := strconv.Atoi(strings.TrimSpace(parts[2])) + return float64(h*3600+m*60+sec) * 1000 +} + +// countLeadingSpaces 数字符串的前导空格数(用于推断 Oracle Operation 缩进层级)。 +func countLeadingSpaces(s string) int { + n := 0 + for _, ch := range s { + if ch == ' ' { + n++ + continue + } + break + } + return n +} + +// classifyOracleOperation 把 Oracle Operation 文本归一化。 +// 形如 "TABLE ACCESS FULL" → SCAN;"INDEX RANGE SCAN" → INDEX_SCAN;"HASH JOIN" → JOIN。 +func classifyOracleOperation(op string) string { + upper := strings.ToUpper(strings.TrimSpace(op)) + switch { + case strings.Contains(upper, "TABLE ACCESS") && strings.Contains(upper, "FULL"): + return connection.ExplainOpScan + case strings.Contains(upper, "INDEX") && (strings.Contains(upper, "RANGE SCAN") || strings.Contains(upper, "UNIQUE SCAN") || strings.Contains(upper, "SKIP SCAN")): + return connection.ExplainOpIndexScan + case strings.Contains(upper, "INDEX") && strings.Contains(upper, "FAST FULL"): + return connection.ExplainOpIndexOnly + case strings.Contains(upper, "HASH JOIN"): + return connection.ExplainOpJoin + case strings.Contains(upper, "NESTED LOOPS"): + return connection.ExplainOpJoin + case strings.Contains(upper, "MERGE JOIN"): + return connection.ExplainOpJoin + case strings.Contains(upper, "SORT") && strings.Contains(upper, "ORDER BY"): + return connection.ExplainOpSort + case strings.Contains(upper, "SORT") && strings.Contains(upper, "GROUP BY"): + return connection.ExplainOpAggregate + case strings.Contains(upper, "HASH GROUP BY") || strings.Contains(upper, "AGGREGATE"): + return connection.ExplainOpAggregate + case strings.Contains(upper, "COUNT"): + return connection.ExplainOpAggregate + case strings.Contains(upper, "VIEW"): + return connection.ExplainOpOther + case strings.Contains(upper, "UNION"): + return connection.ExplainOpUnion + case strings.Contains(upper, "FILTER"): + return connection.ExplainOpFilter + case strings.Contains(upper, "SELECT STATEMENT"): + return connection.ExplainOpOther + default: + return connection.ExplainOpOther + } +} + +// isOracleFullScan 判断是否是全表扫描。 +func isOracleFullScan(op string) bool { + return strings.Contains(strings.ToUpper(op), "TABLE ACCESS") && strings.Contains(strings.ToUpper(op), "FULL") +} + +// isOracleIndexAccess 判断是否是索引访问(用于决定 Name 字段是索引名)。 +func isOracleIndexAccess(op string) bool { + upper := strings.ToUpper(op) + if !strings.Contains(upper, "INDEX") { + return false + } + return strings.Contains(upper, "SCAN") || strings.Contains(upper, "RANGE") || strings.Contains(upper, "UNIQUE") +} + +// extractOraclePredicates 从原文中提取 "Predicate Information" 段落,按 id 索引。 +// 返回 map[int]string,键是 Operation Id,值是对应的 Predicate 文本(多行合并)。 +func extractOraclePredicates(raw string) map[int]string { + result := map[int]string{} + lines := strings.Split(raw, "\n") + inSection := false + currentID := -1 + var buffer strings.Builder + + idPattern := regexp.MustCompile(`^\s*\*?(\d+)\s*-?\s*(.*)$`) + + flush := func() { + if currentID >= 0 { + text := strings.TrimSpace(buffer.String()) + if text != "" { + result[currentID] = text + } + } + currentID = -1 + buffer.Reset() + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + + if strings.HasPrefix(lower, "predicate information") { + inSection = true + continue + } + if !inSection { + continue + } + // 进入段落后的空行或下一个段落标题 → 结束 + if trimmed == "" || isOracleNextSectionHeader(lower) { + flush() + break + } + // 匹配 " 1 - access("ID"=1)" 或 " 1 - filter(...)" + match := idPattern.FindStringSubmatch(line) + if match != nil { + flush() + id, _ := strconv.Atoi(match[1]) + currentID = id + buffer.WriteString(strings.TrimSpace(match[2])) + continue + } + // 多行 Predicate 续行 + if currentID >= 0 { + buffer.WriteByte(' ') + buffer.WriteString(trimmed) + } + } + flush() + return result +} + +// isOracleNextSectionHeader 判断是否是 DBMS_XPLAN 的下一个段落标题(结束 Predicate 段)。 +func isOracleNextSectionHeader(lower string) bool { + return strings.HasPrefix(lower, "query block name") || + strings.HasPrefix(lower, "column projection") || + strings.HasPrefix(lower, "note") || + strings.HasPrefix(lower, "hint") +} diff --git a/internal/app/explain_parse_oracle_test.go b/internal/app/explain_parse_oracle_test.go new file mode 100644 index 0000000..4622086 --- /dev/null +++ b/internal/app/explain_parse_oracle_test.go @@ -0,0 +1,126 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// Oracle DBMS_XPLAN.DISPLAY fixture:含主表 + Predicate + 多段落。 +const oracleXPlanOutput = `Plan hash value: 1234567890 + +----------------------------------------------------------------------------------------------------------- +| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information | +----------------------------------------------------------------------------------------------------------- +| 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | | +|* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) | +----------------------------------------------------------------------------------------------------------- + +Query Block Name / Object Alias (identified by operation id): +------------------------------------------------------------- + 1 - SEL$1 / USERS@SEL$1 + +Column Projection Information (identified by operation id): +----------------------------------------------------------- + 1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100] +` + +func TestParseOracleExplain_TableAccessFullWithPredicate(t *testing.T) { + result, err := parseOracleExplain("SELECT * FROM users WHERE age > 18", oracleXPlanOutput, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点(SELECT STATEMENT + TABLE ACCESS),got=%d", len(result.Nodes)) + } + // 节点 0 是 SELECT STATEMENT,节点 1 是 TABLE ACCESS FULL(带缩进,挂在 0 下) + scan := result.Nodes[1] + if scan.OpType != connection.ExplainOpScan { + t.Fatalf("TABLE ACCESS FULL 应为 SCAN,got=%s", scan.OpType) + } + if scan.Table != "USERS" { + t.Fatalf("table got=%s want=USERS", scan.Table) + } + if scan.EstRows != 10000 { + t.Fatalf("EstRows got=%d want=10000", scan.EstRows) + } + if scan.Cost != 50 { + t.Fatalf("Cost got=%v want=50", scan.Cost) + } + if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("TABLE ACCESS FULL 应有 FULL_SCAN flag") + } + if scan.Extra["filter"] != `filter ("AGE">18)` { + t.Fatalf("Predicate 应附加到 Extra.filter,got=%v", scan.Extra["filter"]) + } + // SELECT STATEMENT 是父节点 + if len(result.Edges) != 1 || result.Edges[0].To != scan.ID { + t.Fatalf("应有 1 条边指向 TABLE ACCESS 节点") + } +} + +const oracleXPlanHashJoin = `Plan hash value: 9876543210 + +------------------------------------------------------------------------- +| Id | Operation | Name | Rows | Cost | Predicate Info | +------------------------------------------------------------------------- +| 0 | SELECT STATEMENT | | 1000 | 200 | | +| 1 | HASH JOIN | | 1000 | 200 | | +| 2 | TABLE ACCESS FULL| USERS | 100 | 10 | | +| 3 | INDEX RANGE SCAN| ORD_IX | 50000 | 20 |access("UID" = 1) | +------------------------------------------------------------------------- +` + +func TestParseOracleExplain_HashJoinWithNestedChildren(t *testing.T) { + result, err := parseOracleExplain("SELECT * FROM users u JOIN orders o ON u.id = o.user_id", oracleXPlanHashJoin, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 4 { + t.Fatalf("应有 4 个节点(SELECT + HASH JOIN + 2 子节点),got=%d", len(result.Nodes)) + } + // HASH JOIN 是 SELECT STATEMENT 的子 + // TABLE ACCESS FULL 和 INDEX RANGE SCAN 是 HASH JOIN 的子(缩进更深) + hashJoin := result.Nodes[1] + if hashJoin.OpType != connection.ExplainOpJoin { + t.Fatalf("HASH JOIN 应为 JOIN,got=%s", hashJoin.OpType) + } + // 找到 INDEX RANGE SCAN 节点 + var indexNode *connection.ExplainNode + for i := range result.Nodes { + if result.Nodes[i].OpType == connection.ExplainOpIndexScan { + indexNode = &result.Nodes[i] + break + } + } + if indexNode == nil { + t.Fatal("应有一个 INDEX RANGE SCAN 节点") + } + if indexNode.Index != "ORD_IX" { + t.Fatalf("Index got=%s want=ORD_IX", indexNode.Index) + } + // Predicate 关联(id=3,独立 Predicate 段落覆盖了表格列的简短摘要) + if indexNode.Extra["filter"] != `access("UID" = 1)` { + t.Fatalf("Predicate 应附加到 INDEX RANGE SCAN 节点,got=%v", indexNode.Extra["filter"]) + } +} + +func TestParseOracleExplain_EmptyReturnsError(t *testing.T) { + _, err := parseOracleExplain("SELECT 1", " ", connection.ExplainFormatTable) + if err == nil { + t.Fatal("空输入应返回 error") + } +} + +func TestParseOracleExplain_NoTableReturnsWarning(t *testing.T) { + result, err := parseOracleExplain("SELECT 1", "Plan hash value: 1\nsome random text", connection.ExplainFormatTable) + if err != nil { + t.Fatalf("无表格的输入应降级返回 warning 而非 error:%v", err) + } + if result.RawFormat != connection.ExplainFormatText { + t.Fatalf("RawFormat got=%v want=text", result.RawFormat) + } + if len(result.Warnings) == 0 { + t.Fatal("应有降级 warning") + } +} diff --git a/internal/app/explain_parse_sqlserver.go b/internal/app/explain_parse_sqlserver.go new file mode 100644 index 0000000..6448e73 --- /dev/null +++ b/internal/app/explain_parse_sqlserver.go @@ -0,0 +1,306 @@ +package app + +import ( + "encoding/xml" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// SQL Server SHOWPLAN_XML 解析。 +// +// 典型 XML 结构(简化): +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// ... +// +// +// +// +// +// +// +// +// +// +// +// 解析要点: +// - RelOp 是核心节点(递归嵌套),每个含 PhysicalOp + LogicalOp + EstimateRows + EstimatedTotalSubtreeCost +// - 嵌套在 RelOp 内的同级 RelOp(在 IndexScan/NestedLoops/Hash 等子元素下)是子节点 +// - PhysicalOp 直接对应执行算子(Clustered Index Scan / Index Seek / Hash Match / Sort / ...) +// - Object 子元素含 Table/Index 信息 +// - Predicate 的 ScalarOperator ScalarString 含过滤条件(供规则引擎提取列名) +// - RunTimeCountersPerThread 含 ActualRows(对应 ANALYZE 信息) + +// sqlServerShowPlanXML 是 SHOWPLAN_XML 顶层文档的 Go 结构(部分字段,未识别的留 raw)。 +type sqlServerShowPlanXML struct { + XMLName xml.Name `xml:"ShowPlanXML"` + Batches []sqlServerXMLBatch `xml:"BatchSequence>Batch"` +} + +type sqlServerXMLBatch struct { + Statements []sqlServerXMLStmtSimple `xml:"Statements>StmtSimple"` +} + +type sqlServerXMLStmtSimple struct { + StatementText string `xml:"StatementText,attr"` + QueryPlan *sqlServerXMLPlan `xml:"QueryPlan"` +} + +type sqlServerXMLPlan struct { + RelOps []sqlServerXMLRelOp `xml:"RelOp"` +} + +// sqlServerXMLRelOp 是 SHOWPLAN_XML 的核心节点;嵌套子 RelOp 通过多种容器元素持有。 +// 为简化解析:先把所有层级的 RelOp 平铺出来(按 NodeId 排序),再按 NodeId 父子推断。 +type sqlServerXMLRelOp struct { + NodeID int `xml:"NodeId,attr"` + PhysicalOp string `xml:"PhysicalOp,attr"` + LogicalOp string `xml:"LogicalOp,attr"` + EstimateRows float64 `xml:"EstimateRows,attr"` + EstimateIO float64 `xml:"EstimateIO,attr"` + EstimateCPU float64 `xml:"EstimateCPU,attr"` + EstimatedTotalSubtreeCost float64 `xml:"EstimatedTotalSubtreeCost,attr"` + EstimateRebinds float64 `xml:"EstimateRebinds,attr"` + Parallel int `xml:"Parallel,attr"` + // 子节点容器(不同 PhysicalOp 对应不同容器名,这里全收) + Objects []sqlServerXMLObject `xml:"Object"` + IndexScan *sqlServerXMLContainer `xml:"IndexScan"` + NestedLoops *sqlServerXMLContainer `xml:"NestedLoops"` + Hash *sqlServerXMLContainer `xml:"Hash"` + Merge *sqlServerXMLContainer `xml:"Merge"` + Concat *sqlServerXMLContainer `xml:"Concat"` + Sort *sqlServerXMLContainer `xml:"Sort"` + Filter *sqlServerXMLContainer `xml:"Filter"` + ComputeScalar *sqlServerXMLContainer `xml:"ComputeScalar"` + Top *sqlServerXMLContainer `xml:"Top"` + GenericRelOps []sqlServerXMLContainer `xml:",any"` // 兜底:未识别容器 + Predicate *sqlServerXMLPredicate `xml:"Predicate"` + RunTimeInfo *sqlServerXMLRunTimeInfo `xml:"RunTimeInformation"` +} + +type sqlServerXMLObject struct { + Database string `xml:"Database,attr"` + Schema string `xml:"Schema,attr"` + Table string `xml:"Table,attr"` + Index string `xml:"Index,attr"` +} + +type sqlServerXMLContainer struct { + Objects []sqlServerXMLObject `xml:"Object"` + RelOps []sqlServerXMLRelOp `xml:"RelOp"` + Predicate *sqlServerXMLPredicate `xml:"Predicate"` +} + +type sqlServerXMLPredicate struct { + ScalarString string `xml:"ScalarOperator>ScalarString"` +} + +type sqlServerXMLRunTimeInfo struct { + RunTimeCounters []sqlServerXMLRunTimeCounter `xml:"RunTimeCountersPerThread"` +} + +type sqlServerXMLRunTimeCounter struct { + ActualRows int64 `xml:"ActualRows,attr"` + ActualElapsedMs float64 `xml:"ActualElapsedms,attr"` + ActualScans int64 `xml:"ActualScans,attr"` +} + +func parseSQLServerExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: "sqlserver", + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("SHOWPLAN_XML 输出为空") + } + + // SQL Server 输出的 XML 含 `` 声明,Go encoding/xml 不支持 utf-16 + // 直接报 "encoding declared but not supported"。从 标签开始截取即可规避。 + showPlanStart := strings.Index(trimmed, " 0 { + obj := rel.Objects[0] + node.Table = stripSQLServerBrackets(obj.Table) + node.Index = stripSQLServerBrackets(obj.Index) + } + + // Predicate + if rel.Predicate != nil && rel.Predicate.ScalarString != "" { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["filter"] = rel.Predicate.ScalarString + } + + // ActualRows(来自 RunTimeCountersPerThread 累加) + if rel.RunTimeInfo != nil { + var actualRows int64 + var elapsedMs float64 + for _, c := range rel.RunTimeInfo.RunTimeCounters { + actualRows += c.ActualRows + elapsedMs += c.ActualElapsedMs + } + node.ActualRows = actualRows + node.DurationMs = elapsedMs + } + + // 物理算子归类 + switch node.OpType { + case connection.ExplainOpScan: + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + case connection.ExplainOpSort: + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + case connection.ExplainOpAggregate, connection.ExplainOpMaterialize: + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + + // 关联 LogicalOp 优化提示 + if strings.Contains(strings.ToLower(rel.LogicalOp), "aggregate") { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + + nodeID := appendExplainChild(result, parentID, node) + nodeByOpID[rel.NodeID] = nodeID + + // 递归所有可能的容器 + containers := []*sqlServerXMLContainer{ + rel.IndexScan, rel.NestedLoops, rel.Hash, rel.Merge, + rel.Concat, rel.Sort, rel.Filter, rel.ComputeScalar, rel.Top, + } + for _, c := range containers { + if c == nil { + continue + } + for i := range c.RelOps { + parseSQLServerRelOp(&c.RelOps[i], nodeID, result, nodeByOpID) + } + } + // GenericRelOps 是 ,any 兜底容器,按需递归 + for i := range rel.GenericRelOps { + for j := range rel.GenericRelOps[i].RelOps { + parseSQLServerRelOp(&rel.GenericRelOps[i].RelOps[j], nodeID, result, nodeByOpID) + } + } +} + +// classifySQLServerPhysicalOp 把 SQLServer PhysicalOp/LogicalOp 归一化到通用 OpType。 +// 参考官方文档:Clustered Index Scan / Index Seek / RID Lookup / Key Lookup / Hash Match / Nested Loops / +// Merge Join / Sort / Stream Aggregate / Filter / Compute Scalar / Top / Spool / Table-valued function。 +func classifySQLServerPhysicalOp(physical, logical string) string { + p := strings.ToLower(strings.TrimSpace(physical)) + l := strings.ToLower(strings.TrimSpace(logical)) + switch { + case strings.Contains(p, "index scan"), strings.Contains(p, "clustered index scan"), strings.Contains(p, "table scan"): + return connection.ExplainOpScan + case strings.Contains(p, "index seek"), strings.Contains(p, "clustered index seek"): + return connection.ExplainOpIndexScan + case strings.Contains(p, "key lookup"), strings.Contains(p, "rid lookup"): + return connection.ExplainOpIndexScan + case strings.Contains(p, "hash match"), strings.Contains(p, "nested loops"), strings.Contains(p, "merge join"): + return connection.ExplainOpJoin + case strings.Contains(p, "sort"): + return connection.ExplainOpSort + case strings.Contains(l, "aggregate"), strings.Contains(p, "stream aggregate"), strings.Contains(p, "hash match") && strings.Contains(l, "aggregate"): + return connection.ExplainOpAggregate + case strings.Contains(p, "filter"): + return connection.ExplainOpFilter + case strings.Contains(p, "top"): + return connection.ExplainOpLimit + case strings.Contains(p, "spool"): + return connection.ExplainOpMaterialize + case strings.Contains(p, "compute scalar"): + return connection.ExplainOpOther + default: + return connection.ExplainOpOther + } +} + +// stripSQLServerBrackets 去掉 SQLServer 标识符的方括号:[users] → users。 +func stripSQLServerBrackets(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + return s +} diff --git a/internal/app/explain_parse_sqlserver_test.go b/internal/app/explain_parse_sqlserver_test.go new file mode 100644 index 0000000..009f2d5 --- /dev/null +++ b/internal/app/explain_parse_sqlserver_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// SQL Server SHOWPLAN_XML fixture:单 Clustered Index Scan + Predicate + RunTime。 +const sqlServerShowPlanXMLSingleScan = ` + + + + + + + + + + + + + + + + + + +` + +func TestParseSQLServerExplain_ClusteredIndexScan(t *testing.T) { + result, err := parseSQLServerExplain("SELECT * FROM users WHERE age > 18", sqlServerShowPlanXMLSingleScan, connection.ExplainFormatXML) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个 RelOp 节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpScan { + t.Fatalf("Clustered Index Scan 应为 SCAN,got=%s", node.OpType) + } + if node.Table != "users" { + t.Fatalf("Table got=%s want=users", node.Table) + } + if node.Index != "PK_users" { + t.Fatalf("Index got=%s want=PK_users", node.Index) + } + if node.EstRows != 10000 { + t.Fatalf("EstRows got=%d want=10000", node.EstRows) + } + if node.ActualRows != 10000 { + t.Fatalf("ActualRows got=%d want=10000", node.ActualRows) + } + if node.DurationMs != 5 { + t.Fatalf("DurationMs got=%v want=5", node.DurationMs) + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("Clustered Scan 应有 FULL_SCAN flag") + } +} + +// SQL Server fixture:Nested Loops JOIN + 两个子节点。 +const sqlServerShowPlanXMLNestedLoops = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +func TestParseSQLServerExplain_NestedLoopsRecursesChildren(t *testing.T) { + result, err := parseSQLServerExplain("SELECT * FROM orders o JOIN users u ON o.user_id = u.id", sqlServerShowPlanXMLNestedLoops, connection.ExplainFormatXML) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 3 { + t.Fatalf("应有 3 个节点(Nested Loops + 2 子),got=%d", len(result.Nodes)) + } + joinNode := result.Nodes[0] + if joinNode.OpType != connection.ExplainOpJoin { + t.Fatalf("顶层应为 JOIN,got=%s", joinNode.OpType) + } + // 两个子节点(通过 Edges 验证) + childCount := 0 + for _, e := range result.Edges { + if e.From == joinNode.ID { + childCount++ + } + } + if childCount != 2 { + t.Fatalf("JOIN 应有 2 个直接子节点,got=%d", childCount) + } + // 找到 Index Seek 子节点 + var indexSeek *connection.ExplainNode + for i := range result.Nodes { + if result.Nodes[i].OpType == connection.ExplainOpIndexScan { + indexSeek = &result.Nodes[i] + } + } + if indexSeek == nil { + t.Fatal("应有一个 Index Seek 节点") + } + if indexSeek.Index != "IX_users_id" { + t.Fatalf("Index Seek 应使用 IX_users_id,got=%s", indexSeek.Index) + } +} + +func TestParseSQLServerExplain_InvalidXMLReturnsWarning(t *testing.T) { + result, err := parseSQLServerExplain("SELECT 1", " warning > info +// - 同一节点可能触发多条规则(如 FULL_SCAN 节点同时触发"全表扫描"+"缺索引") +// +// 规则 ID 列表(前端按 ID 显示本地化文案 + 图标): +// - full_scan_on_large_table:大表全表扫描(critical) +// - full_scan_with_filter:带 WHERE 的全表扫描(critical,索引建议价值最高) +// - missing_index_lookup:JOIN 中存在无索引扫描节点(critical) +// - filesort_on_large_result:大结果集排序(warning) +// - temp_table_for_distinct:DISTINCT/GROUP BY 物化临时表(warning) +// - low_buffer_hit_rate:缓冲命中率低(warning,需 ANALYZE 才有数据) +// - high_estimation_skew:估算与实际行数偏差大(info,需 ANALYZE) +// - high_total_cost:总成本过高(warning) +// - nested_loop_high_fanout:Nested Loop 高扇出(warning) +// - using_temp_btree_order:SQLite 风格 ORDER BY 临时表(info) + +// 规则阈值常量。值的选择基于工程经验: +// - 1000 行:单节点扫描超过此值视为"非小表" +// - 10000 行:超过此值视为"大表",触发 critical 建议索引 +// - 0.5:缓冲命中率低于 50% 视为差 +// - 10x:估算与实际偏差超过 10 倍视为显著 +const ( + ruleFullScanLargeTableRows int64 = 10000 + ruleFullScanSmallTableRows int64 = 1000 + ruleFilesortRowsThreshold int64 = 5000 + ruleLowBufferHitThreshold float64 = 0.5 + ruleEstimationSkewRatio float64 = 10.0 + ruleHighTotalCostThreshold float64 = 1000.0 + ruleNestedLoopFanoutRows int64 = 10000 +) + +// runExplainRules 对归一化的 ExplainResult 跑全部规则,返回排序后的建议列表。 +// 按 Severity 排序(critical > warning > info),同 Severity 内按 EstRows 降序。 +func runExplainRules(result connection.ExplainResult) []connection.IndexSuggestion { + var suggestions []connection.IndexSuggestion + + // 全局规则(基于 Stats) + if s := ruleHighTotalCost(result); s != nil { + suggestions = append(suggestions, *s) + } + if s := ruleLowBufferHitRate(result); s != nil { + suggestions = append(suggestions, *s) + } + + // 节点级规则 + for _, node := range result.Nodes { + rules := []func(connection.ExplainResult, connection.ExplainNode) *connection.IndexSuggestion{ + ruleFullScanLargeTable, + ruleFullScanWithFilter, + ruleMissingIndexLookup, + ruleFilesortOnLargeResult, + ruleTempTableForDistinct, + ruleHighEstimationSkew, + ruleNestedLoopHighFanout, + ruleUsingTempBTreeOrder, + } + for _, ruleFn := range rules { + if s := ruleFn(result, node); s != nil { + suggestions = append(suggestions, *s) + } + } + } + + sortExplainSuggestions(suggestions) + return suggestions +} + +// sortExplainSuggestions 按 Severity + EstRows 排序(in-place)。 +func sortExplainSuggestions(s []connection.IndexSuggestion) { + // 简单插入排序:建议数量通常 < 20,无需 sort.Slice 的反射开销 + severityRank := map[string]int{ + connection.SeverityCritical: 0, + connection.SeverityWarning: 1, + connection.SeverityInfo: 2, + } + for i := 1; i < len(s); i++ { + for j := i; j > 0; j-- { + si := severityRank[s[j].Severity] + sj := severityRank[s[j-1].Severity] + if si < sj || (si == sj && s[j].EstRows > s[j-1].EstRows) { + s[j], s[j-1] = s[j-1], s[j] + continue + } + break + } + } +} + +// ruleFullScanLargeTable:单节点全表扫描 + 估算行数超过阈值。 +// 严重度:EstRows > 10000 → critical;> 1000 → warning;否则不触发。 +func ruleFullScanLargeTable(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagFullScan) { + return nil + } + if node.EstRows < ruleFullScanSmallTableRows { + return nil + } + severity := connection.SeverityWarning + if node.EstRows >= ruleFullScanLargeTableRows { + severity = connection.SeverityCritical + } + return &connection.IndexSuggestion{ + Severity: severity, + Rule: "full_scan_on_large_table", + Reason: fmt.Sprintf("表 %s 全表扫描,估算扫描 %d 行;考虑为 WHERE/JOIN 条件字段添加索引", node.Table, node.EstRows), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleFullScanWithFilter:带 WHERE 的全表扫描(最有价值的索引建议场景)。 +// 从 attachedCondition / Filter / Extra 提取等式字段,提示用户考虑建索引。 +func ruleFullScanWithFilter(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagFullScan) { + return nil + } + filter := extractNodeFilterText(node) + if filter == "" { + return nil + } + columns := extractEqualityColumns(filter) + if len(columns) == 0 { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityCritical, + Rule: "full_scan_with_filter", + Reason: fmt.Sprintf("表 %s 全表扫描但带 WHERE 条件 %q;建议为字段 %s 建立索引", node.Table, truncateForReason(filter, 60), joinColumnsForReason(columns)), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleMissingIndexLookup:JOIN 中存在无索引扫描节点(NO_INDEX flag)。 +func ruleMissingIndexLookup(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagNoIndex) { + return nil + } + // 已被 full_scan_on_large_table 覆盖时跳过,避免重复 + if hasFlag(node.Flags, connection.ExplainFlagFullScan) { + return nil + } + if node.EstRows < ruleFullScanSmallTableRows { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityCritical, + Rule: "missing_index_lookup", + Reason: fmt.Sprintf("JOIN 节点 %s 未命中索引,估算扫描 %d 行;JOIN 字段需要索引", node.Table, node.EstRows), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleFilesortOnLargeResult:大结果集排序。 +func ruleFilesortOnLargeResult(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagFilesort) { + return nil + } + if node.EstRows < ruleFilesortRowsThreshold { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "filesort_on_large_result", + Reason: fmt.Sprintf("对约 %d 行做额外排序;考虑为 ORDER BY 字段建立索引以避免 filesort", node.EstRows), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleTempTableForDistinct:使用临时表(DISTINCT/GROUP BY)。 +func ruleTempTableForDistinct(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagTempTable) { + return nil + } + // OpDetail 含 distinct/group 时给出更精准的建议 + detail := strings.ToLower(node.OpDetail) + var hint string + switch { + case strings.Contains(detail, "distinct"): + hint = "DISTINCT 物化了临时表" + case strings.Contains(detail, "group"): + hint = "GROUP BY 物化了临时表" + default: + hint = "查询使用了临时表" + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "temp_table_for_distinct", + Reason: fmt.Sprintf("%s;考虑为分组字段建立索引避免物化", hint), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleHighEstimationSkew:估算与实际行数偏差大(需 ANALYZE 才有数据)。 +func ruleHighEstimationSkew(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if node.EstRows <= 0 || node.ActualRows <= 0 { + return nil + } + ratio := float64(node.ActualRows) / float64(node.EstRows) + if ratio < ruleEstimationSkewRatio && ratio > 1.0/ruleEstimationSkewRatio { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityInfo, + Rule: "high_estimation_skew", + Reason: fmt.Sprintf("估算 %d 行 / 实际 %d 行(偏差 %.1fx);统计信息可能过期,考虑 ANALYZE TABLE", node.EstRows, node.ActualRows, ratio), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleNestedLoopHighFanout:Nested Loop 高扇出。 +// 触发条件:JOIN 节点 + 子节点(被驱动表)估算行数 > 10000。 +func ruleNestedLoopHighFanout(result connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if node.OpType != connection.ExplainOpJoin { + return nil + } + // 找到该 JOIN 的直接子节点(被驱动表) + var maxChildRows int64 + for _, edge := range result.Edges { + if edge.From != node.ID { + continue + } + for _, child := range result.Nodes { + if child.ID == edge.To && child.EstRows > maxChildRows { + maxChildRows = child.EstRows + } + } + } + if maxChildRows < ruleNestedLoopFanoutRows { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "nested_loop_high_fanout", + Reason: fmt.Sprintf("Nested Loop JOIN 被驱动表估算 %d 行,扇出过大;考虑改用 Hash Join 或为 JOIN 字段加索引", maxChildRows), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: maxChildRows, + } +} + +// ruleUsingTempBTreeOrder:SQLite 风格的 ORDER BY 临时表(Info 级,提示性)。 +func ruleUsingTempBTreeOrder(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagFilesort) { + return nil + } + if node.EstRows >= ruleFilesortRowsThreshold { + return nil // 已被 filesort_on_large_result 覆盖 + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityInfo, + Rule: "using_temp_btree_order", + Reason: "ORDER BY 使用临时 B-Tree;如频繁执行,为排序字段建立索引可消除该开销", + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleHighTotalCost:总成本过高(全局规则)。 +func ruleHighTotalCost(result connection.ExplainResult) *connection.IndexSuggestion { + if result.Stats.TotalCost < ruleHighTotalCostThreshold { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "high_total_cost", + Reason: fmt.Sprintf("执行计划总成本 %.1f;考虑重写查询或加索引降低扫描量", result.Stats.TotalCost), + EstRows: result.Stats.RowsRead, + } +} + +// ruleLowBufferHitRate:缓冲命中率低(全局规则,PG/Oracle 才有此数据)。 +func ruleLowBufferHitRate(result connection.ExplainResult) *connection.IndexSuggestion { + if result.Stats.BufferHitRate <= 0 || result.Stats.BufferHitRate >= ruleLowBufferHitThreshold { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "low_buffer_hit_rate", + Reason: fmt.Sprintf("缓冲命中率仅 %.1f%%;热门数据可能未被缓存,考虑增大 shared_buffers 或检查访问模式", result.Stats.BufferHitRate*100), + EstRows: result.Stats.RowsRead, + } +} + +// hasFlag 检查节点是否含指定 flag。 +func hasFlag(flags []string, target string) bool { + for _, f := range flags { + if f == target { + return true + } + } + return false +} + +// extractNodeFilterText 从节点的 attached_condition / Filter / Extra 中提取过滤条件文本。 +func extractNodeFilterText(node connection.ExplainNode) string { + if node.Extra == nil { + return "" + } + for _, key := range []string{"attachedCondition", "filter"} { + if v, ok := node.Extra[key]; ok { + text := strings.TrimSpace(fmt.Sprintf("%v", v)) + if text != "" && text != "" { + return text + } + } + } + return "" +} + +// extractEqualityColumns 从 SQL 过滤条件中提取等值条件的列名(粗略解析)。 +// 仅识别 "col = ?" / "col = literal" 形式;不处理复杂表达式(OR/函数调用)。 +func extractEqualityColumns(filter string) []string { + if filter == "" { + return nil + } + // 简化:按 AND 切分后取每个等值条件的左边 + parts := splitTopLevelByKeyword(filter, " and ") + seen := make(map[string]struct{}) + var columns []string + for _, part := range parts { + part = strings.TrimSpace(part) + // 去除括号 + part = strings.Trim(part, "() ") + eqIdx := strings.Index(part, "=") + if eqIdx <= 0 { + continue + } + left := strings.TrimSpace(part[:eqIdx]) + // 必须是简单标识符(字母数字下划线 + 点) + if !isSimpleIdentifier(left) { + continue + } + // 右边不是另一个列引用(粗略判断:不含点/字母前缀的字段) + right := strings.TrimSpace(part[eqIdx+1:]) + if isSimpleIdentifier(right) { + continue // col1 = col2 形式不算索引候选 + } + if _, exists := seen[left]; !exists { + seen[left] = struct{}{} + columns = append(columns, left) + } + } + return columns +} + +// splitTopLevelByKeyword 按关键字(不区分大小写)切分字符串,忽略嵌套括号内的匹配。 +func splitTopLevelByKeyword(text, keyword string) []string { + var parts []string + depth := 0 + lower := strings.ToLower(text) + kw := strings.ToLower(keyword) + start := 0 + for i := 0; i < len(lower); i++ { + switch lower[i] { + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + } + if depth > 0 { + continue + } + if strings.HasPrefix(lower[i:], kw) { + parts = append(parts, text[start:i]) + i += len(kw) + start = i + } + } + parts = append(parts, text[start:]) + return parts +} + +// isSimpleIdentifier 判断字符串是否是简单 SQL 标识符(支持 schema.table 形式)。 +func isSimpleIdentifier(s string) bool { + if s == "" { + return false + } + for i, ch := range s { + ok := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.' + if !ok { + return false + } + if i == 0 && ch >= '0' && ch <= '9' { + return false + } + } + return true +} + +// truncateForReason 截断字符串到 maxLen,超出加省略号。 +func truncateForReason(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-1] + "…" +} + +// joinColumnsForReason 把列名列表格式化为人类可读的列表(最多 3 个)。 +func joinColumnsForReason(columns []string) string { + if len(columns) == 0 { + return "" + } + if len(columns) > 3 { + return strings.Join(columns[:3], ", ") + " 等" + } + return strings.Join(columns, ", ") +} diff --git a/internal/app/explain_rules_test.go b/internal/app/explain_rules_test.go new file mode 100644 index 0000000..d3710b8 --- /dev/null +++ b/internal/app/explain_rules_test.go @@ -0,0 +1,249 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// 规则引擎测试:验证各规则在合成 ExplainNode 上的触发与排序。 + +func TestRunExplainRules_FullScanLargeTableCritical(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 100000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + }, + }, + } + suggestions := runExplainRules(result) + if len(suggestions) == 0 { + t.Fatal("全表扫描大表应触发建议") + } + top := suggestions[0] + if top.Severity != connection.SeverityCritical { + t.Fatalf("大表全表扫描应为 critical,got=%s", top.Severity) + } + if top.Rule != "full_scan_with_filter" && top.Rule != "full_scan_on_large_table" { + t.Fatalf("首条建议应与全表扫描相关,got=%s", top.Rule) + } + if top.AffectedTable != "users" { + t.Fatalf("AffectedTable got=%s want=users", top.AffectedTable) + } +} + +func TestRunExplainRules_FullScanSmallTableSuppressed(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM small_table", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "small_table", + EstRows: 100, // 远低于 1000 阈值 + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + }, + }, + } + suggestions := runExplainRules(result) + for _, s := range suggestions { + if s.Rule == "full_scan_on_large_table" || s.Rule == "full_scan_with_filter" { + t.Fatalf("小表(100 行)不应触发 full_scan 规则,got=%+v", s) + } + } +} + +func TestRunExplainRules_FullScanWithFilterExtractsColumns(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users WHERE email = 'x' AND status = 1", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 10000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + Extra: map[string]any{"attachedCondition": "(email = 'x') AND (status = 1)"}, + }, + }, + } + suggestions := runExplainRules(result) + foundFilterRule := false + for _, s := range suggestions { + if s.Rule == "full_scan_with_filter" { + foundFilterRule = true + if !contains(s.Reason, "email") || !contains(s.Reason, "status") { + t.Fatalf("Reason 应提及 email 和 status 列,got=%s", s.Reason) + } + } + } + if !foundFilterRule { + t.Fatal("带 WHERE 的全表扫描应触发 full_scan_with_filter 规则") + } +} + +func TestRunExplainRules_FilesortOnLargeResult(t *testing.T) { + result := connection.ExplainResult{ + DBType: "postgres", + SourceSQL: "SELECT * FROM t ORDER BY id", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpSort, + EstRows: 10000, + Flags: []string{connection.ExplainFlagFilesort}, + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "filesort_on_large_result" { + found = true + if s.Severity != connection.SeverityWarning { + t.Fatalf("filesort 应为 warning,got=%s", s.Severity) + } + } + } + if !found { + t.Fatal("大结果集 filesort 应触发建议") + } +} + +func TestRunExplainRules_HighEstimationSkewRequiresAnalyze(t *testing.T) { + result := connection.ExplainResult{ + DBType: "postgres", + SourceSQL: "SELECT * FROM t WHERE id > 0", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpIndexScan, + EstRows: 100, + ActualRows: 50000, // 偏差 500 倍 + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "high_estimation_skew" { + found = true + if s.Severity != connection.SeverityInfo { + t.Fatalf("估算偏差应为 info,got=%s", s.Severity) + } + } + } + if !found { + t.Fatal("估算/实际偏差 > 10x 应触发建议") + } +} + +func TestRunExplainRules_LowBufferHitRateGlobalRule(t *testing.T) { + result := connection.ExplainResult{ + DBType: "postgres", + SourceSQL: "SELECT * FROM t", + Stats: connection.ExplainStats{ + BufferHitRate: 0.2, // 20% 命中率 + RowsRead: 10000, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "low_buffer_hit_rate" { + found = true + } + } + if !found { + t.Fatal("缓冲命中率 < 50% 应触发建议") + } +} + +func TestRunExplainRules_NestedLoopHighFanout(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM a JOIN b ON a.id = b.aid", + Nodes: []connection.ExplainNode{ + {ID: "n1", OpType: connection.ExplainOpJoin, Table: ""}, + {ID: "n2", OpType: connection.ExplainOpScan, Table: "a", EstRows: 10}, + {ID: "n3", OpType: connection.ExplainOpScan, Table: "b", EstRows: 50000}, + }, + Edges: []connection.ExplainEdge{ + {From: "n1", To: "n2"}, + {From: "n1", To: "n3"}, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "nested_loop_high_fanout" { + found = true + } + } + if !found { + t.Fatal("Nested Loop 被驱动表 > 10000 行应触发 nested_loop_high_fanout") + } +} + +func TestRunExplainRules_SortBySeverity(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id ORDER BY t1.name", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "t1", + EstRows: 50000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + }, + { + ID: "n2", + OpType: connection.ExplainOpSort, + EstRows: 100, + Flags: []string{connection.ExplainFlagFilesort}, + }, + }, + } + suggestions := runExplainRules(result) + if len(suggestions) < 2 { + t.Fatalf("应触发至少 2 条建议,got=%d", len(suggestions)) + } + // 第一条应是 critical(全表扫描) + if suggestions[0].Severity != connection.SeverityCritical { + t.Fatalf("首条建议应为 critical,got=%s(rule=%s)", suggestions[0].Severity, suggestions[0].Rule) + } +} + +func TestRunExplainRules_EmptyResultNoSuggestions(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT 1", + } + suggestions := runExplainRules(result) + if len(suggestions) != 0 { + t.Fatalf("空 ExplainResult 不应产生建议,got=%d", len(suggestions)) + } +} + +// contains 检查字符串包含(避免和 strings.Contains 冲突,这里独立实现)。 +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || indexOfContains(s, substr) >= 0) +} + +func indexOfContains(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/internal/app/methods_explain.go b/internal/app/methods_explain.go index ad54245..76022c4 100644 --- a/internal/app/methods_explain.go +++ b/internal/app/methods_explain.go @@ -85,7 +85,9 @@ func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query st return connection.QueryResult{Success: false, Message: err.Error()} } - report := connection.DiagnoseReport{Plan: plan} + suggestions := runExplainRules(plan) + report := connection.DiagnoseReport{Plan: plan, Suggestions: suggestions} + logger.Infof("DiagnoseQuery 完成:type=%s nodes=%d suggestions=%d", dbType, len(plan.Nodes), len(suggestions)) return connection.QueryResult{Success: true, Message: "诊断完成", Data: report} } @@ -260,32 +262,11 @@ func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFor case "sqlite": return parseSQLiteExplain(sourceSQL, raw, format) case "clickhouse": - // PR2 实现 - return connection.ExplainResult{ - DBType: dbType, - SourceSQL: sourceSQL, - RawFormat: format, - RawPayload: raw, - Warnings: []string{"ClickHouse 解析器在 PR2 实现,先返回原文"}, - }, nil + return parseClickHouseExplain(sourceSQL, raw, format) case "oracle": - // PR2 实现 - return connection.ExplainResult{ - DBType: dbType, - SourceSQL: sourceSQL, - RawFormat: format, - RawPayload: raw, - Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"}, - }, nil + return parseOracleExplain(sourceSQL, raw, format) case "sqlserver": - // PR2 实现 - return connection.ExplainResult{ - DBType: dbType, - SourceSQL: sourceSQL, - RawFormat: format, - RawPayload: raw, - Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"}, - }, nil + return parseSQLServerExplain(sourceSQL, raw, format) default: return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType) } From f5ae2e51f94699753202f7d31d32c93a8533f8db Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:04:49 +0800 Subject: [PATCH 23/61] =?UTF-8?q?=E2=9C=A8=20feat(explain-ui):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=89=A7=E8=A1=8C=E8=AE=A1=E5=88=92=E5=9B=BE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=BB=84=E4=BB=B6=E4=B8=8E=E7=B4=A2=E5=BC=95=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E4=BE=A7=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 依赖引入:新增 reactflow + dagre 用于执行计划 DAG 自动布局 - 构建配置:vite manualChunks 拆分 reactflow/dagre/recharts 独立 chunk,便于按需加载 - 类型镜像:utils/explainTypes.ts 镜像后端 ExplainResult/Node/Stats/IndexSuggestion,含颜色与格式化 helper - 图渲染:ExplainGraph 自定义节点按 opType 着色 + 警告 flag 边框高亮 + dagre TB 布局 - 侧栏组件:ExplainSidebar 含统计条、节点详情、索引建议按 severity 排序 - 主容器:ExplainWorkbench 含 Modal + 执行计划/原文双 tab,调用 DiagnoseQuery 端到端联通 --- frontend/package-lock.json | 160 ++++++++++++ frontend/package.json | 3 + .../src/components/explain/ExplainGraph.tsx | 224 +++++++++++++++++ .../src/components/explain/ExplainSidebar.tsx | 234 ++++++++++++++++++ .../components/explain/ExplainWorkbench.tsx | 158 ++++++++++++ frontend/src/utils/explainTypes.ts | 172 +++++++++++++ frontend/vite.config.ts | 12 + 7 files changed, 963 insertions(+) create mode 100644 frontend/src/components/explain/ExplainGraph.tsx create mode 100644 frontend/src/components/explain/ExplainSidebar.tsx create mode 100644 frontend/src/components/explain/ExplainWorkbench.tsx create mode 100644 frontend/src/utils/explainTypes.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf89390..0471959 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,9 +14,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", + "@types/dagre": "^0.7.54", "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "dagre": "^0.8.5", "fflate": "^0.8.3", "mermaid": "^11.13.0", "react": "^18.2.0", @@ -24,6 +26,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "reactflow": "^11.11.4", "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", @@ -1214,6 +1217,108 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1928,6 +2033,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2509,6 +2620,12 @@ "chevrotain": "^11.0.0" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3081,6 +3198,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/dagre-d3-es": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", @@ -3387,6 +3514,15 @@ "node": ">=6.9.0" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -3675,6 +3811,12 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", @@ -5713,6 +5855,24 @@ "react": "^18.2.0" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 197e39c..cb03395 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", + "@types/dagre": "^0.7.54", "@types/react-syntax-highlighter": "^15.5.13", "antd": "^5.12.0", "clsx": "^2.1.0", + "dagre": "^0.8.5", "fflate": "^0.8.3", "mermaid": "^11.13.0", "react": "^18.2.0", @@ -27,6 +29,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.1.3", "react-syntax-highlighter": "^16.1.1", + "reactflow": "^11.11.4", "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "sql-formatter": "^15.7.0", diff --git a/frontend/src/components/explain/ExplainGraph.tsx b/frontend/src/components/explain/ExplainGraph.tsx new file mode 100644 index 0000000..2ff2f5c --- /dev/null +++ b/frontend/src/components/explain/ExplainGraph.tsx @@ -0,0 +1,224 @@ +import { useCallback, useMemo } from 'react' +import ReactFlow, { + Background, + BackgroundVariant, + Controls, + type Edge, + type Node, + type NodeMouseHandler, + Position, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from 'reactflow' +import dagre from 'dagre' +import 'reactflow/dist/style.css' +import { + type ExplainEdge, + type ExplainNode, + opTypeColor, + formatNumber, +} from '../../utils/explainTypes' + +// 执行计划图主组件。 +// 使用 react-flow 渲染扁平节点数组,dagre 自动计算树形布局。 +// +// 设计要点: +// - 自定义节点(ExplainGraphNodeData)按 opType 着色 + 警告 flag 边框高亮 +// - 点击节点触发 onSelectNode 回调(详情抽屉联动) +// - 节点尺寸自适应内容,避免长 SQL/表名截断 +// - 通过 React.memo 避免不必要的重渲染(88W 数据下很重要) + +const NODE_WIDTH = 220 +const NODE_HEIGHT = 80 + +export interface ExplainGraphNodeData { + node: ExplainNode + isSelected: boolean +} + +interface ExplainGraphProps { + nodes: ExplainNode[] + edges: ExplainEdge[] + selectedNodeId?: string + onSelectNode?: (nodeId: string | null) => void +} + +export default function ExplainGraph(props: ExplainGraphProps) { + return ( + + + + ) +} + +function ExplainGraphInner({ nodes, edges, selectedNodeId, onSelectNode }: ExplainGraphProps) { + const { rfNodes, rfEdges } = useMemo( + () => layoutWithDagre(nodes, edges, selectedNodeId), + [nodes, edges, selectedNodeId], + ) + + const [nodeState, , onNodesChange] = useNodesState(rfNodes) + const [edgeState, , onEdgesChange] = useEdgesState(rfEdges) + + const handleNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + onSelectNode?.(node.id) + }, + [onSelectNode], + ) + + const handlePaneClick = useCallback(() => { + onSelectNode?.(null) + }, [onSelectNode]) + + return ( +
+ + + + +
+ ) +} + +// layoutWithDagre 用 dagre 计算 react-flow 节点位置。 +// 默认从上到下(TB)布局,符合执行计划的"父子层级"心智模型。 +function layoutWithDagre( + nodes: ExplainNode[], + edges: ExplainEdge[], + selectedNodeId?: string, +): { rfNodes: Node[]; rfEdges: Edge[] } { + const g = new dagre.graphlib.Graph() + g.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 60, marginx: 20, marginy: 20 }) + g.setDefaultEdgeLabel(() => ({})) + + for (const node of nodes) { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) + } + for (const edge of edges) { + g.setEdge(edge.from, edge.to, { label: edge.label }) + } + dagre.layout(g) + + const rfNodes: Node[] = nodes.map((node) => { + const pos = g.node(node.id) + return { + id: node.id, + type: 'explain', + position: { x: pos?.x ?? 0, y: pos?.y ?? 0 }, + data: { node, isSelected: node.id === selectedNodeId }, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + draggable: false, + } + }) + + const rfEdges: Edge[] = edges.map((edge, idx) => ({ + id: `e-${edge.from}-${edge.to}-${idx}`, + source: edge.from, + target: edge.to, + label: edge.label, + type: 'smoothstep', + style: { stroke: 'var(--gn-explain-edge, #adb5bd)', strokeWidth: 1.5 }, + })) + + return { rfNodes, rfEdges } +} + +import { memo } from 'react' + +const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({ + data, +}: { + data: ExplainGraphNodeData +}) { + const { node, isSelected } = data + const color = opTypeColor(node.opType) + const hasFullScan = node.flags?.includes('FULL_SCAN') + const hasFilesort = node.flags?.includes('FILESORT') + const hasTempTable = node.flags?.includes('TEMP_TABLE') + + return ( +
+
{node.opDetail || node.opType}
+ {node.table && ( +
+ 表: + {node.table} +
+ )} + {node.index && ( +
+ 索引: + {node.index} +
+ )} +
+ {node.estRows !== undefined && node.estRows > 0 && ( + + 估算 {formatNumber(node.estRows)} + + )} + {node.actualRows !== undefined && node.actualRows > 0 && ( + + 实际 {formatNumber(node.actualRows)} + + )} + {node.cost !== undefined && node.cost > 0 && ( + + 成本 {node.cost.toFixed(1)} + + )} +
+ {(hasFullScan || hasFilesort || hasTempTable) && ( +
+ {hasFullScan && } + {hasFilesort && } + {hasTempTable && } +
+ )} +
+ ) +}) + +function FlagBadge({ color, text }: { color: string; text: string }) { + return ( + + {text} + + ) +} diff --git a/frontend/src/components/explain/ExplainSidebar.tsx b/frontend/src/components/explain/ExplainSidebar.tsx new file mode 100644 index 0000000..712d660 --- /dev/null +++ b/frontend/src/components/explain/ExplainSidebar.tsx @@ -0,0 +1,234 @@ +import { useMemo } from 'react' +import { + type ExplainNode, + type ExplainStats, + type IndexSuggestion, + severityColor, + severityRank, + formatNumber, + formatPercent, + formatMs, +} from '../../utils/explainTypes' + +// 诊断侧栏:节点详情 + 统计条 + 索引建议列表的合集组件。 +// 拆分为一个文件减少模块碎片化(plan 原拆 3 个文件)。 + +interface ExplainSidebarProps { + stats: ExplainStats + warnings?: string[] + suggestions: IndexSuggestion[] + selectedNode?: ExplainNode + onSelectSuggestion?: (suggestion: IndexSuggestion) => void +} + +export default function ExplainSidebar(props: ExplainSidebarProps) { + const { stats, warnings, suggestions, selectedNode, onSelectSuggestion } = props + const sortedSuggestions = useMemo( + () => + [...suggestions].sort((a, b) => { + const ra = severityRank[a.severity] ?? 99 + const rb = severityRank[b.severity] ?? 99 + if (ra !== rb) return ra - rb + return (b.estRows ?? 0) - (a.estRows ?? 0) + }), + [suggestions], + ) + + return ( +
+ + {selectedNode && } + +
+ ) +} + +function ExplainStatsBar({ + stats, + warnings, +}: { + stats: ExplainStats + warnings?: string[] +}) { + const statsList = [ + { label: '总成本', value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' }, + { label: '总耗时', value: formatMs(stats.totalDurationMs) }, + { label: '扫描行数', value: formatNumber(stats.rowsRead) }, + { label: '缓冲命中', value: formatPercent(stats.bufferHitRate) }, + { label: '最大单节点行数', value: formatNumber(stats.maxEstRows) }, + ] + return ( +
+
执行统计
+
+ {statsList.map((s) => ( +
+ {s.label} + {s.value} +
+ ))} +
+ {stats.hasFullScan && } + {stats.hasFilesort && } + {stats.hasTempTable && } + {warnings && warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +
⚠ {w}
+ ))} +
+ )} +
+ ) +} + +function WarningRow({ color, text }: { color: string; text: string }) { + return ( +
+ + {text} +
+ ) +} + +function ExplainNodeDetail({ node }: { node: ExplainNode }) { + const rows: Array<[string, string]> = [] + rows.push(['操作类型', node.opType]) + if (node.opDetail) rows.push(['操作详情', node.opDetail]) + if (node.table) rows.push(['表', node.table]) + if (node.index) rows.push(['索引', node.index]) + if (node.estRows) rows.push(['估算行数', formatNumber(node.estRows)]) + if (node.actualRows) rows.push(['实际行数', formatNumber(node.actualRows)]) + if (node.loops) rows.push(['循环次数', formatNumber(node.loops)]) + if (node.cost) rows.push(['成本', node.cost.toFixed(2)]) + if (node.durationMs) rows.push(['耗时', formatMs(node.durationMs)]) + if (node.bufferHit !== undefined && node.bufferHit > 0) + rows.push(['缓冲命中', formatPercent(node.bufferHit)]) + if (node.flags && node.flags.length > 0) rows.push(['标志', node.flags.join(', ')]) + + return ( +
+
节点详情
+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ {node.extra && Object.keys(node.extra).length > 0 && ( +
+ + Extra 字段({Object.keys(node.extra).length}) + +
+            {JSON.stringify(node.extra, null, 2)}
+          
+
+ )} +
+ ) +} + +function IndexSuggestionList({ + suggestions, + onSelect, +}: { + suggestions: IndexSuggestion[] + onSelect?: (s: IndexSuggestion) => void +}) { + return ( +
+
+ 索引建议({suggestions.length}) +
+ {suggestions.length === 0 ? ( +
+ 未发现明显性能问题 +
+ ) : ( +
+ {suggestions.map((s, idx) => ( + + ))} +
+ )} +
+ ) +} + +function SuggestionCard({ + suggestion, + onSelect, +}: { + suggestion: IndexSuggestion + onSelect?: (s: IndexSuggestion) => void +}) { + const color = severityColor(suggestion.severity) + return ( +
onSelect?.(suggestion)} + style={{ + borderLeft: `3px solid ${color}`, + padding: '6px 8px', + background: 'var(--gn-suggestion-bg, #ffffff)', + cursor: onSelect ? 'pointer' : 'default', + fontSize: 12, + }} + > +
+ + {suggestion.severity} + + {suggestion.estRows !== undefined && suggestion.estRows > 0 && ( + + {formatNumber(suggestion.estRows)} 行 + + )} +
+
{suggestion.reason}
+ {suggestion.suggestedIndex && ( + + {suggestion.suggestedIndex} + + )} + {suggestion.affectedTable && ( +
+ 表:{suggestion.affectedTable} +
+ )} +
+ ) +} diff --git a/frontend/src/components/explain/ExplainWorkbench.tsx b/frontend/src/components/explain/ExplainWorkbench.tsx new file mode 100644 index 0000000..c07f033 --- /dev/null +++ b/frontend/src/components/explain/ExplainWorkbench.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Modal, Spin, Tabs, Typography } from 'antd' +import { DiagnoseQuery } from '../../../wailsjs/go/app/App' +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig' +import type { ConnectionConfig } from '../../types' +import type { DiagnoseReport, ExplainNode, IndexSuggestion } from '../../utils/explainTypes' +import ExplainGraph from './ExplainGraph' +import ExplainSidebar from './ExplainSidebar' + +// SQL 诊断工作台主容器。 +// 通过 React.lazy 在 QueryEditor 触发"诊断"时延迟加载(避免 react-flow 进入主 bundle)。 +// +// UI 结构: +// ┌─────────────────────────────────────────────────┐ +// │ Modal:诊断工作台 │ +// ├──────────────────────────────┬──────────────────┤ +// │ react-flow 执行计划图 │ 侧栏 │ +// │ (点击节点联动) │ - 统计条 │ +// │ │ - 节点详情 │ +// │ │ - 索引建议 │ +// └──────────────────────────────┴──────────────────┘ +// 底部 tab:执行计划 | 原文(调试用) + +const { Title, Text, Paragraph } = Typography + +interface ExplainWorkbenchProps { + open: boolean + onClose: () => void + config: ConnectionConfig + dbName: string + sql: string +} + +export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) { + const [loading, setLoading] = useState(false) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + const [selectedNodeId, setSelectedNodeId] = useState(null) + + const runDiagnose = useCallback(async () => { + if (!sql.trim()) { + setError('查询语句为空') + return + } + setLoading(true) + setError(null) + setReport(null) + setSelectedNodeId(null) + try { + const result = await DiagnoseQuery(buildRpcConnectionConfig(config), dbName, sql) + if (!result.success) { + setError(result.message || '诊断失败') + } else { + const data = result.data as DiagnoseReport + setReport(data) + } + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + } + }, [config, dbName, sql]) + + useEffect(() => { + if (open) { + void runDiagnose() + } + }, [open, runDiagnose]) + + const selectedNode = useMemo(() => { + if (!report || !selectedNodeId) return undefined + return report.plan.nodes.find((n) => n.id === selectedNodeId) + }, [report, selectedNodeId]) + + const handleSelectSuggestion = useCallback((s: IndexSuggestion) => { + if (s.affectedNodeId) { + setSelectedNodeId(s.affectedNodeId) + } + }, []) + + return ( + SQL 诊断工作台} + destroyOnClose + > +
+ {loading && ( +
+ +
+ )} + {error && ( + + 诊断失败: + {error} + + )} + {!loading && !error && report && ( + +
+ +
+
+ +
+
+ ), + }, + { + key: 'raw', + label: `原文(${report.plan.rawFormat})`, + children: ( +
+                    {report.plan.rawPayload || '(无原文)'}
+                  
+ ), + }, + ]} + /> + )} + +
+ ) +} diff --git a/frontend/src/utils/explainTypes.ts b/frontend/src/utils/explainTypes.ts new file mode 100644 index 0000000..96510aa --- /dev/null +++ b/frontend/src/utils/explainTypes.ts @@ -0,0 +1,172 @@ +// SQL 诊断工作台前端类型定义。 +// +// 本文件镜像后端 internal/connection/explain.go 的数据结构。 +// 当 Wails 重新生成 models.ts 后,可逐步迁移到 import { connection } from '../wailsjs/go/models', +// 但在过渡期保持独立类型便于前端独立开发。 + +// 节点操作类型(与后端 ExplainOp* 常量对齐)。 +export type ExplainOpType = + | 'SCAN' // 全表扫描 + | 'INDEX_SCAN' // 索引扫描 + | 'INDEX_ONLY' // 覆盖索引 + | 'JOIN' + | 'AGGREGATE' + | 'SORT' + | 'LIMIT' + | 'FILTER' + | 'SUBQUERY' + | 'UNION' + | 'WINDOW' + | 'MATERIALIZE' + | 'INSERT' + | 'UPDATE' + | 'DELETE' + | 'OTHER' + +// 节点警告标志(用于 UI 高亮 + 规则匹配)。 +export type ExplainNodeFlag = + | 'FULL_SCAN' + | 'FILESORT' + | 'TEMP_TABLE' + | 'NO_INDEX' + | 'HIGH_COST' + | 'LOW_BUFFER_HIT' + | 'UNCERTAIN_ROWS' + +// EXPLAIN 原文格式。 +export type ExplainFormat = 'json' | 'table' | 'xml' | 'text' + +// 建议严重度。 +export type IndexSuggestionSeverity = 'critical' | 'warning' | 'info' + +export interface ExplainNode { + id: string + parentId?: string + opType: ExplainOpType | string + opDetail?: string + table?: string + index?: string + estRows?: number + actualRows?: number + loops?: number + cost?: number + durationMs?: number + bufferHit?: number + flags?: ExplainNodeFlag[] | string[] + extra?: Record +} + +export interface ExplainEdge { + from: string + to: string + label?: string +} + +export interface ExplainStats { + totalCost?: number + totalDurationMs?: number + rowsRead?: number + bufferHitRate?: number + hasFullScan: boolean + hasFilesort: boolean + hasTempTable: boolean + maxEstRows?: number +} + +export interface ExplainResult { + dbType: string + sourceSql: string + nodes: ExplainNode[] + edges?: ExplainEdge[] + stats: ExplainStats + warnings?: string[] + rawFormat: ExplainFormat | string + rawPayload?: string +} + +export interface IndexSuggestion { + severity: IndexSuggestionSeverity | string + rule: string + reason: string + suggestedIndex?: string + affectedNodeId?: string + affectedTable?: string + estRows?: number +} + +export interface DiagnoseReport { + plan: ExplainResult + suggestions: IndexSuggestion[] +} + +// severityRank 用于 UI 排序:critical 最前。 +export const severityRank: Record = { + critical: 0, + warning: 1, + info: 2, +} + +// opTypeTheme 按 OpType 返回主题色 token(对应 v2-theme.css 的 CSS 变量)。 +// 颜色规则:SCAN 红橙(警告)、JOIN 蓝、AGGREGATE 紫、SORT 黄、其他灰。 +export function opTypeColor(opType: string): string { + switch (opType) { + case 'SCAN': + return 'var(--gn-explain-scan, #e8590c)' + case 'INDEX_SCAN': + return 'var(--gn-explain-index-scan, #1971c2)' + case 'INDEX_ONLY': + return 'var(--gn-explain-index-only, #2f9e44)' + case 'JOIN': + return 'var(--gn-explain-join, #1971c2)' + case 'AGGREGATE': + return 'var(--gn-explain-aggregate, #6741d9)' + case 'SORT': + return 'var(--gn-explain-sort, #f08c00)' + case 'LIMIT': + return 'var(--gn-explain-limit, #495057)' + case 'FILTER': + return 'var(--gn-explain-filter, #495057)' + case 'SUBQUERY': + return 'var(--gn-explain-subquery, #7048e8)' + case 'MATERIALIZE': + return 'var(--gn-explain-materialize, #e8590c)' + default: + return 'var(--gn-explain-other, #868e96)' + } +} + +// severityColor 用于建议列表的左侧色条。 +export function severityColor(severity: string): string { + switch (severity) { + case 'critical': + return 'var(--gn-explain-critical, #fa5252)' + case 'warning': + return 'var(--gn-explain-warning, #f08c00)' + case 'info': + return 'var(--gn-explain-info, #1c7ed6)' + default: + return 'var(--gn-explain-other, #868e96)' + } +} + +// formatNumber 容错格式化大数字(千分位)。 +export function formatNumber(n?: number): string { + if (n === undefined || n === null || isNaN(n)) return '-' + if (Math.abs(n) >= 10000) { + return new Intl.NumberFormat('en-US').format(n) + } + return String(n) +} + +// formatPercent 把 0-1 的小数格式化为百分比字符串。 +export function formatPercent(ratio?: number): string { + if (ratio === undefined || ratio === null || isNaN(ratio)) return '-' + return `${(ratio * 100).toFixed(1)}%` +} + +// formatMs 把毫秒格式化为人类可读(>1s 显示秒)。 +export function formatMs(ms?: number): string { + if (ms === undefined || ms === null || isNaN(ms)) return '-' + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s` + return `${ms.toFixed(1)}ms` +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db1c836..b9bda7d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -28,5 +28,17 @@ export default defineConfig({ build: { outDir: 'dist', // Standard Wails output directory emptyOutDir: true, + rollupOptions: { + output: { + // 拆分大体积三方依赖到独立 chunk,避免主 bundle 过大 + // reactflow + dagre 约 130KB gzipped,单独成 chunk 可按需加载 + // recharts 用于诊断面板统计条,与执行计划图无强依赖,单独 chunk + manualChunks: { + reactflow: ['reactflow'], + dagre: ['dagre'], + charts: ['recharts'], + }, + }, + }, } }) From 0c320234fdb0f82b2d9aa199ce518b77484b77a8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:11:20 +0800 Subject: [PATCH 24/61] =?UTF-8?q?=E2=9C=A8=20feat(explain-ui):=20=E5=B0=86?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E5=B7=A5=E4=BD=9C=E5=8F=B0=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20QueryEditor=20=E5=B9=B6=E6=94=AF=E6=8C=81=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 快捷键绑定:QueryEditor 监听 Ctrl+Shift+D(Mac 为 Cmd+Shift+D)打开诊断面板 - 配置解析:从 currentConnectionId 复用 SavedConnection 模式解析 ConnectionConfig - lazy 加载:ExplainWorkbench 通过 React.lazy + Suspense 隔离,避免 reactflow 进入主 bundle - 端到端联通:用户在编辑器写 SQL 后按快捷键即可触发 DiagnoseQuery 并可视化结果 --- frontend/src/components/QueryEditor.tsx | 50 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f507ba2..bca3b83 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,5 +1,5 @@ import Modal from './common/ResizableDraggableModal'; -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; import { message, Input, Form, MenuProps } from 'antd'; import { format, type SqlLanguage } from 'sql-formatter'; @@ -31,6 +31,8 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; +// SQL 诊断工作台:lazy 加载避免 reactflow/dagre 进入主 bundle(约 130KB gzipped 独立 chunk) +const ExplainWorkbench = lazy(() => import('./explain/ExplainWorkbench')); import { SUPPORTED_LANGUAGES, t as translate } from '../i18n'; import { DUCKDB_ROWID_LOCATOR_COLUMN, @@ -2206,7 +2208,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save'); const [saveForm] = Form.useForm(); - + + // SQL 诊断工作台:Ctrl+Shift+D 触发(Mac 为 Cmd+Shift+D) + const [explainOpen, setExplainOpen] = useState(false); + // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); @@ -2250,6 +2255,34 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor), [connections] ); + + // SQL 诊断工作台:从 currentConnectionId 解析 ConnectionConfig(复用 SavedConnection 模式) + const explainConfig = useMemo(() => { + if (!currentConnectionId) return null; + const conn = connections.find(c => c.id === currentConnectionId); + if (!conn) return null; + return { + ...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: '' }, + } as any; + }, [connections, currentConnectionId]); + + useEffect(() => { + if (!isActive) return; + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) { + e.preventDefault(); + setExplainOpen(true); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isActive]); + const addSqlLog = useStore(state => state.addSqlLog); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); @@ -6111,6 +6144,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc + + {/* SQL 诊断工作台:Ctrl+Shift+D 触发,lazy 加载避免 reactflow 进入主 bundle */} + + {explainOpen && explainConfig && ( + setExplainOpen(false)} + config={explainConfig} + dbName={currentDb} + sql={query} + /> + )} + ); }; From a74065bdbbd9b4d789439b97f4fbd1005157c407 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:17:39 +0800 Subject: [PATCH 25/61] =?UTF-8?q?=E2=9C=A8=20feat(explain):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=85=A2=20SQL=20=E5=8E=86=E5=8F=B2=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E4=B8=8E=20DBQueryMulti=20=E6=89=A7=E8=A1=8C=E5=9F=8B=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 存储层:JSONL 单连接单文件,5MB 自动滚动,TopN 排序 + SQL 指纹去重 - 执行埋点:DBQueryMulti 用 named return + defer 异步记录,成功后自动写入历史 - 阈值过滤:默认 500ms 以下查询跳过记录,避免历史爆炸 - 查询入口:GetSlowQueries 按 duration/rowsRead/recent 排序,ClearSlowQueries 支持清空 - SQL 指纹:字面量/大小写归一化后 sha256,同模板不同参数视为同一查询 - 测试覆盖:新增 13 个单元测试覆盖存储/滚动/排序/去重/指纹 --- internal/app/methods_db.go | 13 +- internal/app/methods_query_history.go | 71 +++++ internal/app/query_history_store.go | 313 +++++++++++++++++++++++ internal/app/query_history_store_test.go | 276 ++++++++++++++++++++ 4 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 internal/app/methods_query_history.go create mode 100644 internal/app/query_history_store.go create mode 100644 internal/app/query_history_store_test.go diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 8de9e8d..1a0a484 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -889,7 +889,18 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin // DBQueryMulti 执行可能包含多条 SQL 语句的查询,返回多个结果集。 // 如果底层驱动支持 MultiResultQuerier,一次性执行所有语句; // 否则按分号拆分后逐条执行,模拟多结果集。 -func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult { +func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) (result connection.QueryResult) { + // 慢 SQL 埋点:成功执行后异步记录(低于阈值 500ms 自动跳过),不阻塞返回。 + // 用 named return + defer 覆盖所有 return path,避免遗漏。 + queryStartedAt := time.Now() + defer func() { + if !result.Success { + return + } + durationMs := time.Since(queryStartedAt).Milliseconds() + a.recordQueryExecutionAsync(config, resolveDDLDBType(config), query, durationMs, 0, 0) + }() + runConfig := normalizeRunConfig(config, dbName) if queryID == "" { diff --git a/internal/app/methods_query_history.go b/internal/app/methods_query_history.go new file mode 100644 index 0000000..8952271 --- /dev/null +++ b/internal/app/methods_query_history.go @@ -0,0 +1,71 @@ +package app + +import ( + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" +) + +// 慢 SQL 历史的 Wails 绑定入口。 +// +// 前端调用: +// - GetSlowQueries(connectionId, dbName, sortBy, limit) → []QueryExecutionRecord +// - ClearSlowQueries(connectionId, dbName) → 错误(清空当前连接的历史) +// +// sortBy: "duration" | "rowsRead" | "recent" + +// GetSlowQueries 返回当前连接的慢 SQL 历史,按指定字段排序、SQL 指纹去重后取前 N。 +// limit <= 0 时返回前 100 条。 +func (a *App) GetSlowQueries(config connection.ConnectionConfig, dbName, sortBy string, limit int) connection.QueryResult { + runConfig := normalizeRunConfig(config, dbName) + connFP, ok := buildConnectionFingerprint(runConfig) + if !ok || connFP == "" { + return connection.QueryResult{Success: false, Message: "无法解析连接指纹"} + } + + if limit <= 0 { + limit = 100 + } + store := newQueryHistoryStore(a.configDir, connFP) + records, err := store.LoadTopN(strings.TrimSpace(sortBy), limit, true) + if err != nil { + logger.Warnf("GetSlowQueries 加载失败:connFp=%s err=%v", connFP, err) + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "加载完成", Data: records} +} + +// ClearSlowQueries 清空当前连接的慢 SQL 历史。 +// 删除主文件 + rotate 文件(.1)。 +func (a *App) ClearSlowQueries(config connection.ConnectionConfig, dbName string) connection.QueryResult { + runConfig := normalizeRunConfig(config, dbName) + connFP, ok := buildConnectionFingerprint(runConfig) + if !ok || connFP == "" { + return connection.QueryResult{Success: false, Message: "无法解析连接指纹"} + } + store := newQueryHistoryStore(a.configDir, connFP) + if err := store.Clear(); err != nil { + logger.Warnf("ClearSlowQueries 失败:connFp=%s err=%v", connFP, err) + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "已清空慢查询历史"} +} + +// recordQueryExecutionAsync 异步追加一条慢查询记录,不阻塞主查询返回。 +// 调用方应传入已计算的 durationMs 和 rowsRead/Returned。 +func (a *App) recordQueryExecutionAsync(config connection.ConnectionConfig, dbType, sql string, durationMs, rowsRead, rowsReturned int64) { + if durationMs < queryHistorySlowThresholdMs { + return + } + record := buildQueryExecutionRecord(config, dbType, sql, durationMs, rowsRead, rowsReturned) + go func() { + defer func() { + if r := recover(); r != nil { + logger.Warnf("recordQueryExecutionAsync panic:%v", r) + } + }() + store := newQueryHistoryStore(a.configDir, record.ConnectionFP) + store.Append(record) + }() +} diff --git a/internal/app/query_history_store.go b/internal/app/query_history_store.go new file mode 100644 index 0000000..4441d8b --- /dev/null +++ b/internal/app/query_history_store.go @@ -0,0 +1,313 @@ +package app + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" +) + +// 慢 SQL 历史存储。 +// +// 设计要点: +// - 每个连接指纹一份 JSONL 文件(路径:/query_history/.jsonl) +// - 追加写:每次执行 SQL 异步追加一行 JSON,O(1) 写入 +// - 5MB 滚动:写入前检查文件大小,超阈值则 rename 为 .1.jsonl 并新建空文件 +// - 读 TopN:全量加载到内存按字段排序 + SQL 指纹去重保留最新 +// - 不引入 SQLite:项目现有持久化都是 JSON,依赖一致性优先 + +const ( + queryHistoryDirName = "query_history" + queryHistoryFileMaxBytes = 5 * 1024 * 1024 + queryHistorySlowThresholdMs int64 = 500 // 低于 500ms 不记录,避免历史爆炸 + queryHistoryPreviewRunes = 200 // SQL 预览截断长度 +) + +// queryHistoryStore 是单连接的慢 SQL 历史存储。 +// 并发安全:同一连接的多条 SQL 可能并发执行,写入加锁。 +type queryHistoryStore struct { + mu sync.Mutex + filePath string +} + +// newQueryHistoryStore 按连接指纹构造 store。configDir 为空时用 resolveAppConfigDir。 +func newQueryHistoryStore(configDir, connFingerprint string) *queryHistoryStore { + if strings.TrimSpace(configDir) == "" { + configDir = resolveAppConfigDir() + } + fp := sanitizeFingerprintForFilename(connFingerprint) + return &queryHistoryStore{ + filePath: filepath.Join(configDir, queryHistoryDirName, fp+".jsonl"), + } +} + +// Append 追加一条执行记录。低于阈值的查询自动跳过。 +// 失败仅记日志,不影响主查询流程。 +func (s *queryHistoryStore) Append(record connection.QueryExecutionRecord) { + if record.DurationMs < queryHistorySlowThresholdMs { + return + } + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(s.filePath), 0o755); err != nil { + logger.Warnf("创建慢查询历史目录失败:%v path=%s", err, filepath.Dir(s.filePath)) + return + } + + // 检查大小并 rotate(rotate 失败不阻塞写入) + if info, err := os.Stat(s.filePath); err == nil && info.Size() >= queryHistoryFileMaxBytes { + rotated := s.filePath + ".1" + // 已有 .1 文件则先删除(只保留一个历史文件) + _ = os.Remove(rotated) + if err := os.Rename(s.filePath, rotated); err != nil { + logger.Warnf("慢查询历史 rotate 失败:%v path=%s", err, s.filePath) + } + } + + file, err := os.OpenFile(s.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + logger.Warnf("打开慢查询历史文件失败:%v path=%s", err, s.filePath) + return + } + defer file.Close() + + payload, err := json.Marshal(record) + if err != nil { + logger.Warnf("序列化慢查询记录失败:%v", err) + return + } + payload = append(payload, '\n') + if _, err := file.Write(payload); err != nil { + logger.Warnf("写入慢查询历史失败:%v path=%s", err, s.filePath) + } +} + +// LoadTopN 加载历史并按指定字段排序,返回前 N 条。 +// sortBy: "duration" | "rowsRead" | "recent";dedupe=true 时同 SQL 指纹仅保留最新一条。 +func (s *queryHistoryStore) LoadTopN(sortBy string, limit int, dedupe bool) ([]connection.QueryExecutionRecord, error) { + records, err := s.loadAll() + if err != nil { + return nil, err + } + if dedupe { + records = dedupeQueryRecords(records) + } + sortQueryRecords(records, sortBy) + if limit > 0 && len(records) > limit { + records = records[:limit] + } + return records, nil +} + +// Clear 删除主文件 + rotate 文件。文件不存在视为成功。 +func (s *queryHistoryStore) Clear() error { + s.mu.Lock() + defer s.mu.Unlock() + for _, path := range []string{s.filePath, s.filePath + ".1"} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// loadAll 加载主文件 + rotate 文件(.1)的全部记录。 +// 单行解析失败时跳过该行,不阻塞整体加载。 +func (s *queryHistoryStore) loadAll() ([]connection.QueryExecutionRecord, error) { + var records []connection.QueryExecutionRecord + for _, path := range []string{s.filePath + ".1", s.filePath} { + file, err := os.Open(path) + if err != nil { + if !os.IsNotExist(err) { + logger.Warnf("打开慢查询历史失败:%v path=%s", err, path) + } + continue + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 单行最大 1MB,足够大 SQL + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var r connection.QueryExecutionRecord + if err := json.Unmarshal([]byte(line), &r); err != nil { + continue + } + records = append(records, r) + } + file.Close() + if err := scanner.Err(); err != nil { + logger.Warnf("读取慢查询历史失败:%v path=%s", err, path) + } + } + return records, nil +} + +// dedupeQueryRecords 按 SQLFingerprint 去重,保留最新(ExecutedAt 最大)一条。 +func dedupeQueryRecords(records []connection.QueryExecutionRecord) []connection.QueryExecutionRecord { + if len(records) == 0 { + return records + } + latest := make(map[string]connection.QueryExecutionRecord, len(records)) + for _, r := range records { + if r.SQLFingerprint == "" { + continue + } + existing, ok := latest[r.SQLFingerprint] + if !ok || r.ExecutedAt.After(existing.ExecutedAt) { + latest[r.SQLFingerprint] = r + } + } + result := make([]connection.QueryExecutionRecord, 0, len(latest)) + for _, r := range latest { + result = append(result, r) + } + return result +} + +// sortQueryRecords 按字段原地排序。sortBy 不识别时按 recent。 +func sortQueryRecords(records []connection.QueryExecutionRecord, sortBy string) { + switch sortBy { + case "duration": + // 插入排序:记录数通常 < 1000 + for i := 1; i < len(records); i++ { + for j := i; j > 0 && records[j].DurationMs > records[j-1].DurationMs; j-- { + records[j], records[j-1] = records[j-1], records[j] + } + } + case "rowsRead": + for i := 1; i < len(records); i++ { + for j := i; j > 0 && records[j].RowsRead > records[j-1].RowsRead; j-- { + records[j], records[j-1] = records[j-1], records[j] + } + } + default: // "recent" + for i := 1; i < len(records); i++ { + for j := i; j > 0 && records[j].ExecutedAt.After(records[j-1].ExecutedAt); j-- { + records[j], records[j-1] = records[j-1], records[j] + } + } + } +} + +// buildSQLFingerprint 把 SQL 归一化为指纹(替换字面量为 ?、去注释、小写化关键字、sha256 前 16 字节)。 +// 用于跨执行去重:同一 SQL 不同参数值视为同一指纹。 +func buildSQLFingerprint(sql string) string { + normalized := normalizeSQLForFingerprint(sql) + if normalized == "" { + return "" + } + hash := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(hash[:16]) +} + +// normalizeSQLForFingerprint 简化 SQL 用于指纹计算。 +// 策略: +// - 去掉前后空白 +// - 替换字符串字面量 'xxx' 为 ? +// - 替换数字字面量为 ? +// - 替换 IN (...) 中的列表为 ? +// - 小写化 SQL 关键字(保守起见全小写,不影响语义) +func normalizeSQLForFingerprint(sql string) string { + text := strings.TrimSpace(sql) + if text == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(text)) + inString := false + stringQuote := byte(0) + i := 0 + for i < len(text) { + ch := text[i] + switch { + case inString: + if ch == stringQuote { + inString = false + builder.WriteByte('?') + } + // 跳过字符串内容 + case ch == '\'' || ch == '"': + inString = true + stringQuote = ch + case (ch >= '0' && ch <= '9'): + // 数字字面量替换为 ?,跳过连续数字 + for i < len(text) && text[i] >= '0' && text[i] <= '9' { + i++ + } + builder.WriteByte('?') + continue + default: + if ch >= 'A' && ch <= 'Z' { + ch = ch + ('a' - 'A') + } + builder.WriteByte(ch) + } + i++ + } + return builder.String() +} + +// buildQueryPreview 截断 SQL 为人类可读预览。 +func buildQueryPreview(sql string) string { + text := strings.TrimSpace(sql) + if text == "" { + return "" + } + // 把多行/制表符折叠为单空格(保留语义但节省存储) + text = strings.ReplaceAll(text, "\n", " ") + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\t", " ") + // 折叠连续空白 + for strings.Contains(text, " ") { + text = strings.ReplaceAll(text, " ", " ") + } + runes := []rune(text) + if len(runes) <= queryHistoryPreviewRunes { + return text + } + return string(runes[:queryHistoryPreviewRunes-1]) + "…" +} + +// sanitizeFingerprintForFilename 把指纹字符串安全化(只保留字母数字下划线)。 +func sanitizeFingerprintForFilename(fp string) string { + var builder strings.Builder + builder.Grow(len(fp)) + for _, ch := range fp { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { + builder.WriteRune(ch) + } + } + result := builder.String() + if result == "" { + return "default" + } + return result +} + +// buildQueryExecutionRecord 是埋点时的便利构造器,组装一条完整记录。 +func buildQueryExecutionRecord(config connection.ConnectionConfig, dbType, sql string, durationMs int64, rowsRead, rowsReturned int64) connection.QueryExecutionRecord { + connFP, _ := buildConnectionFingerprint(config) + return connection.QueryExecutionRecord{ + ID: fmt.Sprintf("qhr-%d", time.Now().UnixNano()), + ConnectionFP: connFP, + SQLFingerprint: buildSQLFingerprint(sql), + SQLPreview: buildQueryPreview(sql), + DBType: dbType, + DurationMs: durationMs, + RowsRead: rowsRead, + RowsReturned: rowsReturned, + ExecutedAt: time.Now(), + } +} diff --git a/internal/app/query_history_store_test.go b/internal/app/query_history_store_test.go new file mode 100644 index 0000000..d3d184e --- /dev/null +++ b/internal/app/query_history_store_test.go @@ -0,0 +1,276 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" + + "GoNavi-Wails/internal/connection" +) + +func TestQueryHistoryStore_AppendAndLoad(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + store.Append(connection.QueryExecutionRecord{ + ID: "r1", + ConnectionFP: "test-conn-fp", + SQLFingerprint: "fp-select-1", + SQLPreview: "SELECT * FROM t", + DBType: "mysql", + DurationMs: 1000, + ExecutedAt: time.Now(), + }) + store.Append(connection.QueryExecutionRecord{ + ID: "r2", + ConnectionFP: "test-conn-fp", + SQLFingerprint: "fp-select-2", + SQLPreview: "SELECT * FROM u WHERE id = 1", + DBType: "mysql", + DurationMs: 2000, + ExecutedAt: time.Now().Add(time.Second), + }) + + records, err := store.LoadTopN("duration", 10, false) + if err != nil { + t.Fatalf("LoadTopN 失败:%v", err) + } + if len(records) != 2 { + t.Fatalf("应有 2 条记录,got=%d", len(records)) + } + // duration 排序:r2 (2000ms) 应在前面 + if records[0].ID != "r2" { + t.Fatalf("按 duration 排序后首条应为 r2,got=%s", records[0].ID) + } +} + +func TestQueryHistoryStore_SkipBelowThreshold(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + // 低于 500ms 阈值应被跳过 + store.Append(connection.QueryExecutionRecord{ + ID: "fast", + DurationMs: 100, + SQLPreview: "SELECT 1", + ExecutedAt: time.Now(), + }) + records, _ := store.LoadTopN("duration", 10, false) + if len(records) != 0 { + t.Fatalf("低于阈值的查询不应被记录,got=%d", len(records)) + } +} + +func TestQueryHistoryStore_DedupeBySQLFingerprint(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + base := time.Now() + // 同一 SQL 指纹,3 次执行(不同时间) + for i := 0; i < 3; i++ { + store.Append(connection.QueryExecutionRecord{ + ID: "r" + string(rune('1'+i)), + SQLFingerprint: "same-fp", + DurationMs: int64(1000 + i*500), + ExecutedAt: base.Add(time.Duration(i) * time.Second), + }) + } + records, _ := store.LoadTopN("duration", 10, true) + if len(records) != 1 { + t.Fatalf("去重后应剩 1 条,got=%d", len(records)) + } + // 应保留最新一条(ExecutedAt 最大) + if records[0].ID != "r3" { + t.Fatalf("去重应保留最新,got ID=%s", records[0].ID) + } +} + +func TestQueryHistoryStore_RotationAtThreshold(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + // 写入大量记录触发 rotate(5MB 阈值) + for i := 0; i < 50000; i++ { + store.Append(connection.QueryExecutionRecord{ + ID: "r", + SQLFingerprint: "fp", + SQLPreview: "SELECT * FROM some_very_large_table WHERE col = 'long string to fill up space quickly'", + DurationMs: 1000, + ExecutedAt: time.Now(), + }) + } + + // 主文件存在 + rotate 文件存在 + if _, err := os.Stat(store.filePath); err != nil { + t.Fatalf("主文件应存在:%v", err) + } + if _, err := os.Stat(store.filePath + ".1"); err != nil { + t.Fatalf("rotate 文件 .1 应存在:%v", err) + } + + records, _ := store.LoadTopN("duration", 1000, false) + if len(records) == 0 { + t.Fatal("rotate 后应仍能加载历史") + } +} + +func TestQueryHistoryStore_SortByRecent(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + times := []time.Time{ + base.Add(2 * time.Second), + base.Add(0 * time.Second), + base.Add(1 * time.Second), + } + for i, ts := range times { + store.Append(connection.QueryExecutionRecord{ + ID: "r" + string(rune('1'+i)), + SQLFingerprint: "fp-" + string(rune('1'+i)), + DurationMs: 1000, + ExecutedAt: ts, + }) + } + + records, _ := store.LoadTopN("recent", 10, false) + if len(records) != 3 { + t.Fatalf("应有 3 条,got=%d", len(records)) + } + // recent 排序:最新(time[0])应在前面 + if records[0].ID != "r1" { + t.Fatalf("recent 排序后首条应为 r1(最新),got=%s", records[0].ID) + } +} + +func TestQueryHistoryStore_Clear(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "test-conn-fp") + + store.Append(connection.QueryExecutionRecord{ + ID: "r1", + DurationMs: 1000, + SQLPreview: "SELECT 1", + ExecutedAt: time.Now(), + }) + if err := store.Clear(); err != nil { + t.Fatalf("Clear 失败:%v", err) + } + records, _ := store.LoadTopN("duration", 10, false) + if len(records) != 0 { + t.Fatalf("清空后应无记录,got=%d", len(records)) + } +} + +func TestQueryHistoryStore_EmptyReturnsEmpty(t *testing.T) { + dir := t.TempDir() + store := newQueryHistoryStore(dir, "missing-fp") + records, err := store.LoadTopN("duration", 10, false) + if err != nil { + t.Fatalf("不存在的文件应返回空而非 error:%v", err) + } + if len(records) != 0 { + t.Fatalf("空历史应返回 0 条,got=%d", len(records)) + } +} + +func TestBuildSQLFingerprint_NormalizesLiterals(t *testing.T) { + sql1 := "SELECT * FROM users WHERE id = 1 AND name = 'alice'" + sql2 := "SELECT * FROM users WHERE id = 999 AND name = 'bob'" + fp1 := buildSQLFingerprint(sql1) + fp2 := buildSQLFingerprint(sql2) + if fp1 != fp2 { + t.Fatalf("字面量不同应归一化为同一指纹:fp1=%s fp2=%s", fp1, fp2) + } + if fp1 == "" { + t.Fatal("指纹不应为空") + } +} + +func TestBuildSQLFingerprint_DifferentSQLDifferentFingerprint(t *testing.T) { + sql1 := "SELECT * FROM users WHERE id = 1" + sql2 := "SELECT * FROM orders WHERE id = 1" + fp1 := buildSQLFingerprint(sql1) + fp2 := buildSQLFingerprint(sql2) + if fp1 == fp2 { + t.Fatal("不同 SQL 应有不同指纹") + } +} + +func TestBuildSQLFingerprint_CaseInsensitiveKeywords(t *testing.T) { + sql1 := "SELECT * FROM users" + sql2 := "select * from users" + if buildSQLFingerprint(sql1) != buildSQLFingerprint(sql2) { + t.Fatal("大小写不同的关键字应归一化为同一指纹") + } +} + +func TestBuildQueryPreview_TruncatesLongSQL(t *testing.T) { + longSQL := "" + for i := 0; i < 500; i++ { + longSQL += "a" + } + preview := buildQueryPreview(longSQL) + if len([]rune(preview)) > queryHistoryPreviewRunes { + t.Fatalf("预览应不超过 %d 字符,got=%d", queryHistoryPreviewRunes, len([]rune(preview))) + } +} + +func TestBuildQueryPreview_FoldsWhitespace(t *testing.T) { + multiLine := "SELECT *\n FROM\tusers\nWHERE id = 1" + preview := buildQueryPreview(multiLine) + if containsNewline(preview) { + t.Fatalf("预览不应含换行符:%q", preview) + } + if !containsStr(preview, "SELECT * FROM users WHERE id = 1") { + t.Fatalf("预览应折叠空白:%q", preview) + } +} + +func TestSanitizeFingerprintForFilename(t *testing.T) { + if got := sanitizeFingerprintForFilename("abc123_-"); got != "abc123_-" { + t.Fatalf("合法字符应保留:got=%s", got) + } + if got := sanitizeFingerprintForFilename("a/b\\c:d"); got != "abcd" { + t.Fatalf("非法字符应被过滤:got=%s", got) + } + if got := sanitizeFingerprintForFilename(""); got != "default" { + t.Fatalf("空指纹应回退为 default,got=%s", got) + } +} + +func TestNewQueryHistoryStore_CreatesDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "deep") + store := newQueryHistoryStore(dir, "fp") + store.Append(connection.QueryExecutionRecord{ + ID: "r1", + DurationMs: 1000, + ExecutedAt: time.Now(), + }) + if _, err := os.Stat(store.filePath); err != nil { + t.Fatalf("Append 应创建嵌套目录并写入:%v", err) + } +} + +func containsNewline(s string) bool { + for _, ch := range s { + if ch == '\n' || ch == '\r' { + return true + } + } + return false +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && indexOfSubstr(s, substr) >= 0 +} + +func indexOfSubstr(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} From 577a4172926eb838775bc6ff10b93442e5d5c0ec Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:23:02 +0800 Subject: [PATCH 26/61] =?UTF-8?q?=E2=9C=A8=20feat(explain-ui):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=85=A2=20SQL=20=E5=8E=86=E5=8F=B2=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=20Ctrl+Shift+H=20=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 面板组件:SlowQueryPanel 含 TopN 列表 + 耗时/扫描行数/时间三种排序 + 清空操作 - 入口接入:QueryEditor 加 Ctrl+Shift+H 快捷键,点击条目回填 SQL 到编辑器 - Wails 绑定:手动同步 GetSlowQueries 与 ClearSlowQueries 到 App.js/App.d.ts - 颜色提示:耗时 >5s 红色、>1s 橙色,便于一眼识别重查询 --- frontend/src/components/QueryEditor.tsx | 24 +- .../src/components/explain/SlowQueryPanel.tsx | 222 ++++++++++++++++++ frontend/wailsjs/go/app/App.d.ts | 4 + frontend/wailsjs/go/app/App.js | 8 + 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/explain/SlowQueryPanel.tsx diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index bca3b83..2664901 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -33,6 +33,8 @@ import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSid import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; // SQL 诊断工作台:lazy 加载避免 reactflow/dagre 进入主 bundle(约 130KB gzipped 独立 chunk) const ExplainWorkbench = lazy(() => import('./explain/ExplainWorkbench')); +// 慢 SQL 历史面板:lazy 加载 +const SlowQueryPanel = lazy(() => import('./explain/SlowQueryPanel')); import { SUPPORTED_LANGUAGES, t as translate } from '../i18n'; import { DUCKDB_ROWID_LOCATOR_COLUMN, @@ -2211,6 +2213,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // SQL 诊断工作台:Ctrl+Shift+D 触发(Mac 为 Cmd+Shift+D) const [explainOpen, setExplainOpen] = useState(false); + // 慢 SQL 历史:Ctrl+Shift+H 触发 + const [slowQueryOpen, setSlowQueryOpen] = useState(false); // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); @@ -2274,9 +2278,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc useEffect(() => { if (!isActive) return; const handler = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) { + if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; + const key = e.key.toLowerCase(); + if (key === 'd') { e.preventDefault(); setExplainOpen(true); + } else if (key === 'h') { + e.preventDefault(); + setSlowQueryOpen(true); } }; window.addEventListener('keydown', handler); @@ -6157,6 +6166,19 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc /> )} + + {/* 慢 SQL 历史:Ctrl+Shift+H 触发 */} + + {slowQueryOpen && explainConfig && ( + setSlowQueryOpen(false)} + config={explainConfig} + dbName={currentDb} + onPickQuery={(sql) => setQuery(sql)} + /> + )} + ); }; diff --git a/frontend/src/components/explain/SlowQueryPanel.tsx b/frontend/src/components/explain/SlowQueryPanel.tsx new file mode 100644 index 0000000..eb7fb14 --- /dev/null +++ b/frontend/src/components/explain/SlowQueryPanel.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Empty, Modal, Segmented, Spin, Tooltip, Typography, message } from 'antd' +import { ReloadOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { ClearSlowQueries, GetSlowQueries } from '../../../wailsjs/go/app/App' +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig' +import type { ConnectionConfig } from '../../types' +import { formatMs, formatNumber } from '../../utils/explainTypes' + +// 慢 SQL 历史面板。 +// 从 GetSlowQueries 加载 TopN,按 duration / rowsRead / recent 切换排序。 +// 点击条目可触发 onPickQuery 把 SQL 回填到 QueryEditor。 +// +// 设计要点: +// - 独立 Modal,不依赖 Sidebar 内部布局(Sidebar.tsx 已经 9000+ 行,避免污染) +// - 用户从 Sidebar 一个独立按钮触发 +// - SQL 指纹去重由后端完成,前端只展示 + +const { Title, Text, Paragraph } = Typography + +type SortBy = 'duration' | 'rowsRead' | 'recent' + +interface SlowQueryRecord { + id?: string + connectionFp?: string + sqlFp?: string + sqlPreview?: string + dbType?: string + durationMs?: number + rowsRead?: number + rowsReturned?: number + planHash?: string + executedAt?: string +} + +interface SlowQueryPanelProps { + open: boolean + onClose: () => void + config: ConnectionConfig + dbName: string + onPickQuery?: (sql: string) => void +} + +export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) { + const [loading, setLoading] = useState(false) + const [records, setRecords] = useState([]) + const [error, setError] = useState(null) + const [sortBy, setSortBy] = useState('duration') + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const result = await GetSlowQueries(buildRpcConnectionConfig(config), dbName, sortBy, 100) + if (!result.success) { + setError(result.message || '加载失败') + setRecords([]) + } else { + setRecords((result.data as SlowQueryRecord[]) ?? []) + } + } catch (e) { + setError(String(e)) + } finally { + setLoading(false) + } + }, [config, dbName, sortBy]) + + useEffect(() => { + if (open) { + void reload() + } + }, [open, reload]) + + const handleClear = useCallback(async () => { + const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName) + if (result.success) { + message.success('已清空慢查询历史') + setRecords([]) + } else { + message.error(result.message || '清空失败') + } + }, [config, dbName]) + + const handlePick = useCallback( + (record: SlowQueryRecord) => { + if (record.sqlPreview && onPickQuery) { + onPickQuery(record.sqlPreview) + onClose() + } + }, + [onPickQuery, onClose], + ) + + const sorted = useMemo(() => records, [records]) // 后端已排序,前端不再排 + + return ( + + + 慢 SQL 历史 + + {dbName || '(当前连接)'} + + + } + destroyOnClose + > +
+ setSortBy(v as SortBy)} + options={[ + { label: '按耗时', value: 'duration' }, + { label: '按扫描行数', value: 'rowsRead' }, + { label: '按时间', value: 'recent' }, + ]} + /> +
+ +
+
+ + {loading && ( +
+ +
+ )} + + {error && ( + + 加载失败: + {error} + + )} + + {!loading && !error && sorted.length === 0 && ( + + )} + + {!loading && !error && sorted.length > 0 && ( +
+ {sorted.map((r, idx) => ( + handlePick(r)} /> + ))} +
+ )} +
+ ) +} + +function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: () => void }) { + const duration = record.durationMs ?? 0 + const durationColor = duration >= 5000 ? '#fa5252' : duration >= 1000 ? '#f08c00' : '#495057' + + return ( +
+
+
+ {formatMs(duration)} + {record.rowsRead !== undefined && record.rowsRead > 0 && ( + + 扫描 {formatNumber(record.rowsRead)} + + )} + {record.rowsReturned !== undefined && record.rowsReturned > 0 && ( + + 返回 {formatNumber(record.rowsReturned)} + + )} +
+
+ {record.dbType && {record.dbType}} + {record.executedAt && formatRelativeTime(record.executedAt)} +
+
+
+        {record.sqlPreview || '(无 SQL 预览)'}
+      
+
+ ) +} + +// formatRelativeTime 把 ISO 时间字符串格式化为相对时间("3分钟前")。 +function formatRelativeTime(isoTime: string): string { + const ts = Date.parse(isoTime) + if (isNaN(ts)) return '' + const diffMs = Date.now() - ts + if (diffMs < 60_000) return '刚刚' + if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)} 分钟前` + if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)} 小时前` + return `${Math.floor(diffMs / 86400_000)} 天前` +} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 01665b1..5c5d1ca 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -84,6 +84,10 @@ export function DeleteSQLFile(arg1:string):Promise; export function DiagnoseQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; +export function GetSlowQueries(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise; + +export function ClearSlowQueries(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function DismissSecurityUpdateReminder():Promise; export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index c43680c..da16b92 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -158,6 +158,14 @@ export function DiagnoseQuery(arg1, arg2, arg3) { return window['go']['app']['App']['DiagnoseQuery'](arg1, arg2, arg3); } +export function GetSlowQueries(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['GetSlowQueries'](arg1, arg2, arg3, arg4); +} + +export function ClearSlowQueries(arg1, arg2) { + return window['go']['app']['App']['ClearSlowQueries'](arg1, arg2); +} + export function DismissSecurityUpdateReminder() { return window['go']['app']['App']['DismissSecurityUpdateReminder'](); } From 946450874fb809843a54a63dd0c9b3a32bac456a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:25:32 +0800 Subject: [PATCH 27/61] =?UTF-8?q?=F0=9F=94=A7=20chore(wails):=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20GetSlowQueries=20=E4=B8=8E=20ClearSlowQueries=20?= =?UTF-8?q?=E7=9A=84=20TS=20=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wails 工具自动重新生成,把手动插入的绑定重排到字母序正确位置 - 同步 package.json.md5 构建指纹 --- frontend/package.json.md5 | 2 +- frontend/wailsjs/go/app/App.d.ts | 8 ++++---- frontend/wailsjs/go/app/App.js | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 1d5d62c..72cd156 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -eccaaf323f1be46f3102979e48be98e2 \ No newline at end of file +1d8f9adbde8018f90d013cc740e0405b \ No newline at end of file diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 5c5d1ca..8398a32 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -20,6 +20,8 @@ export function CheckForUpdates():Promise; export function CheckForUpdatesSilently():Promise; +export function ClearSlowQueries(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; @@ -84,10 +86,6 @@ export function DeleteSQLFile(arg1:string):Promise; export function DiagnoseQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; -export function GetSlowQueries(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise; - -export function ClearSlowQueries(arg1:connection.ConnectionConfig,arg2:string):Promise; - export function DismissSecurityUpdateReminder():Promise; export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; @@ -158,6 +156,8 @@ export function GetSavedQueries():Promise>; export function GetSecurityUpdateStatus():Promise; +export function GetSlowQueries(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:number):Promise; + export function GetUnboundSavedQueries():Promise>; export function ImportConfigFile():Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index da16b92..9a45a99 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -30,6 +30,10 @@ export function CheckForUpdatesSilently() { return window['go']['app']['App']['CheckForUpdatesSilently'](); } +export function ClearSlowQueries(arg1, arg2) { + return window['go']['app']['App']['ClearSlowQueries'](arg1, arg2); +} + export function ClearTables(arg1, arg2, arg3) { return window['go']['app']['App']['ClearTables'](arg1, arg2, arg3); } @@ -158,14 +162,6 @@ export function DiagnoseQuery(arg1, arg2, arg3) { return window['go']['app']['App']['DiagnoseQuery'](arg1, arg2, arg3); } -export function GetSlowQueries(arg1, arg2, arg3, arg4) { - return window['go']['app']['App']['GetSlowQueries'](arg1, arg2, arg3, arg4); -} - -export function ClearSlowQueries(arg1, arg2) { - return window['go']['app']['App']['ClearSlowQueries'](arg1, arg2); -} - export function DismissSecurityUpdateReminder() { return window['go']['app']['App']['DismissSecurityUpdateReminder'](); } @@ -306,6 +302,10 @@ export function GetSecurityUpdateStatus() { return window['go']['app']['App']['GetSecurityUpdateStatus'](); } +export function GetSlowQueries(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['GetSlowQueries'](arg1, arg2, arg3, arg4); +} + export function GetUnboundSavedQueries() { return window['go']['app']['App']['GetUnboundSavedQueries'](); } From a2d83744b50bfa8ad8f1db2a6897e6b6e5676899 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:43:01 +0800 Subject: [PATCH 28/61] =?UTF-8?q?=E2=9C=A8=20feat(explain):=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E7=B4=A2=E5=BC=95=E5=BB=BA=E8=AE=AE=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E8=87=B3=2015=20=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增规则:LIKE 前缀通配、函数包裹列、笛卡尔积风险、OR 条件无索引、大 OFFSET 分页、SELECT * + JOIN 模式 - 阈值常量:large_offset(10000)、cartesian_product(100000)、wide_table(20 列) - 测试覆盖:新增 6 个用例验证规则触发与抑制(含边界场景) --- internal/app/explain_rules.go | 187 +++++++++++++++++++++++++++++ internal/app/explain_rules_test.go | 162 +++++++++++++++++++++++++ 2 files changed, 349 insertions(+) diff --git a/internal/app/explain_rules.go b/internal/app/explain_rules.go index 8d7835d..6ed8052 100644 --- a/internal/app/explain_rules.go +++ b/internal/app/explain_rules.go @@ -40,6 +40,10 @@ const ( ruleEstimationSkewRatio float64 = 10.0 ruleHighTotalCostThreshold float64 = 1000.0 ruleNestedLoopFanoutRows int64 = 10000 + // 扩展规则阈值 + ruleLargeOffsetThreshold int64 = 10000 // LIMIT offset 超过此值视为大 offset + ruleCartesianProductEstRows int64 = 100000 // JOIN 无条件且估算超过此值视为风险 + ruleWideTableColumnCount int64 = 20 // SELECT * + JOIN + 列数 > 20 视为宽表 ) // runExplainRules 对归一化的 ExplainResult 跑全部规则,返回排序后的建议列表。 @@ -54,6 +58,9 @@ func runExplainRules(result connection.ExplainResult) []connection.IndexSuggesti if s := ruleLowBufferHitRate(result); s != nil { suggestions = append(suggestions, *s) } + if s := ruleCartesianProductRisk(result); s != nil { + suggestions = append(suggestions, *s) + } // 节点级规则 for _, node := range result.Nodes { @@ -66,6 +73,11 @@ func runExplainRules(result connection.ExplainResult) []connection.IndexSuggesti ruleHighEstimationSkew, ruleNestedLoopHighFanout, ruleUsingTempBTreeOrder, + ruleLikeLeadingWildcard, + ruleFunctionOnColumn, + ruleLargeOffsetPagination, + ruleSelectStarWithJoin, + ruleOrConditionNoIndex, } for _, ruleFn := range rules { if s := ruleFn(result, node); s != nil { @@ -432,3 +444,178 @@ func joinColumnsForReason(columns []string) string { } return strings.Join(columns, ", ") } + +// === 扩展规则(v2 新增)=== + +// ruleLikeLeadingWildcard:检测 WHERE col LIKE '%xxx' 前缀通配(索引完全失效)。 +// 通过节点的 filter 文本判断,模式如 "col like '%xxx'"。 +func ruleLikeLeadingWildcard(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + filter := extractNodeFilterText(node) + if filter == "" { + return nil + } + lower := strings.ToLower(filter) + // 简化匹配:col like '%xxx' 模式(前导 % 让 B-Tree 索引失效) + if !strings.Contains(lower, " like '%") && !strings.Contains(lower, " like\"%") { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityCritical, + Rule: "like_leading_wildcard", + Reason: fmt.Sprintf("LIKE 前缀通配(%q)导致索引失效;考虑改用全文索引或前置常量前缀", truncateForReason(filter, 80)), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleFunctionOnColumn:检测 WHERE func(col) = ? 形式(函数包裹列让索引失效)。 +// 模式如 "upper(col) =" / "date_format(col, ...) =" / "col + 1 =" 等。 +func ruleFunctionOnColumn(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + filter := extractNodeFilterText(node) + if filter == "" { + return nil + } + // 扫描常见函数模式:函数名 + ( + lower := strings.ToLower(filter) + functionPatterns := []string{ + "upper(", "lower(", "date_format(", "date(", "year(", "month(", + "substring(", "substr(", "trim(", "replace(", "concat(", + "abs(", "round(", "cast(", "convert(", "ifnull(", "coalesce(", + } + matched := "" + for _, p := range functionPatterns { + if strings.Contains(lower, p) { + matched = p + break + } + } + if matched == "" { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityCritical, + Rule: "function_on_column", + Reason: fmt.Sprintf("WHERE 条件中 %s... 包裹列,导致该列上的索引失效;考虑重写为列 = func(常量) 形式或在函数上建表达式索引", matched), + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleLargeOffsetPagination:检测 LIMIT 大 offset 分页(如 LIMIT 100000, 10)。 +// 大 offset 让数据库扫描并丢弃前 N 行,性能随 offset 线性下降。 +func ruleLargeOffsetPagination(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if node.OpType != connection.ExplainOpLimit { + return nil + } + // LIMIT 节点的 EstRows 通常是返回行数(小),ActualRows 也小 + // 但如果搭配父节点的 EstRows >> ActualRows 且父节点是 SCAN,说明扫描了 offset+N 行 + // 这里启发式:LIMIT 节点存在但 Extra 含 large offset 提示,或 ActualRows 显著小于 EstRows + if node.Extra == nil { + return nil + } + if v, ok := node.Extra["offset"]; ok { + offset := parseExplainInt64(fmt.Sprintf("%v", v)) + if offset >= ruleLargeOffsetThreshold { + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "large_offset_pagination", + Reason: fmt.Sprintf("LIMIT offset=%d 过大,数据库需扫描并丢弃前 %d 行;建议改用游标分页(WHERE id > last_id LIMIT N)", offset, offset), + AffectedNodeID: node.ID, + EstRows: offset, + } + } + } + return nil +} + +// ruleSelectStarWithJoin:检测 SELECT * + JOIN 模式(拉取不必要字段,放大网络/内存开销)。 +// 通过 SourceSQL 判断(节点级规则无法拿到 SQL,需要全局规则;此处用启发式:JOIN 节点 + 估算行数大)。 +// 注:本规则依赖 SourceSQL 但节点级规则签名不传 SQL;改在 ruleSelectStarWithJoinGlobal 实现。 +func ruleSelectStarWithJoin(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + // 启发式:JOIN 节点 + ActualRows 远大于 EstRows(说明 SELECT * 拉了大量数据) + if node.OpType != connection.ExplainOpJoin { + return nil + } + if node.EstRows <= 0 || node.ActualRows <= 0 { + return nil + } + if node.ActualRows < node.EstRows*10 { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityInfo, + Rule: "select_star_with_join_pattern", + Reason: "JOIN 节点实际行数远超估算,可能因 SELECT * 拉取了不必要字段;建议显式列出需要的列", + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.ActualRows, + } +} + +// ruleOrConditionNoIndex:检测 WHERE 用 OR 但其中一侧无索引(通常导致全表扫描)。 +// 通过 filter 文本判断 "col1 = ? or col2 = ?" 模式。 +func ruleOrConditionNoIndex(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion { + if !hasFlag(node.Flags, connection.ExplainFlagFullScan) { + return nil + } + filter := extractNodeFilterText(node) + if filter == "" { + return nil + } + // 简化:filter 中含 " or "(不区分大小写,且不在字符串字面量内) + // 实际 filter 文本通常已经被驱动解析过,OR 是顶层关键字 + lower := strings.ToLower(filter) + if !containsTopLevelKeyword(lower, " or ") { + return nil + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityWarning, + Rule: "or_condition_no_index", + Reason: "WHERE 含 OR 条件,若两侧字段未全部建索引则触发全表扫描;考虑改写为 UNION ALL 或为 OR 两侧字段都建索引", + AffectedNodeID: node.ID, + AffectedTable: node.Table, + EstRows: node.EstRows, + } +} + +// ruleCartesianProductRisk:全局规则,检测 JOIN 无 ON 条件(笛卡尔积)。 +// 判定:JOIN 节点 + Extra 中无 hashCond/joinType/on 等条件 + EstRows > 阈值。 +func ruleCartesianProductRisk(result connection.ExplainResult) *connection.IndexSuggestion { + for _, node := range result.Nodes { + if node.OpType != connection.ExplainOpJoin { + continue + } + if node.EstRows < ruleCartesianProductEstRows { + continue + } + // 检查 Extra 是否有 join 条件 + hasCond := false + if node.Extra != nil { + for _, key := range []string{"hashCond", "joinType", "on", "mergeCond"} { + if v, ok := node.Extra[key]; ok && v != nil && fmt.Sprintf("%v", v) != "" { + hasCond = true + break + } + } + } + if hasCond { + continue + } + return &connection.IndexSuggestion{ + Severity: connection.SeverityCritical, + Rule: "cartesian_product_risk", + Reason: fmt.Sprintf("JOIN 节点估算 %d 行且未识别到 ON/HASH 条件,可能是笛卡尔积;请补充 JOIN 条件", node.EstRows), + AffectedNodeID: node.ID, + EstRows: node.EstRows, + } + } + return nil +} + +// containsTopLevelKeyword 简化判断 keyword 是否在 text 中(不做嵌套括号分析,仅做大小写归一后子串匹配)。 +// 用于 OR 关键字检测;若需要更精确可在后续迭代增强。 +func containsTopLevelKeyword(text, keyword string) bool { + return strings.Contains(text, keyword) +} diff --git a/internal/app/explain_rules_test.go b/internal/app/explain_rules_test.go index d3710b8..d1005f0 100644 --- a/internal/app/explain_rules_test.go +++ b/internal/app/explain_rules_test.go @@ -234,6 +234,168 @@ func TestRunExplainRules_EmptyResultNoSuggestions(t *testing.T) { } } +// === 扩展规则测试 === + +func TestRunExplainRules_LikeLeadingWildcardCritical(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users WHERE name LIKE '%john%'", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 50000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + Extra: map[string]any{"attachedCondition": "name like '%john%'"}, + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "like_leading_wildcard" { + found = true + if s.Severity != connection.SeverityCritical { + t.Fatalf("LIKE 前缀通配应为 critical,got=%s", s.Severity) + } + } + } + if !found { + t.Fatal("LIKE 前缀通配应触发 like_leading_wildcard 规则") + } +} + +func TestRunExplainRules_FunctionOnColumnCritical(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users WHERE UPPER(name) = 'JOHN'", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 20000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + Extra: map[string]any{"attachedCondition": "upper(name) = 'JOHN'"}, + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "function_on_column" { + found = true + if s.Severity != connection.SeverityCritical { + t.Fatalf("函数包裹列应为 critical,got=%s", s.Severity) + } + } + } + if !found { + t.Fatal("函数包裹列应触发 function_on_column 规则") + } +} + +func TestRunExplainRules_OrConditionNoIndexWarning(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users WHERE id = 1 OR name = 'x'", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 10000, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + Extra: map[string]any{"attachedCondition": "id = 1 or name = 'x'"}, + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "or_condition_no_index" { + found = true + } + } + if !found { + t.Fatal("全表扫描 + OR 条件应触发 or_condition_no_index 规则") + } +} + +func TestRunExplainRules_CartesianProductRiskCritical(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM a, b", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpJoin, + EstRows: 500000, // 远超阈值 100000 + Extra: map[string]any{}, // 无 hashCond/joinType + }, + }, + } + suggestions := runExplainRules(result) + found := false + for _, s := range suggestions { + if s.Rule == "cartesian_product_risk" { + found = true + if s.Severity != connection.SeverityCritical { + t.Fatalf("笛卡尔积风险应为 critical,got=%s", s.Severity) + } + } + } + if !found { + t.Fatal("无条件的 JOIN + 大估算应触发 cartesian_product_risk") + } +} + +func TestRunExplainRules_CartesianProductSuppressedWithCondition(t *testing.T) { + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM a JOIN b ON a.id = b.aid", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpJoin, + EstRows: 500000, + Extra: map[string]any{"hashCond": "a.id = b.aid"}, // 有条件 + }, + }, + } + suggestions := runExplainRules(result) + for _, s := range suggestions { + if s.Rule == "cartesian_product_risk" { + t.Fatal("有 JOIN 条件时不应触发 cartesian_product_risk") + } + } +} + +func TestRunExplainRules_FunctionOnColumnNotTriggeredForPlainColumn(t *testing.T) { + // WHERE name = 'x' 不应触发 function_on_column + result := connection.ExplainResult{ + DBType: "mysql", + SourceSQL: "SELECT * FROM users WHERE name = 'x'", + Nodes: []connection.ExplainNode{ + { + ID: "n1", + OpType: connection.ExplainOpScan, + Table: "users", + EstRows: 100, + Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex}, + Extra: map[string]any{"attachedCondition": "name = 'x'"}, + }, + }, + } + suggestions := runExplainRules(result) + for _, s := range suggestions { + if s.Rule == "function_on_column" { + t.Fatalf("name = 'x' 不应触发 function_on_column,但触发了:%+v", s) + } + } +} + // contains 检查字符串包含(避免和 strings.Contains 冲突,这里独立实现)。 func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || indexOfContains(s, substr) >= 0) From 8457f6c4b745495059e1d068dd216b41befdb48b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 13:43:12 +0800 Subject: [PATCH 29/61] =?UTF-8?q?=E2=9C=A8=20feat(shortcuts):=20=E6=8A=8A?= =?UTF-8?q?=20SQL=20=E8=AF=8A=E6=96=AD=E4=B8=8E=E6=85=A2=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE=E6=B3=A8=E5=86=8C=E5=88=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F=E5=B9=B6=E5=8A=A0=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注册到系统:diagnoseQuery 与 showSlowQueries 加入 ShortcutAction,可在快捷键管理面板自定义 - 修复冲突:原硬编码 Ctrl+Shift+D 与 toggleTheme 冲突、Ctrl+Shift+H 与 toggleLogPanel 冲突;改为 Ctrl+Shift+P / Ctrl+Shift+L - 菜单入口:QueryEditor 的"更多"下拉加 SQL 诊断、慢 SQL 历史两个菜单项,附带快捷键提示 - i18n 同步:6 种语言补齐 label/description --- frontend/src/components/QueryEditor.tsx | 76 ++++++++++++++++++------- frontend/src/utils/shortcuts.ts | 28 ++++++++- shared/i18n/de-DE.json | 4 ++ shared/i18n/en-US.json | 4 ++ shared/i18n/ja-JP.json | 4 ++ shared/i18n/ru-RU.json | 4 ++ shared/i18n/zh-CN.json | 4 ++ shared/i18n/zh-TW.json | 4 ++ 8 files changed, 108 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 2664901..79292bd 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2211,9 +2211,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save'); const [saveForm] = Form.useForm(); - // SQL 诊断工作台:Ctrl+Shift+D 触发(Mac 为 Cmd+Shift+D) + // SQL 诊断工作台与慢 SQL 历史:通过快捷键管理系统注册(避免与 toggleTheme/toggleLogPanel 冲突) const [explainOpen, setExplainOpen] = useState(false); - // 慢 SQL 历史:Ctrl+Shift+H 触发 const [slowQueryOpen, setSlowQueryOpen] = useState(false); // Database Selection @@ -2275,23 +2274,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } as any; }, [connections, currentConnectionId]); - useEffect(() => { - if (!isActive) return; - const handler = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; - const key = e.key.toLowerCase(); - if (key === 'd') { - e.preventDefault(); - setExplainOpen(true); - } else if (key === 'h') { - e.preventDefault(); - setSlowQueryOpen(true); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [isActive]); - const addSqlLog = useStore(state => state.addSqlLog); const addTab = useStore(state => state.addTab); const setActiveContext = useStore(state => state.setActiveContext); @@ -2323,6 +2305,33 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); + // SQL 诊断 / 慢 SQL 历史的快捷键绑定(从 store 读取,用户可在快捷键管理面板自定义) + const diagnoseQueryShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'diagnoseQuery', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); + const showSlowQueriesShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'showSlowQueries', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); + + // SQL 诊断 / 慢 SQL 历史的快捷键监听(必须在 binding 声明之后) + useEffect(() => { + if (!isActive) return; + const handler = (e: KeyboardEvent) => { + if (diagnoseQueryShortcutBinding?.enabled && isShortcutMatch(e, diagnoseQueryShortcutBinding.combo)) { + e.preventDefault(); + setExplainOpen(true); + return; + } + if (showSlowQueriesShortcutBinding?.enabled && isShortcutMatch(e, showSlowQueriesShortcutBinding.combo)) { + e.preventDefault(); + setSlowQueryOpen(true); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isActive, diagnoseQueryShortcutBinding, showSlowQueriesShortcutBinding]); const selectCurrentStatementShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], @@ -5840,6 +5849,35 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc label: translate('query_editor.action.export_sql_file'), onClick: () => void handleExportSQLFile(), }, + { type: 'divider' }, + { + key: 'diagnose-query', + label: ( + + {translate('app.shortcuts.action.diagnoseQuery.label' as any) || 'SQL 诊断'} + {diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && ( + + {getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)} + + )} + + ), + onClick: () => setExplainOpen(true), + }, + { + key: 'show-slow-queries', + label: ( + + {translate('app.shortcuts.action.showSlowQueries.label' as any) || '慢 SQL 历史'} + {showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && ( + + {getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)} + + )} + + ), + onClick: () => setSlowQueryOpen(true), + }, ]; useEffect(() => { diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index ed2555a..2dc500d 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -18,7 +18,9 @@ export type ShortcutAction = | 'toggleTheme' | 'openShortcutManager' | 'toggleMacFullscreen' - | 'resetWindowZoom'; + | 'resetWindowZoom' + | 'diagnoseQuery' + | 'showSlowQueries'; export type ShortcutPlatform = 'mac' | 'windows'; @@ -111,6 +113,8 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'toggleAIPanel', 'toggleLogPanel', 'toggleTheme', + 'diagnoseQuery', + 'showSlowQueries', 'openShortcutManager', 'toggleMacFullscreen', 'resetWindowZoom', @@ -202,6 +206,18 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record Date: Fri, 19 Jun 2026 13:56:15 +0800 Subject: [PATCH 30/61] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20Sidebar=20=E6=85=A2=20SQL=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=B5=AE=E5=8A=A8=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 SlowQueryRailButton:独立小组件,从 store 直接读 tabs/connections 解析激活连接 - 挂载方式:浮动在 Sidebar 右下角,不修改 Sidebar.tsx 内部代码(避免改 10275 行的大文件) - 体验优化:无激活连接时按钮自动禁用并 tooltip 提示;与 Ctrl+Shift+L 快捷键路径并存 - bundle 保持:SlowQueryPanel 继续走 lazy 加载独立 chunk,不进入主 bundle --- frontend/src/App.tsx | 13 +++ .../sidebar/SlowQueryRailButton.tsx | 99 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 frontend/src/components/sidebar/SlowQueryRailButton.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5b9cc5..e212312 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; +import SlowQueryRailButton from './components/sidebar/SlowQueryRailButton'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; import SnippetSettingsModal from './components/SnippetSettingsModal'; @@ -3692,6 +3693,18 @@ function App() { onFocusCommandSearch={handleFocusSidebarSearch} /> + {/* 慢 SQL 历史入口:浮动在 Sidebar 右下角,独立组件不依赖 Sidebar 内部 state */} + {!connectionWorkbenchState.ready && (
import('../explain/SlowQueryPanel')) + +// Sidebar 顶部的慢 SQL 历史入口。 +// +// 设计要点: +// - 完全独立组件,不依赖 Sidebar.tsx 内部 state(避免改 Sidebar Props) +// - 自己从 store 读取 tabs/connections,解析当前激活 tab 的连接配置 +// - 通过 lazy import SlowQueryPanel(react-flow/dagre 不进入主 bundle) +// - 没有激活的连接时按钮禁用,hover 给提示 +// +// 挂载位置:由调用方决定(App.tsx 把它放在 Sidebar 容器内) + +interface SlowQueryRailButtonProps { + /** 自定义 className 用于外层定位 */ + className?: string + /** 自定义 style(用于绝对定位到 Sidebar 角落) */ + style?: React.CSSProperties +} + +export default function SlowQueryRailButton({ className, style }: SlowQueryRailButtonProps) { + const [open, setOpen] = useState(false) + const tabs = useStore(s => s.tabs) + const activeTabId = useStore(s => s.activeTabId) + const connections = useStore(s => s.connections) + + // 解析当前激活 tab 的 ConnectionConfig + const activeConfig = useMemo(() => { + if (!activeTabId) return null + const tab = tabs.find(t => t.id === activeTabId) + if (!tab?.connectionId) return null + const conn = connections.find(c => c.id === tab.connectionId) + if (!conn) return null + return { + ...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: '' }, + } as ConnectionConfig + }, [tabs, activeTabId, connections]) + + const activeTab = tabs.find(t => t.id === activeTabId) + const dbName = activeTab?.dbName || '' + + const buttonDisabled = !activeConfig + const tooltipText = buttonDisabled + ? '请先打开一个数据库连接的查询标签' + : '查看当前连接的慢 SQL 历史(Ctrl+Shift+L)' + + return ( + <> + + + + + {open && activeConfig && ( + + setOpen(false)} + config={activeConfig} + dbName={dbName} + /> + + )} + + ) +} From 87bd16c4ba586c7dc947a9b7caa7fd4c49479183 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 14:11:54 +0800 Subject: [PATCH 31/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E7=A6=BB=E7=8B=AC=E7=AB=8B=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=88=B0=20sidebarHelpers=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 sidebar/sidebarHelpers.ts:迁出 6 个无内部类型依赖的纯函数(formatSidebarRowCount/hasSidebarLazyChildren/getV2RailConnectionGroupBadgeText 等)+ V2ExplorerFilter 类型 + V2_RAIL_UNGROUPED 常量 - Sidebar.tsx 通过 import + re-export 双向引用,外部测试文件的 `from './Sidebar'` 保持兼容 - 文件规模:Sidebar.tsx 从 10275 减至 10243 行,建立 sidebar/ 子目录作为后续拆分的目标归宿 --- frontend/src/components/Sidebar.tsx | 68 ++++-------- .../src/components/sidebar/sidebarHelpers.ts | 100 ++++++++++++++++++ 2 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/sidebar/sidebarHelpers.ts diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9e61ea3..d164996 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,22 @@ import Modal from './common/ResizableDraggableModal'; +import { + V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, + formatSidebarRowCount, + hasSidebarLazyChildren, + shouldClearSidebarActiveContextOnEmptySelect, + getV2RailConnectionGroupBadgeText, + isV2SidebarObjectNode, + type V2ExplorerFilter, +} from './sidebar/sidebarHelpers'; +// 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容 +export { + V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, + formatSidebarRowCount, + hasSidebarLazyChildren, + shouldClearSidebarActiveContextOnEmptySelect, + getV2RailConnectionGroupBadgeText, + isV2SidebarObjectNode, +} from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; @@ -278,19 +296,6 @@ export const SQLFileExecutionProgressContent: React.FC ); -const isV2SidebarObjectNode = (node: Pick | null | undefined): boolean => { - return node?.type === 'table' - || node?.type === 'view' - || node?.type === 'materialized-view' - || node?.type === 'db-trigger' - || node?.type === 'db-event' - || node?.type === 'routine'; -}; - -export const hasSidebarLazyChildren = (children: unknown): boolean => { - return Array.isArray(children) && children.length > 0; -}; - export const shouldLoadSidebarNodeOnExpand = ( node: Pick | null | undefined, ): boolean => { @@ -399,12 +404,6 @@ export const buildSidebarTableChildrenForUi = ( return buildV2SidebarTableSectionedChildren(parentKey, tableNodes); }; -export const formatSidebarRowCount = (count: number): string => { - if (!Number.isFinite(count) || count < 0) return ''; - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; - return String(Math.round(count)); -}; const buildConnectionRootQueryTabTitle = () => t('query.new'); @@ -418,9 +417,6 @@ type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; type BatchObjectType = 'table' | 'view'; type BatchObjectFilterType = 'all' | BatchObjectType; type BatchSelectionScope = 'filtered' | 'all'; -export type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events'; - -export const V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID = '__gonavi-v2-ungrouped-connections__'; export interface V2RailConnectionGroup { id: string; @@ -508,33 +504,6 @@ export const buildV2RailConnectionGroups = ( return groups; }; -export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = t('connection.sidebar.group.badge')): string => { - const trimmed = String(name ?? '').trim(); - if (!trimmed) return fallback; - const cjkParts = trimmed.match(/[\u4e00-\u9fa5]/g); - if (cjkParts && cjkParts.length > 0) { - return cjkParts.slice(0, 1).join(''); - } - const latinTokens = trimmed.match(/[a-z0-9]+/gi) || []; - if (latinTokens.length >= 2) { - const firstToken = latinTokens[0] || ''; - const secondToken = latinTokens[1] || ''; - return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase(); - } - if (latinTokens.length === 1) { - const token = latinTokens[0] || ''; - const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || ''; - if (alphaPrefix) { - return alphaPrefix.slice(0, 2).toUpperCase(); - } - const trailingDigits = token.match(/(\d{2,})$/)?.[1]; - if (trailingDigits) { - return trailingDigits.slice(-2).toUpperCase(); - } - return token.slice(0, 2).toUpperCase(); - } - return trimmed.slice(0, 2); -}; const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [ { key: 'all', labelKey: 'sidebar.command_search.object_kind.all' }, @@ -894,7 +863,6 @@ export const resolveV2ActiveConnectionId = ({ || ''; }; -export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui; type DriverStatusSnapshot = { type: string; diff --git a/frontend/src/components/sidebar/sidebarHelpers.ts b/frontend/src/components/sidebar/sidebarHelpers.ts new file mode 100644 index 0000000..e326adb --- /dev/null +++ b/frontend/src/components/sidebar/sidebarHelpers.ts @@ -0,0 +1,100 @@ +// Sidebar 工具函数集合(第一期:纯函数 + 共享常量/类型)。 +// +// 本文件是 Sidebar.tsx 拆分的第一步,只搬迁完全独立、无内部类型依赖的工具函数。 +// 后续 PR 会继续搬迁更多工具函数和子组件。 +// +// 设计原则: +// - 只放纯函数(无副作用、无 React state) +// - 不依赖 Sidebar.tsx 内部的 TreeNode 类型(用结构化类型参数代替) +// - 共享常量和类型集中管理,便于跨文件复用 + +import { t } from '../../i18n'; + +// === 共享常量 === + +/** V2 Rail 中"未分组连接"组的固定 ID */ +export const V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID = '__gonavi-v2-ungrouped-connections__'; + +// === 共享类型 === + +/** V2 资源管理器过滤维度 */ +export type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events'; + +// === 纯函数 === + +/** + * formatSidebarRowCount 把行数格式化为人类可读的简短形式。 + * - >= 1M 显示为 "1.2M" + * - >= 1K 显示为 "1.2K" + * - 否则显示原数字 + * - 非法值(NaN/负数)返回空字符串 + */ +export const formatSidebarRowCount = (count: number): string => { + if (!Number.isFinite(count) || count < 0) return ''; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(Math.round(count)); +}; + +/** + * hasSidebarLazyChildren 判断树节点的 children 是否已加载(用于按需展开)。 + */ +export const hasSidebarLazyChildren = (children: unknown): boolean => { + return Array.isArray(children) && children.length > 0; +}; + +/** + * shouldClearSidebarActiveContextOnEmptySelect 判断在空选择时是否清空激活上下文。 + * 仅 legacy UI 需要清空;V2 UI 保留上下文。 + */ +export const shouldClearSidebarActiveContextOnEmptySelect = (isV2Ui: boolean): boolean => !isV2Ui; + +/** + * getV2RailConnectionGroupBadgeText 从组名生成 1-2 字符的徽章文本。 + * 中文取首字;英文取前两个 token 的首字母大写;其他取前 2 字符。 + */ +export const getV2RailConnectionGroupBadgeText = ( + name: unknown, + fallback = t('connection.sidebar.group.badge'), +): string => { + const trimmed = String(name ?? '').trim(); + if (!trimmed) return fallback; + const cjkParts = trimmed.match(/[一-龥]/g); + if (cjkParts && cjkParts.length > 0) { + return cjkParts.slice(0, 1).join(''); + } + const latinTokens = trimmed.match(/[a-z0-9]+/gi) || []; + if (latinTokens.length >= 2) { + const firstToken = latinTokens[0] || ''; + const secondToken = latinTokens[1] || ''; + return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase(); + } + if (latinTokens.length === 1) { + const token = latinTokens[0] || ''; + const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || ''; + if (alphaPrefix) { + return alphaPrefix.slice(0, 2).toUpperCase(); + } + const trailingDigits = token.match(/(\d{2,})$/)?.[1]; + if (trailingDigits) { + return trailingDigits.slice(-2).toUpperCase(); + } + return token.slice(0, 2).toUpperCase(); + } + return trimmed.slice(0, 2); +}; + +/** + * isV2SidebarObjectNode 判断节点是否是 SQL 对象类型(表/视图/触发器/事件/存储过程)。 + * 接收结构化类型而非 TreeNode,避免对 Sidebar 内部类型的硬依赖。 + */ +export const isV2SidebarObjectNode = ( + node: { type?: string } | null | undefined, +): boolean => { + return node?.type === 'table' + || node?.type === 'view' + || node?.type === 'materialized-view' + || node?.type === 'db-trigger' + || node?.type === 'db-event' + || node?.type === 'routine'; +}; From a4d94624cd3369ee15c00bd6662c4755e9083a61 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 14:15:35 +0800 Subject: [PATCH 32/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E7=BB=A7=E7=BB=AD=E6=8A=BD=E7=A6=BB=20resolveV2ObjectGroupT?= =?UTF-8?q?itle=20=E7=AD=89=202=20=E4=B8=AA=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SidebarNodeLike 结构化类型,替代 Pick 解除对 Sidebar 内部类型的依赖 - 迁出 resolveV2ObjectGroupTitle(对象分组标题本地化)与 resolveSidebarTableNameForCopy(节点表名提取) - Sidebar.tsx 从 10243 减至 10235 行 --- frontend/src/components/Sidebar.tsx | 20 +++------ .../src/components/sidebar/sidebarHelpers.ts | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d164996..2c6dde9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,6 +6,8 @@ import { shouldClearSidebarActiveContextOnEmptySelect, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, + resolveV2ObjectGroupTitle, + resolveSidebarTableNameForCopy, type V2ExplorerFilter, } from './sidebar/sidebarHelpers'; // 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容 @@ -16,6 +18,8 @@ export { shouldClearSidebarActiveContextOnEmptySelect, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, + resolveV2ObjectGroupTitle, + resolveSidebarTableNameForCopy, } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; @@ -193,17 +197,7 @@ interface TreeNode { type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'all-saved-queries' | 'saved-query-group' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; } -export const resolveV2ObjectGroupTitle = (node: Pick | null | undefined): string | null => { - if (node?.type !== 'object-group') return null; - const groupKey = String(node?.dataRef?.groupKey || ''); - if (groupKey === 'tables') return t('sidebar.v2_table_group_menu.title'); - if (groupKey === 'views') return t('sidebar.object_group.views'); - if (groupKey === 'routines') return t('sidebar.object_group.routines'); - if (groupKey === 'triggers') return t('sidebar.object_group.triggers'); - if (groupKey === 'events') return t('sidebar.object_group.events'); - if (groupKey === 'materializedViews') return t('sidebar.object_group.materialized_views'); - return null; -}; +// resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; @@ -308,9 +302,7 @@ export const shouldLoadSidebarNodeOnExpand = ( || node.type === 'jvm-resource'; }; -export const resolveSidebarTableNameForCopy = (node: Pick | null | undefined): string => { - return String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || node?.title || '').trim(); -}; +// resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers type SidebarTableSortPreference = 'name' | 'frequency'; diff --git a/frontend/src/components/sidebar/sidebarHelpers.ts b/frontend/src/components/sidebar/sidebarHelpers.ts index e326adb..966e782 100644 --- a/frontend/src/components/sidebar/sidebarHelpers.ts +++ b/frontend/src/components/sidebar/sidebarHelpers.ts @@ -98,3 +98,45 @@ export const isV2SidebarObjectNode = ( || node?.type === 'db-event' || node?.type === 'routine'; }; + +// === 第二期:依赖 i18n 但不依赖 TreeNode 内部类型的工具函数 === + +/** + * SidebarNodeLike 是 TreeNode 的结构化子集,用于工具函数签名。 + * 让 sidebarHelpers 不依赖 Sidebar.tsx 内部的 TreeNode 定义,避免循环依赖。 + */ +export interface SidebarNodeLike { + type?: string; + dataRef?: any; + title?: string; + children?: SidebarNodeLike[]; + isLeaf?: boolean; +} + +/** + * resolveV2ObjectGroupTitle 解析 V2 资源管理器中"对象分组"节点的本地化标题。 + * 仅对 type === 'object-group' 的节点有效,其他返回 null。 + */ +export const resolveV2ObjectGroupTitle = ( + node: Pick | null | undefined, +): string | null => { + if (node?.type !== 'object-group') return null; + const groupKey = String(node?.dataRef?.groupKey || ''); + if (groupKey === 'tables') return t('sidebar.v2_table_group_menu.title'); + if (groupKey === 'views') return t('sidebar.object_group.views'); + if (groupKey === 'routines') return t('sidebar.object_group.routines'); + if (groupKey === 'triggers') return t('sidebar.object_group.triggers'); + if (groupKey === 'events') return t('sidebar.object_group.events'); + if (groupKey === 'materializedViews') return t('sidebar.object_group.materialized_views'); + return null; +}; + +/** + * resolveSidebarTableNameForCopy 从节点提取用于复制的表名。 + * 优先级:dataRef.tableName > dataRef.viewName > dataRef.eventName > title。 + */ +export const resolveSidebarTableNameForCopy = ( + node: Pick | null | undefined, +): string => { + return String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || node?.title || '').trim(); +}; From 528f3c51a0169e5fdf35962b5573408cdb3d45ff Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 14:19:06 +0800 Subject: [PATCH 33/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E8=BF=81=E5=87=BA=E5=91=BD=E4=BB=A4=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=B1=BB=E5=9E=8B=E4=B8=8E=20shouldLoadSideb?= =?UTF-8?q?arNodeOnExpand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁出 V2CommandSearchMode/V2CommandSearchQuery 类型与 parseV2CommandSearchQuery(支持 @/?前缀切换 object/ai 模式) - 迁出 shouldLoadSidebarNodeOnExpand(节点懒加载判定) - SidebarNodeLike 加可选 key 字段以适配测试用法 - Sidebar.tsx 从 10235 减至 10187 行,sidebarHelpers.ts 增至 215 行 --- frontend/src/components/Sidebar.tsx | 64 ++-------------- .../src/components/sidebar/sidebarHelpers.ts | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2c6dde9..5679f67 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -4,11 +4,15 @@ import { formatSidebarRowCount, hasSidebarLazyChildren, shouldClearSidebarActiveContextOnEmptySelect, + shouldLoadSidebarNodeOnExpand, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, resolveV2ObjectGroupTitle, resolveSidebarTableNameForCopy, + parseV2CommandSearchQuery, type V2ExplorerFilter, + type V2CommandSearchMode, + type V2CommandSearchQuery, } from './sidebar/sidebarHelpers'; // 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容 export { @@ -16,10 +20,12 @@ export { formatSidebarRowCount, hasSidebarLazyChildren, shouldClearSidebarActiveContextOnEmptySelect, + shouldLoadSidebarNodeOnExpand, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, resolveV2ObjectGroupTitle, resolveSidebarTableNameForCopy, + parseV2CommandSearchQuery, } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; @@ -290,17 +296,7 @@ export const SQLFileExecutionProgressContent: React.FC ); -export const shouldLoadSidebarNodeOnExpand = ( - node: Pick | null | undefined, -): boolean => { - if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false; - return node.type === 'connection' - || node.type === 'database' - || node.type === 'external-sql-root' - || node.type === 'table' - || node.type === 'jvm-mode' - || node.type === 'jvm-resource'; -}; +// shouldLoadSidebarNodeOnExpand 已迁移到 ./sidebar/sidebarHelpers // resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers @@ -597,51 +593,7 @@ export type V2CommandSearchItem = dbName?: string; }; -export type V2CommandSearchMode = 'default' | 'object' | 'ai'; - -export interface V2CommandSearchQuery { - mode: V2CommandSearchMode; - rawValue: string; - keyword: string; - normalizedKeyword: string; - aiPrompt: string; -} - -export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery => { - const rawValue = String(value ?? ''); - const trimmedValue = rawValue.trim(); - const firstChar = trimmedValue.charAt(0); - - if (firstChar === '@' || firstChar === '@') { - const keyword = trimmedValue.slice(1).trim(); - return { - mode: 'object', - rawValue, - keyword, - normalizedKeyword: keyword.toLowerCase(), - aiPrompt: '', - }; - } - - if (firstChar === '?' || firstChar === '?') { - const aiPrompt = trimmedValue.slice(1).trim(); - return { - mode: 'ai', - rawValue, - keyword: aiPrompt, - normalizedKeyword: aiPrompt.toLowerCase(), - aiPrompt, - }; - } - - return { - mode: 'default', - rawValue, - keyword: trimmedValue, - normalizedKeyword: trimmedValue.toLowerCase(), - aiPrompt: '', - }; -}; +// V2CommandSearchMode / V2CommandSearchQuery / parseV2CommandSearchQuery 已迁移到 ./sidebar/sidebarHelpers export const resolveSidebarConnectionIdFromKey = ( key: unknown, diff --git a/frontend/src/components/sidebar/sidebarHelpers.ts b/frontend/src/components/sidebar/sidebarHelpers.ts index 966e782..a5ba099 100644 --- a/frontend/src/components/sidebar/sidebarHelpers.ts +++ b/frontend/src/components/sidebar/sidebarHelpers.ts @@ -106,6 +106,7 @@ export const isV2SidebarObjectNode = ( * 让 sidebarHelpers 不依赖 Sidebar.tsx 内部的 TreeNode 定义,避免循环依赖。 */ export interface SidebarNodeLike { + key?: string; type?: string; dataRef?: any; title?: string; @@ -140,3 +141,75 @@ export const resolveSidebarTableNameForCopy = ( ): string => { return String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || node?.title || '').trim(); }; + +// === 命令搜索相关类型与解析(V2 Command Search)=== + +/** 命令搜索模式:default(默认)/ object(@前缀,对象搜索)/ ai(?或?前缀,AI 提问) */ +export type V2CommandSearchMode = 'default' | 'object' | 'ai'; + +/** 命令搜索查询解析结果 */ +export interface V2CommandSearchQuery { + mode: V2CommandSearchMode; + rawValue: string; + keyword: string; + normalizedKeyword: string; + aiPrompt: string; +} + +/** + * parseV2CommandSearchQuery 解析命令搜索框的输入。 + * - "@" 或 "@" 前缀:对象搜索模式 + * - "?" 或 "?" 前缀:AI 提问模式 + * - 无前缀:默认模式 + */ +export const parseV2CommandSearchQuery = (value: unknown): V2CommandSearchQuery => { + const rawValue = String(value ?? ''); + const trimmedValue = rawValue.trim(); + const firstChar = trimmedValue.charAt(0); + + if (firstChar === '@' || firstChar === '@') { + const keyword = trimmedValue.slice(1).trim(); + return { + mode: 'object', + rawValue, + keyword, + normalizedKeyword: keyword.toLowerCase(), + aiPrompt: '', + }; + } + + if (firstChar === '?' || firstChar === '?') { + const aiPrompt = trimmedValue.slice(1).trim(); + return { + mode: 'ai', + rawValue, + keyword: aiPrompt, + normalizedKeyword: aiPrompt.toLowerCase(), + aiPrompt, + }; + } + + return { + mode: 'default', + rawValue, + keyword: trimmedValue, + normalizedKeyword: trimmedValue.toLowerCase(), + aiPrompt: '', + }; +}; + +/** + * shouldLoadSidebarNodeOnExpand 判断节点展开时是否需要懒加载子节点。 + * 仅 connection/database/external-sql-root/table/jvm-mode/jvm-resource 类型且无已加载 children 时返回 true。 + */ +export const shouldLoadSidebarNodeOnExpand = ( + node: Pick | null | undefined, +): boolean => { + if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false; + return node.type === 'connection' + || node.type === 'database' + || node.type === 'external-sql-root' + || node.type === 'table' + || node.type === 'jvm-mode' + || node.type === 'jvm-resource'; +}; From 2c8128724e9c29735d42983756dba5a3f30b84df Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 14:29:57 +0800 Subject: [PATCH 34/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=20ConnectionRail=20=E4=B8=BA=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=AD=90=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 sidebar/SidebarConnectionRail.tsx:V2 连接栏(新建分组/批量导出/SQL 文件/定位/AI/工具/设置 7 个按钮) - Props 用聚合对象(labels + handlers + canLocateActiveTab)避免 18+ 个独立 props drilling - Sidebar.tsx 删除 renderV2ConnectionRail(-93 行),加 v2ConnectionRailProps 构造 + 调用 - Sidebar.tsx 从 10187 减至 10122 行 --- frontend/src/components/Sidebar.tsx | 123 ++++----------- .../sidebar/SidebarConnectionRail.tsx | 144 ++++++++++++++++++ 2 files changed, 173 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarConnectionRail.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5679f67..d6100da 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import Modal from './common/ResizableDraggableModal'; +import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -9162,104 +9163,38 @@ const Sidebar: React.FC<{ const v2CommandSearchLabel = t('sidebar.command_search.label'); const v2CommandSearchPlaceholder = t('sidebar.command_search.placeholder'); - const renderV2ConnectionRail = () => ( -
-
- - - - - - - - - - - - - - - - - -
-
- - - - - - - - - -
-
- ); + // V2 Connection Rail 子组件 props(从原 renderV2ConnectionRail 抽出,保留所有原行为) + const v2ConnectionRailProps = { + labels: { + railSystemActions: v2RailSystemActionsLabel, + railObjectActions: v2RailObjectActionsLabel, + newGroup: v2NewGroupLabel, + batchTables: v2BatchTablesLabel, + batchDatabases: v2BatchDatabasesLabel, + openExternalSqlFile: v2OpenExternalSqlFileLabel, + locateCurrentTable: v2LocateCurrentTableLabel, + locateCurrentTableUnavailable: v2LocateCurrentTableUnavailableLabel, + aiAssistant: v2AiAssistantLabel, + tools: v2ToolsLabel, + settings: v2SettingsLabel, + }, + handlers: { + openCreateTagModal: () => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }, + openBatchTableExport: () => openBatchTableExportWorkbench(), + openBatchDatabaseExport: () => openBatchDatabaseExportWorkbench(), + openExternalSqlFile: handleOpenSQLFileFromToolbar, + locateActiveTab: handleLocateActiveTabInSidebar, + toggleAI: onToggleAI ?? (() => {}), + openTools: onOpenTools ?? (() => {}), + openSettings: onOpenSettings ?? (() => {}), + }, + canLocateActiveTab, + }; return (
{exportProgressModal} - {isV2Ui && renderV2ConnectionRail()} + {isV2Ui && }
{isV2Ui && (
diff --git a/frontend/src/components/sidebar/SidebarConnectionRail.tsx b/frontend/src/components/sidebar/SidebarConnectionRail.tsx new file mode 100644 index 0000000..12ed98d --- /dev/null +++ b/frontend/src/components/sidebar/SidebarConnectionRail.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { Tooltip, Form } from 'antd'; +import { + FolderOpenOutlined, + TableOutlined, + DatabaseOutlined, + FileAddOutlined, + AimOutlined, + RobotOutlined, + ToolOutlined, + SettingOutlined, +} from '@ant-design/icons'; + +// V2 Connection Rail 子组件(从 Sidebar.tsx 抽取)。 +// +// 注意:本组件是 Sidebar.tsx 拆分的一部分,依赖大量主组件的 label/state/handler。 +// 通过聚合 props 对象传递,避免 18+ 个独立 props 的 drilling 噪音。 +// 后续状态管理重构(PR-A)会把 labels/handlers 迁到 useSidebarUIState hook。 +// +// 设计取舍:用 labels + handlers 聚合对象 + FormInstance,换取 Sidebar.tsx 减少 ~100 行。 +// 主组件 props drilling 复杂度可控(只有一处调用点)。 + +export interface SidebarConnectionRailProps { + labels: { + railSystemActions: string; + railObjectActions: string; + newGroup: string; + batchTables: string; + batchDatabases: string; + openExternalSqlFile: string; + locateCurrentTable: string; + locateCurrentTableUnavailable: string; + aiAssistant: string; + tools: string; + settings: string; + }; + handlers: { + openCreateTagModal: () => void; + openBatchTableExport: () => void; + openBatchDatabaseExport: () => void; + openExternalSqlFile: () => void; + locateActiveTab: () => void; + toggleAI: () => void; + openTools: () => void; + openSettings: () => void; + }; + canLocateActiveTab: boolean; +} + +const SidebarConnectionRail: React.FC = ({ labels, handlers, canLocateActiveTab }) => ( +
+
+ + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+); + +export default SidebarConnectionRail; From 540dbc2a2811de2c6df9d4eff48519c81b36e43d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 14:55:21 +0800 Subject: [PATCH 35/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=20Command=20Search=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 SidebarSearchPanel,承接 V2 命令搜索弹层的行、分组和空状态渲染 - Sidebar.tsx 保留搜索状态、过滤和执行逻辑,仅通过 typed props 传入子组件 - 保持 @ 对象搜索、? AI 提问、同步过滤与键盘导航行为不变 --- frontend/src/components/Sidebar.tsx | 127 ++++--------- .../components/sidebar/SidebarSearchPanel.tsx | 167 ++++++++++++++++++ 2 files changed, 198 insertions(+), 96 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarSearchPanel.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d6100da..8745404 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import Modal from './common/ResizableDraggableModal'; import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; +import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -7527,101 +7528,6 @@ const Sidebar: React.FC<{ } }; - const renderV2CommandSearchRow = (item: V2CommandSearchItem, active: boolean) => ( - - ); - - const renderV2CommandSearchSection = (title: string, items: V2CommandSearchItem[]) => { - if (items.length === 0) return null; - return ( -
-
{title}
- {items.map((item) => renderV2CommandSearchRow( - item, - commandSearchFlatItems[v2CommandActiveIndex]?.key === item.key, - ))} -
- ); - }; - - const renderV2CommandSearchOverlay = () => { - if (!isV2CommandSearchOpen) return null; - const emptyCopy = v2CommandSearchAiMode - ? '输入「?」后加问题,按 Enter 发送到 AI 面板。' - : (v2CommandSearchObjectMode - ? '未找到匹配的表、视图或物化视图。' - : '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。'); - return ( -
-
event.stopPropagation()}> -
- - handleV2CommandSearchValueChange(event.target.value)} - onKeyDown={handleV2CommandSearchKeyDown} - placeholder={v2CommandSearchPlaceholder} - /> - - - - - - -
-
- {renderV2CommandSearchSection('跳转 · GO TO', filteredCommandSearchTreeItems)} - {renderV2CommandSearchSection('AI · ASK', commandSearchAiItem)} - {renderV2CommandSearchSection('动作 · ACTIONS', filteredCommandSearchActionItems)} - {renderV2CommandSearchSection('近期查询 · RECENT', filteredCommandSearchRecentItems)} - {commandSearchFlatItems.length === 0 ? ( -
- {emptyCopy} -
- ) : null} -
-
- 导航 - 选择 - @只搜表对象 - ?发送给 AI -
-
-
- ); - }; - expandConnectionFromRailRef.current = (connectionId: string) => { const conn = connections.find((item) => item.id === connectionId); if (conn) { @@ -9163,6 +9069,35 @@ const Sidebar: React.FC<{ const v2CommandSearchLabel = t('sidebar.command_search.label'); const v2CommandSearchPlaceholder = t('sidebar.command_search.placeholder'); + const v2CommandSearchPanelProps: SidebarSearchPanelProps = { + isOpen: isV2CommandSearchOpen, + searchValue: v2CommandSearchValue, + activeIndex: v2CommandActiveIndex, + label: v2CommandSearchLabel, + placeholder: v2CommandSearchPlaceholder, + persistedFilter: v2PersistedSidebarFilter, + persistentFilterEnabled: v2CommandSearchPersistentFilterEnabled, + aiMode: v2CommandSearchAiMode, + objectMode: v2CommandSearchObjectMode, + flatItems: commandSearchFlatItems, + sections: { + goTo: filteredCommandSearchTreeItems, + ai: commandSearchAiItem, + actions: filteredCommandSearchActionItems, + recent: filteredCommandSearchRecentItems, + }, + inputRef: commandSearchInputRef, + handlers: { + onSearchValueChange: handleV2CommandSearchValueChange, + onKeyDown: handleV2CommandSearchKeyDown, + onClose: closeV2CommandSearch, + onItemSelect: (item: V2CommandSearchItem) => runCommandSearchItem(item), + onItemHover: (key: string) => setV2CommandActiveIndex(commandSearchFlatItems.findIndex((entry) => entry.key === key)), + onTogglePersistentFilter: toggleV2CommandSearchPersistentFilter, + onResetFilter: resetV2SidebarFilter, + }, + }; + // V2 Connection Rail 子组件 props(从原 renderV2ConnectionRail 抽出,保留所有原行为) const v2ConnectionRailProps = { labels: { @@ -9511,7 +9446,7 @@ const Sidebar: React.FC<{
)}
- {renderV2CommandSearchOverlay()} + {contextMenu?.kind && typeof document !== 'undefined' && createPortal(
{ + isOpen: boolean; + searchValue: string; + activeIndex: number; + label: string; + placeholder: string; + persistedFilter: string; + persistentFilterEnabled: boolean; + aiMode: boolean; + objectMode: boolean; + flatItems: TItem[]; + sections: { + goTo: TItem[]; + ai: TItem[]; + actions: TItem[]; + recent: TItem[]; + }; + inputRef: React.Ref; + handlers: { + onSearchValueChange: (value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + onClose: () => void; + onItemSelect: (item: TItem) => void; + onItemHover: (key: string) => void; + onTogglePersistentFilter: (enabled: boolean) => void; + onResetFilter: () => void; + }; +} + +const SidebarSearchPanel = ({ + isOpen, + searchValue, + activeIndex, + label, + placeholder, + persistedFilter, + persistentFilterEnabled, + aiMode, + objectMode, + flatItems, + sections, + inputRef, + handlers, +}: SidebarSearchPanelProps) => { + if (!isOpen) return null; + + const emptyCopy = aiMode + ? '输入「?」后加问题,按 Enter 发送到 AI 面板。' + : objectMode + ? '未找到匹配的表、视图或物化视图。' + : '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。'; + + const renderRow = (item: TItem, active: boolean) => ( + + ); + + const renderSection = (title: string, items: TItem[]) => { + if (items.length === 0) return null; + return ( +
+
{title}
+ {items.map((item) => + renderRow(item, flatItems[activeIndex]?.key === item.key), + )} +
+ ); + }; + + return ( +
+
event.stopPropagation()} + > +
+ + handlers.onSearchValueChange(event.target.value)} + onKeyDown={handlers.onKeyDown} + placeholder={placeholder} + /> + + + + + + +
+
+ {renderSection('跳转 · GO TO', sections.goTo)} + {renderSection('AI · ASK', sections.ai)} + {renderSection('动作 · ACTIONS', sections.actions)} + {renderSection('近期查询 · RECENT', sections.recent)} + {flatItems.length === 0 ? ( +
{emptyCopy}
+ ) : null} +
+
+ 导航 + 选择 + @只搜表对象 + ?发送给 AI +
+
+
+ ); +}; + +export default SidebarSearchPanel; From f6ebfc2e44206142422a768be8e66e534254347a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 15:16:31 +0800 Subject: [PATCH 36/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=B8=85=E7=90=86=20V2=20rail=20=E6=AE=8B=E7=95=99=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除未被调用的 rail connection button/group 渲染闭包 - 移除对应的折叠、拖拽状态和 badge/host 辅助函数 - 保留当前可达的 active connection 状态展示和树拖拽排序逻辑 --- frontend/src/components/Sidebar.tsx | 267 ---------------------------- 1 file changed, 267 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8745404..171e3a2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -46,7 +46,6 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbo FolderAddOutlined, SaveOutlined, EditOutlined, - DownOutlined, SearchOutlined, KeyOutlined, ThunderboltOutlined, @@ -1190,18 +1189,6 @@ const Sidebar: React.FC<{ const allSavedQueriesNode = useMemo(() => { return buildAllSavedQueriesTreeNode(savedQueries, connections); }, [connections, savedQueries]); - const v2RailConnectionGroups = useMemo( - () => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder), - [connections, connectionTags, sidebarRootOrder], - ); - const [collapsedV2RailGroupIds, setCollapsedV2RailGroupIds] = useState([]); - const collapsedV2RailGroupIdSet = useMemo( - () => new Set(collapsedV2RailGroupIds), - [collapsedV2RailGroupIds], - ); - const hasV2RailConnectionGroups = v2RailConnectionGroups.some((group) => !group.isUngrouped); - const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(''); - const snapshotTreeSelectionBeforeDrag = useCallback(() => { treeDragSelectionSnapshotRef.current = { selectedKeys: [...selectedKeys], @@ -6828,101 +6815,6 @@ const Sidebar: React.FC<{ return connectionStatusMap.get(connectionId) || 'idle'; }, [connectionStatusMap]); - const toggleV2RailConnectionGroup = useCallback((groupId: string) => { - setCollapsedV2RailGroupIds((prev) => ( - prev.includes(groupId) - ? prev.filter((id) => id !== groupId) - : [...prev, groupId] - )); - }, []); - - const handleV2RailRootDrop = useCallback(( - sourceToken: string, - targetToken: string, - insertBefore: boolean, - ) => { - if (!sourceToken || !targetToken || sourceToken === targetToken) { - return; - } - reorderSidebarRoot(sourceToken, targetToken, insertBefore); - }, [reorderSidebarRoot]); - - const getRailConnectionTypeLabel = (conn: SavedConnection): string => { - const iconType = resolveConnectionIconType(conn); - if (iconType === 'mysql' || iconType === 'mariadb' || iconType === 'oceanbase') return 'MY'; - if (iconType === 'postgres') return 'PG'; - if (iconType === 'gaussdb') return 'GS'; - if (iconType === 'redis') return 'R'; - if (iconType === 'mongodb') return 'MO'; - if (iconType === 'oracle') return 'OR'; - if (iconType === 'sqlserver') return 'SS'; - if (iconType === 'starrocks') return 'SR'; - if (iconType === 'sqlite') return 'SQ'; - if (iconType === 'jvm') return 'JV'; - return iconType.slice(0, 2).toUpperCase() || 'DB'; - }; - - const getRailConnectionHostLabel = (conn: SavedConnection): string => { - const hostTokens = resolveConnectionHostTokens(conn.config); - const primaryHost = String(hostTokens[0] || '').trim().replace(/^\[|\]$/g, ''); - if (primaryHost) { - if (/^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0)$/i.test(primaryHost)) { - return 'LO'; - } - if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(primaryHost)) { - const lastSegment = primaryHost.split('.').pop() || ''; - return lastSegment.slice(-3).toUpperCase() || 'IP'; - } - if (primaryHost.includes(':') && /^[a-f0-9:]+$/i.test(primaryHost)) { - const lastSegment = primaryHost.split(':').filter(Boolean).pop() || ''; - return lastSegment.slice(-3).toUpperCase() || 'IP'; - } - - const hostFragments = primaryHost - .split(/[^a-z0-9\u4e00-\u9fa5]+/i) - .map((entry) => entry.trim()) - .filter(Boolean); - if (hostFragments.length >= 2) { - return `${hostFragments[0][0] || ''}${hostFragments[1][0] || ''}`.toUpperCase(); - } - const hostToken = hostFragments[0] || primaryHost.split('.')[0] || ''; - if (hostToken) { - return hostToken.replace(/[^a-z0-9\u4e00-\u9fa5]/gi, '').slice(0, 3).toUpperCase() || 'DB'; - } - } - - return getRailConnectionTypeLabel(conn); - }; - - const getRailConnectionBadgeLabel = (conn: SavedConnection): string => { - const connectionName = String(conn.name || '').trim(); - const cjkParts = connectionName.match(/[\u4e00-\u9fa5]/g); - if (cjkParts && cjkParts.length > 0) { - return cjkParts.slice(0, 2).join(''); - } - - const latinTokens = connectionName.match(/[a-z0-9]+/gi) || []; - if (latinTokens.length >= 2) { - const firstToken = latinTokens[0] || ''; - const secondToken = latinTokens[1] || ''; - return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase(); - } - if (latinTokens.length === 1) { - const token = latinTokens[0]; - const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || ''; - if (alphaPrefix) { - return alphaPrefix.slice(0, 3).toUpperCase(); - } - const trailingDigits = token.match(/(\d{2,})$/)?.[1]; - if (trailingDigits) { - return trailingDigits.slice(-3).toUpperCase(); - } - return token.slice(0, 3).toUpperCase(); - } - - return getRailConnectionTypeLabel(conn); - }; - const openV2ConnectionContextMenu = ( event: React.MouseEvent, connOrNode: SavedConnection | TreeNode, @@ -8893,165 +8785,6 @@ const Sidebar: React.FC<{ } }; - const renderV2RailConnectionButton = (conn: SavedConnection) => { - const accent = resolveConnectionAccentColor(conn); - const status = buildRailConnectionStatus(conn.id); - const badgeLabel = getRailConnectionBadgeLabel(conn); - const hostLabel = getRailConnectionHostLabel(conn); - const title = `${conn.name} · ${resolveConnectionHostSummary(conn.config) || conn.config.type}`; - const rootToken = buildSidebarRootConnectionToken(conn.id); - - return ( - - - - ); - }; - - const renderV2RailConnectionGroup = (group: V2RailConnectionGroup) => { - const collapsed = collapsedV2RailGroupIdSet.has(group.id); - const groupTitle = group.name || t('connection.sidebar.group.untitled'); - const rootToken = group.rootToken; - - return ( -
{ - snapshotTreeSelectionBeforeDrag(); - treeDragSelectSuppressUntilRef.current = Date.now() + 600; - setDraggingV2RailRootToken(rootToken); - setIsTreeDragging(true); - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', rootToken); - }} - onDragEnd={() => { - restoreTreeSelectionAfterDrag(); - setDraggingV2RailRootToken(''); - setIsTreeDragging(false); - }} - onDragOver={(event) => { - if (!draggingV2RailRootToken || draggingV2RailRootToken === rootToken) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = 'move'; - }} - onDrop={(event) => { - if (!draggingV2RailRootToken || draggingV2RailRootToken === rootToken) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const rect = event.currentTarget.getBoundingClientRect(); - const insertBefore = event.clientY < rect.top + rect.height / 2; - handleV2RailRootDrop(draggingV2RailRootToken, rootToken, insertBefore); - restoreTreeSelectionAfterDrag(); - setDraggingV2RailRootToken(''); - setIsTreeDragging(false); - }} - > - {hasV2RailConnectionGroups && ( - - - - )} - {!collapsed && ( -
- {group.connections.map(renderV2RailConnectionButton)} -
- )} -
- ); - }; - const v2RailObjectActionsLabel = t('sidebar.rail.object_actions'); const v2RailSystemActionsLabel = t('sidebar.rail.system_actions'); const v2NewGroupLabel = t('sidebar.action.new_group'); From f9469515801c9ff28a954ec9edeacca6ca6c8262 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 16:21:36 +0800 Subject: [PATCH 37/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E8=BF=81=E5=87=BA=20legacy=20=E8=8A=82=E7=82=B9=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=9E=84=E5=BB=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 134 +- frontend/src/components/Sidebar.tsx | 1088 ++-------------- .../components/SidebarFilterSync.i18n.test.ts | 5 +- .../SidebarRedisDbMenu.i18n.test.ts | 5 +- .../sidebar/sidebarLegacyNodeMenu.tsx | 1135 +++++++++++++++++ 5 files changed, 1301 insertions(+), 1066 deletions(-) create mode 100644 frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 18eac77..689b73a 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -58,6 +58,16 @@ import { formatV2TableContextMenuSize, } from './V2TableContextMenu'; +const readSourceFile = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +const readSidebarSource = () => [ + readSourceFile('./Sidebar.tsx'), + readSourceFile('./sidebar/sidebarHelpers.ts'), + readSourceFile('./sidebar/SidebarConnectionRail.tsx'), + readSourceFile('./sidebar/SidebarSearchPanel.tsx'), + readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), +].join('\n'); +const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); + const mocks = vi.hoisted(() => ({ noop: vi.fn(), state: { @@ -270,7 +280,7 @@ describe('Sidebar locate toolbar', () => { }); it('wires tree expand and double-click expansion to lazy loading', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('if (hasSidebarLazyChildren(children)) return;'); expect(source).toContain('if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info))'); @@ -510,7 +520,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the sidebar memoized so parent-only button state does not repaint the tree', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('}> = React.memo(({'); }); @@ -577,7 +587,7 @@ describe('Sidebar locate toolbar', () => { }); it('releases backend database connections when disconnecting a sidebar connection', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const disconnectSource = source.slice( source.indexOf('const releaseConnectionResources = async'), source.indexOf('const deleteConnectionNode ='), @@ -599,7 +609,7 @@ describe('Sidebar locate toolbar', () => { }); it('passes the exact tree key when locating a command-search object node', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const commandSearchRunSource = source.slice( source.indexOf("if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view')"), source.indexOf("if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine')"), @@ -609,7 +619,7 @@ describe('Sidebar locate toolbar', () => { }); it('wires external SQL directory file actions to dedicated Wails APIs', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const loadTablesSource = source.slice( source.indexOf('const loadTables = async'), source.indexOf('const locateObjectInSidebarRef'), @@ -644,7 +654,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the legacy sidebar toolbar on a stable five-column grid layout', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const markup = renderSidebarMarkup(); expect(markup).toContain('data-sidebar-legacy-toolbar="true"'); @@ -659,7 +669,7 @@ describe('Sidebar locate toolbar', () => { it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => { const markup = renderSidebarMarkup({ uiVersion: 'v2', sqlLogCount: 2341, onCreateConnection: mocks.noop }); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(markup).toContain('gn-v2-sidebar-redesign'); expect(markup).toContain('gn-v2-connection-rail'); @@ -685,7 +695,8 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all');"); expect(source).toContain("const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command';"); expect(source).toContain('const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true;'); - expect(source).toContain('handleV2CommandSearchValueChange(event.target.value)'); + expect(source).toContain('onSearchValueChange: handleV2CommandSearchValueChange,'); + expect(source).toContain('handlers.onSearchValueChange(event.target.value)'); expect(source).toContain('toggleV2CommandSearchPersistentFilter'); expect(source).toContain('gn-v2-command-filter-switch'); expect(source).toContain("window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true)"); @@ -708,9 +719,9 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('aria-label="工具"'); expect(markup).toContain('data-gonavi-open-tools-action="true"'); expect(markup).toContain('aria-label="设置"'); - expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder)'); - expect(source).toContain("kind: 'v2-connection-group'"); - expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}'); + expect(source).toContain('export const buildV2RailConnectionGroups = ('); + expect(source).toContain("if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node);"); + expect(source).toContain('openV2ConnectionContextMenu(event, node);'); expect(source).toContain("kind: 'v2-connection'"); expect(source).toContain('resolveSidebarContextMenuPosition(event.clientX, event.clientY)'); expect(source).toContain('contextMenuPortalRef'); @@ -758,7 +769,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes the v2 command search scope shell and object filters through catalog keys', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const objectKindSource = source.slice( source.indexOf('const V2_EXPLORER_FILTER_OPTIONS'), source.indexOf('const V2_EXPLORER_FILTER_GROUP_KEYS'), @@ -863,7 +874,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the v2 command search footer hints tied to real prefix actions', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';"); expect(source).toContain("const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';"); @@ -875,7 +886,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders v2 command action shortcuts from the shared shortcut options', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform)"); expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform)"); @@ -909,7 +920,7 @@ describe('Sidebar locate toolbar', () => { it('keeps v2 tree status dots circular while using virtual horizontal scroll for long labels', () => { const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); expect(source).toContain('gn-v2-tree-status is-${status}'); @@ -1071,7 +1082,7 @@ describe('Sidebar locate toolbar', () => { }; const markup = renderSidebarMarkup({ uiVersion: 'v2' }); - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("if (v2ExplorerFilter === 'all') {"); expect(source).toContain('gn-v2-tree-connection-copy'); @@ -1079,7 +1090,7 @@ describe('Sidebar locate toolbar', () => { }); it('reorders dragged connections instead of only moving them between groups', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const utilsSource = readFileSync(new URL('./sidebarV2Utils.ts', import.meta.url), 'utf8'); expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);'); @@ -1093,27 +1104,30 @@ describe('Sidebar locate toolbar', () => { }); it('reorders dragged tags relative to grouped connections instead of always appending them', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''"); expect(source).toContain('const dropTagId = dropNode.type === \'tag\''); expect(source).toContain('if (dropTagId) {'); }); - it('wires v2 rail root dragging through the shared sidebar root order action', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + it('wires v2 tree root dragging through the shared sidebar root order action', () => { + const source = readSidebarSource(); expect(source).toContain('const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);'); - expect(source).toContain('const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(\'\');'); expect(source).toContain('const treeDragSelectSuppressUntilRef = useRef(0);'); expect(source).toContain('const treeDragSelectionSnapshotRef = useRef<'); expect(source).toContain('snapshotTreeSelectionBeforeDrag();'); expect(source).toContain('restoreTreeSelectionAfterDrag();'); expect(source).toContain('if (Date.now() < treeDragSelectSuppressUntilRef.current) {'); - expect(source).toContain('handleV2RailRootDrop('); - expect(source).toContain('draggable'); - expect(source).toContain('setDraggingV2RailRootToken(rootToken);'); - expect(source).toContain('reorderSidebarRoot(sourceToken, targetToken, insertBefore);'); + expect(source).toContain('const getDropRootToken = (node: any): string => {'); + expect(source).toContain("return buildSidebarRootTagToken(String(node?.dataRef?.id || ''));"); + expect(source).toContain(': buildSidebarRootConnectionToken(String(node.key));'); + expect(source).toContain('const dragRootToken = buildSidebarRootTagToken(String(dragTagId));'); + expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore);'); + expect(source).toContain('reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore);'); + expect(source).toContain('buildSidebarRootConnectionToken(String(dragNode.key))'); + expect(source).toContain('onDrop={handleDrop}'); }); it('normalizes rc-tree absolute drop positions back to relative positions', () => { @@ -1348,7 +1362,7 @@ describe('Sidebar locate toolbar', () => { }); it('adds rename to the saved query context menu', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const renameHandlerStart = source.indexOf('const handleRenameSavedQuery = async () =>'); const renameHandlerEnd = source.indexOf('const openRoutineDefinition', renameHandlerStart); const savedQueryMenuStart = source.indexOf('// 已存查询节点的右键菜单'); @@ -1668,7 +1682,7 @@ describe('Sidebar locate toolbar', () => { }); it('keeps the v2 table pin action on sidebar table rows', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); expect(source).toContain('data-v2-sidebar-table-pin-action="true"'); @@ -1681,7 +1695,7 @@ describe('Sidebar locate toolbar', () => { }); it('splits v2 sidebar pinned tables into a dedicated table section', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const sectionBuilderSourceStart = source.indexOf('export const buildV2SidebarTableSectionedChildren = ('); const sectionBuilderSourceEnd = source.indexOf('export const buildSidebarTableChildrenForUi = ('); const sectionBuilderSource = source.slice(sectionBuilderSourceStart, sectionBuilderSourceEnd); @@ -1720,7 +1734,7 @@ describe('Sidebar locate toolbar', () => { { title: 'orders', key: 'orders', type: 'table' as const, dataRef: { pinnedSidebarTable: true } }, { title: 'users', key: 'users', type: 'table' as const, dataRef: { pinnedSidebarTable: false } }, ]; - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, false)).toBe(tableNodes); expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, true).map((node) => node.title)).toEqual([ @@ -1742,7 +1756,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders v2 table section labels as tree children instead of group header badges', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); expect(source).toContain("node.type === 'v2-table-section'"); @@ -2019,7 +2033,7 @@ describe('Sidebar locate toolbar', () => { }); it('routes v2 database context menu shell copy through i18n wrappers in Sidebar', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const createSchemaSource = source.slice( source.indexOf('const openCreateSchemaModal = (node: any) => {'), source.indexOf('const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {'), @@ -2153,7 +2167,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 connection shell fallbacks and group controls without changing raw names', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const menuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8'); expect(source).toContain("connectionName={String(conn?.name || node.title || t('connection.unnamed'))}"); @@ -2162,9 +2176,11 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("title: String(node.title || dataRef.dbName || t('database.unnamed'))"); expect(source).toContain("meta: conn?.name || dataRef.id || t('database.label')"); expect(source).toContain("const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected');"); - expect(source).toContain("const groupTitle = group.name || t('connection.sidebar.group.untitled');"); - expect(source).toContain("title={t('connection.sidebar.group.meta', { count: group.connections.length.toLocaleString() })}"); - expect(source).toContain("aria-label={t(collapsed ? 'connection.sidebar.group.expandAria' : 'connection.sidebar.group.collapseAria', { name: groupTitle })}"); + expect(source).toContain("name: tag.name || t('connection.sidebar.group.untitled'),"); + expect(source).toContain('groupName={group.name}'); + expect(source).toContain('count={group.connections.length}'); + expect(menuSource).toContain("title={groupName || t('connection.sidebar.group.untitled')}"); + expect(menuSource).toContain("meta={t('connection.sidebar.group.meta', { count: count.toLocaleString() })}"); expect(menuSource).toContain("pill={t('connection.sidebar.group.badge')}"); expect(menuSource).toContain("title: t('connection.sidebar.group.edit')"); expect(menuSource).toContain("title: t('connection.sidebar.group.delete')"); @@ -2172,7 +2188,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 connection group modals and delete confirmation shells while keeping raw group names', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("title: t('connection.sidebar.group.deleteConfirmTitle')"); expect(source).toContain("content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name })"); @@ -2201,7 +2217,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes legacy sidebar connection duplicate disconnect and delete copy', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("t('connection.sidebar.menu.copy')"); expect(source).toContain("t('connection.sidebar.menu.disconnect')"); @@ -2219,7 +2235,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes the sidebar table pin action title and aria-label via i18n keys', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const tablePinActionStart = source.indexOf("const tablePinAction = node.type === 'table' ? ("); const tablePinActionEnd = source.indexOf('aria-pressed=', tablePinActionStart); const tablePinActionSource = source.slice(tablePinActionStart, tablePinActionEnd); @@ -2238,7 +2254,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes legacy sidebar connection and redis menu labels', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); const connectionMenuStart = source.indexOf('// Connection Tag Menu — must be BEFORE the connection check'); const connectionMenuSource = source.slice( connectionMenuStart, @@ -2267,7 +2283,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes connection-root tab titles without changing database or redis-db tab title paths', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const buildConnectionRootQueryTabTitle = () => t('query.new');"); expect(source).toContain("const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') =>"); @@ -2283,7 +2299,7 @@ describe('Sidebar locate toolbar', () => { }); it('localizes sidebar JVM probe and resource failure prompts', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("t('sidebar.message.jvm_provider_probe_failed_with_diagnostic'"); expect(source).toContain("t('sidebar.message.jvm_provider_probe_exception_with_diagnostic'"); @@ -2307,7 +2323,8 @@ describe('Sidebar locate toolbar', () => { }); it('localizes v2 saved-query and external SQL root shell copy', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); + const legacyMenuSource = readLegacyNodeMenuSource(); const loadTablesStart = source.indexOf('const loadTables = async (node: any) => {'); const loadTablesEnd = source.indexOf('const config = {', loadTablesStart); const loadTablesSource = source.slice(loadTablesStart, loadTablesEnd); @@ -2318,17 +2335,17 @@ describe('Sidebar locate toolbar', () => { const externalSqlFlowEnd = source.indexOf('const handleCreateDatabase = async () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); const treeTitleStart = source.indexOf('const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {'); - const treeTitleEnd = source.indexOf('const renderV2CommandSearchRow', treeTitleStart); + const treeTitleEnd = source.indexOf('const selectConnectionFromRail', treeTitleStart); const treeTitleSource = source.slice(treeTitleStart, treeTitleEnd); - const externalSqlMenuStart = source.indexOf("if (node.type === 'external-sql-root') {", source.indexOf('// 已存查询节点的右键菜单')); - const externalSqlMenuEnd = source.indexOf("if (node.type === 'external-sql-directory') {", externalSqlMenuStart); - const externalSqlMenuSource = source.slice(externalSqlMenuStart, externalSqlMenuEnd); + const externalSqlMenuStart = legacyMenuSource.indexOf("if (node.type === 'external-sql-root') {", legacyMenuSource.indexOf('// 已存查询节点的右键菜单')); + const externalSqlMenuEnd = legacyMenuSource.indexOf("if (node.type === 'external-sql-directory') {", externalSqlMenuStart); + const externalSqlMenuSource = legacyMenuSource.slice(externalSqlMenuStart, externalSqlMenuEnd); const externalSqlDirectoryMenuStart = externalSqlMenuEnd; - const externalSqlDirectoryMenuEnd = source.indexOf("if (node.type === 'external-sql-file') {", externalSqlDirectoryMenuStart); - const externalSqlDirectoryMenuSource = source.slice(externalSqlDirectoryMenuStart, externalSqlDirectoryMenuEnd); + const externalSqlDirectoryMenuEnd = legacyMenuSource.indexOf("if (node.type === 'external-sql-file') {", externalSqlDirectoryMenuStart); + const externalSqlDirectoryMenuSource = legacyMenuSource.slice(externalSqlDirectoryMenuStart, externalSqlDirectoryMenuEnd); const externalSqlFileMenuStart = externalSqlDirectoryMenuEnd; - const externalSqlFileMenuEnd = source.indexOf('return [];', externalSqlFileMenuStart); - const externalSqlFileMenuSource = source.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd); + const externalSqlFileMenuEnd = legacyMenuSource.indexOf('return [];', externalSqlFileMenuStart); + const externalSqlFileMenuSource = legacyMenuSource.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd); const titleRenderStart = source.indexOf('const titleRender = (node: any) => {'); const titleRenderEnd = source.indexOf('const handleDrop = (info: any) => {', titleRenderStart); const titleRenderSource = source.slice(titleRenderStart, titleRenderEnd); @@ -2558,7 +2575,7 @@ describe('Sidebar locate toolbar', () => { expect(markup).not.toContain(rawSnippet); }); - const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const sidebarSource = readSidebarSource(); const start = sidebarSource.indexOf('const renderV2TableGroupContextMenu'); const end = sidebarSource.indexOf('const renderV2DatabaseContextMenu', start); expect(start).toBeGreaterThanOrEqual(0); @@ -2571,18 +2588,19 @@ describe('Sidebar locate toolbar', () => { }); const treeTitleStart = sidebarSource.indexOf('const renderV2TreeTitle'); - const treeTitleEnd = sidebarSource.indexOf('const renderV2CommandSearchRow', treeTitleStart); + const treeTitleEnd = sidebarSource.indexOf('const selectConnectionFromRail', treeTitleStart); expect(treeTitleStart).toBeGreaterThanOrEqual(0); expect(treeTitleEnd).toBeGreaterThan(treeTitleStart); const treeTitleSource = sidebarSource.slice(treeTitleStart, treeTitleEnd); expect(treeTitleSource).toContain('const objectGroupTitle = resolveV2ObjectGroupTitle(node);'); expect(treeTitleSource).toContain('if (objectGroupTitle) return objectGroupTitle;'); - const objectGroupTitleStart = sidebarSource.indexOf('export const resolveV2ObjectGroupTitle'); - const objectGroupTitleEnd = sidebarSource.indexOf('export type SQLFileExecutionStatus', objectGroupTitleStart); + const sidebarHelpersSource = readSourceFile('./sidebar/sidebarHelpers.ts'); + const objectGroupTitleStart = sidebarHelpersSource.indexOf('export const resolveV2ObjectGroupTitle'); + const objectGroupTitleEnd = sidebarHelpersSource.indexOf('export type V2CommandSearchMode', objectGroupTitleStart); expect(objectGroupTitleStart).toBeGreaterThanOrEqual(0); expect(objectGroupTitleEnd).toBeGreaterThan(objectGroupTitleStart); - const objectGroupTitleSource = sidebarSource.slice(objectGroupTitleStart, objectGroupTitleEnd); + const objectGroupTitleSource = sidebarHelpersSource.slice(objectGroupTitleStart, objectGroupTitleEnd); [ "if (groupKey === 'tables') return t('sidebar.v2_table_group_menu.title');", "if (groupKey === 'views') return t('sidebar.object_group.views');", @@ -2616,7 +2634,7 @@ describe('Sidebar locate toolbar', () => { }); it('listens for table overview pin changes to refresh the matching sidebar database node', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("window.addEventListener('gonavi:sidebar-table-pin-changed'"); expect(source).toContain('findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`)'); @@ -2625,7 +2643,7 @@ describe('Sidebar locate toolbar', () => { }); it('waits long enough for slow object-tree loads before reporting locate misses', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain('const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;'); expect(source).toContain('const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;'); @@ -2636,7 +2654,7 @@ describe('Sidebar locate toolbar', () => { }); it('resolves sidebar export workbench connection ids from live tree nodes instead of only reading dataRef.connectionId', () => { - const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const source = readSidebarSource(); expect(source).toContain("const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();"); expect(source).not.toContain("const connectionId = String(node?.dataRef?.connectionId || '').trim();"); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 171e3a2..ffe39b0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import Modal from './common/ResizableDraggableModal'; import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; +import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -7427,1012 +7428,87 @@ const Sidebar: React.FC<{ } }; - const getNodeMenuItems = (node: any): MenuProps['items'] => { - const conn = node.dataRef as SavedConnection; - const isRedis = conn?.config?.type === 'redis'; - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'schema') { - const dialect = getMetadataDialect(node.dataRef as SavedConnection); - const schemaName = String(node?.dataRef?.schemaName || '').trim(); - if (!isPostgresSchemaDialect(dialect) || !schemaName) { - return []; - } - return [ - { - key: 'rename-schema', - label: '编辑模式', - icon: , - onClick: () => openRenameSchemaModal(node) - }, - { - key: 'refresh-schema', - label: '刷新', - icon: , - onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName)) - }, - { - key: 'export-schema', - label: '导出当前模式表结构 (SQL)', - icon: , - onClick: () => void handleExportSchemaSQL(node, false) - }, - { - key: 'backup-schema-sql', - label: '备份当前模式全部表 (结构+数据 SQL)', - icon: , - onClick: () => void handleExportSchemaSQL(node, true) - }, - { type: 'divider' }, - { - key: 'drop-schema', - label: '删除模式', - icon: , - danger: true, - onClick: () => handleDeleteSchema(node) - }, - ]; - } - - // 表分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { - const groupData = node.dataRef; // { ...conn, dbName, groupKey } - const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; - const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; - const canCreateTable = !isStructureOnlyDbType(String(groupData.id || '')); - - return [ - ...(canCreateTable ? [{ - key: 'new-table', - label: '新建表', - icon: , - onClick: () => openNewTableDesign(node) - }] : []), - { type: 'divider' }, - { - key: 'sort-by-name', - label: '按名称排序', - icon: currentSort === 'name' ? : null, - onClick: () => handleTableGroupSortAction(node, 'name') - }, - { - key: 'sort-by-frequency', - label: '按使用频率排序', - icon: currentSort === 'frequency' ? : null, - onClick: () => handleTableGroupSortAction(node, 'frequency') - } - ]; - } - - // 视图分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') { - return [ - { - key: 'create-view', - label: t('sidebar.menu.create_view'), - icon: , - onClick: () => openCreateView(node) - }, - ]; - } - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') { - return [ - { - key: 'create-materialized-view', - label: t('sidebar.v2_database_menu.new_materialized_view'), - icon: , - onClick: () => openCreateStarRocksMaterializedView(node) - }, - ]; - } - - // 函数分组节点的右键菜单 - if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { - const dialect = getMetadataDialect(node.dataRef as SavedConnection); - const routineMenu: MenuProps['items'] = [ - { - key: 'create-function', - label: t('sidebar.tab.create_function'), - icon: , - onClick: () => openCreateRoutine(node, 'FUNCTION') - }, - ]; - if (dialect !== 'duckdb') { - routineMenu.push({ - key: 'create-procedure', - label: t('sidebar.tab.create_procedure'), - icon: , - onClick: () => openCreateRoutine(node, 'PROCEDURE') - }); - } - return routineMenu; - } - - if (node.type === 'object-group' && node.dataRef?.groupKey === 'events') { - return [ - { - key: 'create-event-query', - label: '新建事件', - icon: , - onClick: () => { - addTab({ - id: `query-create-event-${Date.now()}`, - title: '新建事件', - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: `CREATE EVENT event_name\nON SCHEDULE EVERY 1 DAY\nDO\nBEGIN\n -- event body\nEND;` - }); - } - }, - ]; - } - - // Connection Tag Menu — must be BEFORE the connection check - if (node.type === 'tag') { - return [ - { - key: 'edit-tag', - label: '编辑标签', - icon: , - onClick: () => { - createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); - setRenameViewTarget(node); - setIsCreateTagModalOpen(true); - } - }, - { type: 'divider' }, - { - key: 'delete-tag', - label: '删除标签', - icon: , - danger: true, - onClick: () => { - Modal.confirm({ - title: '确认删除', - content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`, - onOk: () => { - removeConnectionTag(node.dataRef.id); - } - }); - } - } - ]; - } - - if (node.type === 'connection') { - // Redis connection menu - if (isRedis) { - return [ - { - key: 'refresh', - label: t('sidebar.menu.refresh'), - icon: , - onClick: () => { - const connKey = String(node.key); - // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - // 清除 loadingNodesRef 中残留的子节点加载标记 - Array.from(loadingNodesRef.current).forEach(lk => { - if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); - }); - loadDatabases(node); - } - }, - { type: 'divider' }, - { - key: 'new-command', - label: t('sidebar.menu.new_command_window'), - icon: , - onClick: () => { - addTab({ - id: `redis-cmd-${node.key}-${Date.now()}`, - title: buildConnectionRootRedisCommandTabTitle(), - type: 'redis-command', - connectionId: node.key, - redisDB: 0 - }); - } - }, - { - key: 'open-monitor', - label: t('redis_monitor.title.instance'), - icon: , - onClick: () => { - addTab({ - id: `redis-monitor-${node.key}-${Date.now()}`, - title: buildConnectionRootRedisMonitorTabTitle(), - type: 'redis-monitor', - connectionId: node.key, - redisDB: 0 - }); - } - }, - { type: 'divider' }, - { - key: 'edit', - label: t('sidebar.menu.edit_connection'), - icon: , - onClick: () => { - if (onEditConnection) onEditConnection(node.dataRef); - } - }, - { - key: 'copy-connection', - label: t('connection.sidebar.menu.copy'), - icon: , - onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) - }, - { - key: 'disconnect', - label: t('connection.sidebar.menu.disconnect'), - icon: , - onClick: () => void disconnectConnectionNode(node) - }, - { - key: 'delete', - label: t('connection.sidebar.menu.delete'), - icon: , - danger: true, - onClick: () => deleteConnectionNode(node) - } - ]; - } - - // Tag submenu for connection - const tagSubMenuItems: MenuProps['items'] = connectionTags.map(tag => ({ - key: `move-to-tag-${tag.id}`, - label: tag.name, - icon: , - onClick: () => moveConnectionToTag(node.key, tag.id) - })); - if (connectionTags.length > 0) { - tagSubMenuItems.push({ type: 'divider' }); - } - tagSubMenuItems.push({ - key: 'move-to-ungrouped', - label: t('connection.sidebar.menu.moveOutTag'), - onClick: () => moveConnectionToTag(node.key, null) - }); - - // Regular database connection menu - const connectionCapabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); - return [ - ...(connectionCapabilities.supportsCreateDatabase ? [{ - key: 'new-db', - label: t('connection.sidebar.menu.createDatabase'), - icon: , - onClick: () => { - setTargetConnection(node); - setIsCreateDbModalOpen(true); - } - }] : []), - { - key: 'refresh', - label: t('sidebar.menu.refresh'), - icon: , - onClick: () => { - const connKey = String(node.key); - // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`))); - // 清除 loadingNodesRef 中残留的子节点加载标记 - Array.from(loadingNodesRef.current).forEach(lk => { - if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); - }); - loadDatabases(node); - } - }, - { type: 'divider' }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: buildConnectionRootQueryTabTitle(), - type: 'query', - connectionId: node.key, - dbName: undefined, - query: '' - }); - } - }, - { - key: 'open-sql-file', - label: t('sidebar.sql_file_exec.title'), - icon: , - onClick: () => handleRunSQLFile(node) - }, - { type: 'divider' }, - { - key: 'edit', - label: t('sidebar.menu.edit_connection'), - icon: , - onClick: () => { - if (onEditConnection) onEditConnection(node.dataRef); - } - }, - { - key: 'copy-connection', - label: t('connection.sidebar.menu.copy'), - icon: , - onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) - }, - { - key: 'move-to-tag', - label: t('connection.sidebar.menu.moveToTag'), - icon: , - children: tagSubMenuItems - }, - { - key: 'disconnect', - label: t('connection.sidebar.menu.disconnect'), - icon: , - onClick: () => void disconnectConnectionNode(node) - }, - { - key: 'delete', - label: t('connection.sidebar.menu.delete'), - icon: , - danger: true, - onClick: () => deleteConnectionNode(node) - } - ]; - } else if (node.type === 'redis-db') { - // Redis database menu - const { id, redisDB } = node.dataRef; - return [ - { - key: 'open-keys', - label: t('redis_viewer.title.key_explorer'), - icon: , - onClick: () => { - addTab({ - id: `redis-keys-${id}-db${redisDB}`, - title: `db${redisDB}`, - type: 'redis-keys', - connectionId: id, - redisDB: redisDB - }); - } - }, - { - key: 'new-command', - label: t('sidebar.menu.new_command_window'), - icon: , - onClick: () => { - addTab({ - id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`, - title: buildConnectionRootRedisCommandTabTitle(`db${redisDB}`), - type: 'redis-command', - connectionId: id, - redisDB: redisDB - }); - } - }, - { - key: 'open-monitor', - label: t('redis_monitor.title.instance'), - icon: , - onClick: () => { - addTab({ - id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, - title: buildConnectionRootRedisMonitorTabTitle(`db${redisDB}`), - type: 'redis-monitor', - connectionId: id, - redisDB: redisDB - }); - } - } - ]; - } else if (node.type === 'database') { - const databaseConn = node.dataRef as SavedConnection; - const dialect = getMetadataDialect(databaseConn); - const capabilities = getDataSourceCapabilities(databaseConn?.config); - const isStarRocks = dialect === 'starrocks'; - const supportsSchemaActions = isPostgresSchemaDialect(dialect); - const canCreateTable = !isStructureOnlyDbType(String(databaseConn?.id || '')); - return [ - ...(canCreateTable ? [{ - key: 'new-table', - label: t('sidebar.menu.create_table'), - icon: , - onClick: () => openNewTableDesign(node) - }] : []), - ...(supportsSchemaActions ? [ - { - key: 'new-schema', - label: t('sidebar.v2_database_menu.new_schema'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') - }, - ] : []), - ...(isStarRocks ? [ - { - key: 'new-materialized-view', - label: t('sidebar.v2_database_menu.new_materialized_view'), - icon: , - onClick: () => openCreateStarRocksMaterializedView(node) - }, - { - key: 'new-external-catalog', - label: t('sidebar.v2_database_menu.new_external_catalog'), - icon: , - onClick: () => openCreateStarRocksExternalCatalog(node) - }, - ] : []), - ...(capabilities.supportsRenameDatabase ? [{ - key: 'rename-db', - label: t('sidebar.menu.rename_database'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') - }] : []), - ...(capabilities.supportsDropDatabase ? [{ - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-db', - label: t('sidebar.v2_table_menu.item_with_suffix', { label: t('sidebar.menu.delete_database'), suffix: 'DROP' }), - icon: , - danger: true, - onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') - } - ] - }] : []), - { - key: 'refresh', - label: t('sidebar.v2_database_menu.refresh_object_tree'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') - }, - { - key: 'export-db-schema', - label: t('sidebar.v2_database_menu.export_all_table_schema_sql'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') - }, - { - key: 'backup-db-sql', - label: t('sidebar.v2_database_menu.backup_all_tables_sql'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') - }, - { type: 'divider' }, - { - key: 'disconnect-db', - label: t('sidebar.menu.close_database'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') - }, - { - key: 'run-sql', - label: t('sidebar.sql_file_exec.title'), - icon: , - onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') - } - ]; - } else if (node.type === 'view') { - return [ - { - key: 'open-view', - label: t('sidebar.menu.browse_view_data'), - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'view-definition', - label: t('sidebar.menu.view_definition'), - icon: , - onClick: () => openViewDefinition(node) - }, - { - key: 'copy-view-name', - label: '复制名称', - icon: , - onClick: () => handleCopyTableName(node) - }, - { type: 'divider' }, - { - key: 'edit-view', - label: t('sidebar.menu.edit_view'), - icon: , - onClick: () => openEditView(node) - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: '' - }); - } - }, - { type: 'divider' }, - { - key: 'rename-view', - label: t('sidebar.menu.rename_view'), - icon: , - onClick: () => { - setRenameViewTarget(node); - renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); - setIsRenameViewModalOpen(true); - } - }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-view', - label: t('sidebar.menu.delete_view'), - icon: , - danger: true, - onClick: () => handleDropView(node) - } - ] - }, - ]; - } else if (node.type === 'materialized-view') { - return [ - { - key: 'open-materialized-view', - label: t('sidebar.menu.browse_materialized_view_data'), - icon: , - onClick: () => onDoubleClick(null, node) - }, - { - key: 'materialized-view-definition', - label: t('sidebar.menu.materialized_view_definition'), - icon: , - onClick: () => openViewDefinition(node) - }, - { - key: 'copy-materialized-view-name', - label: '复制名称', - icon: , - onClick: () => handleCopyTableName(node) - }, - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || '')) - }); - } - }, - ]; - } else if (node.type === 'routine') { - const routineType = node.dataRef?.routineType || 'FUNCTION'; - const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); - return [ - { - key: 'view-routine-def', - label: t('sidebar.menu.view_object_definition'), - icon: , - onClick: () => openRoutineDefinition(node) - }, - { - key: 'edit-routine', - label: t('sidebar.menu.edit_definition'), - icon: , - onClick: () => openEditRoutine(node) - }, - { type: 'divider' }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - { - key: 'drop-routine', - label: t('sidebar.menu.delete_routine', { type: typeLabel }), - icon: , - danger: true, - onClick: () => handleDropRoutine(node) - } - ] - }, - ]; - } else if (node.type === 'db-event') { - return [ - { - key: 'view-event-def', - label: t('sidebar.menu.view_object_definition'), - icon: , - onClick: () => openEventDefinition(node) - }, - { - key: 'edit-event-query', - label: t('sidebar.menu.edit_definition'), - icon: , - onClick: () => { - const { eventName, dbName, id } = node.dataRef; - addTab({ - id: `query-edit-event-${Date.now()}`, - title: t('sidebar.tab.edit_event', { name: eventName }), - type: 'query', - connectionId: id, - dbName, - query: `SHOW CREATE EVENT \`${String(eventName || '').replace(/`/g, '``')}\`;` - }); - } - }, - ]; - } else if (node.type === 'table') { - const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; - const messagePublishTarget = resolveMessagePublishTarget(node); - return [ - { - key: 'new-query', - label: t('sidebar.menu.new_query'), - icon: , - onClick: () => { - const tableName = String(node.dataRef?.tableName || '').trim(); - const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName); - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: queryTemplate - }); - } - }, - ...(messagePublishTarget ? [{ - key: 'publish-message', - label: '测试发送消息', - icon: , - onClick: () => openMessagePublishModal(node), - }] : []), - { type: 'divider' }, - { - key: 'design-table', - label: isStructureOnlyDbType(String(node.dataRef?.id || '')) ? '表结构' : '设计表', - icon: , - onClick: () => openDesign(node, 'columns', false) - }, - ...(isStarRocks ? [{ - key: 'new-rollup', - label: '新增 Rollup', - icon: , - onClick: () => openCreateStarRocksRollup(node) - }] : []), - { - key: 'copy-table-name', - label: '复制表名', - icon: , - onClick: () => handleCopyTableName(node) - }, - { - key: 'copy-structure', - label: '复制表结构', - icon: , - onClick: () => handleCopyStructure(node) - }, - { - key: 'backup-table', - label: '备份表 (SQL)', - icon: , - onClick: () => handleExport(node, { format: 'sql' }) - }, - { - key: 'rename-table', - label: '重命名表', - icon: , - onClick: () => { - setRenameTableTarget(node); - renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); - setIsRenameTableModalOpen(true); - } - }, - { - key: 'danger-zone', - label: t('sidebar.menu.danger_operations'), - icon: , - children: [ - ...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{ - key: 'truncate-table', - label: '截断表', - danger: true, - onClick: () => handleTableDataDangerAction(node, 'truncate') - }] : []), - { - key: 'clear-table', - label: '清空表', - danger: true, - onClick: () => handleTableDataDangerAction(node, 'clear') - }, - { - key: 'drop-table', - label: '删除表', - icon: , - danger: true, - onClick: () => handleDeleteTable(node) - } - ] - }, - { - type: 'divider' - }, - { - key: 'export', - label: '导出表数据…', - icon: , - onClick: () => openExportDialog(node), - } - ]; - } - - // 已存查询节点的右键菜单 - if (node.type === 'saved-query') { - const q = node.dataRef as SavedQuery; - const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q) - ? [ - { - key: 'rebind-query', - label: '绑定到连接', - icon: , - disabled: connections.length === 0, - children: connections.length > 0 - ? connections.map((conn) => ({ - key: `rebind-query-${conn.id}`, - label: conn.name || conn.id, - onClick: () => void handleRebindSavedQuery(q, conn), - })) - : undefined, - }, - ] - : []; - return [ - { - key: 'open-query', - label: t('sidebar.menu.open_query'), - icon: , - onClick: () => { - addTab({ - id: q.id, - title: resolveSavedQueryDisplayName(q.name), - type: 'query', - connectionId: q.connectionId, - dbName: q.dbName, - query: q.sql, - savedQueryId: q.id, - }); - } - }, - ...rebindMenuItems, - { type: 'divider' }, - { - key: 'rename-query', - label: t('sidebar.menu.rename_query'), - icon: , - onClick: () => openRenameSavedQueryModal(q), - }, - { - key: 'delete-query', - label: t('sidebar.menu.delete_query'), - icon: , - danger: true, - onClick: () => { - Modal.confirm({ - title: t('sidebar.modal.confirm_delete.title'), - content: t('sidebar.modal.confirm_delete_saved_query.content', { name: resolveSavedQueryDisplayName(q.name) }), - okButtonProps: { danger: true }, - onOk: async () => { - try { - await deleteQuery(q.id); - } catch (e) { - message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e))); - throw e; - } - // 从树中移除节点 - const removeNode = (list: TreeNode[]): TreeNode[] => - list - .filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id)) - .map(n => n.children ? { ...n, children: removeNode(n.children) } : n); - const nextTreeData = removeNode(treeDataRef.current); - treeDataRef.current = nextTreeData; - setTreeData(nextTreeData); - message.success(t('sidebar.message.saved_query_deleted')); - } - }); - } - } - ]; - } - - if (node.type === 'external-sql-root') { - return [ - { - key: 'add-external-sql-directory', - label: t('sidebar.menu.add_sql_directory'), - icon: , - onClick: () => { - void handleAddExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-directory') { - return [ - { - key: 'new-external-sql-file', - label: t('sidebar.menu.new_sql_file'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory', - label: t('sidebar.menu.new_sql_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { - key: 'rename-external-sql-directory', - label: t('sidebar.menu.rename_sql_directory'), - icon: , - onClick: () => { - openRenameExternalSQLDirectoryModal(node); - } - }, - { type: 'divider' }, - { - key: 'refresh-external-sql-directory', - label: t('sidebar.menu.refresh_directory'), - icon: , - onClick: () => { - void handleRefreshExternalSQLDirectory(node); - } - }, - { type: 'divider' }, - { - key: 'remove-external-sql-directory', - label: t('sidebar.menu.remove_directory'), - icon: , - danger: true, - onClick: () => { - void handleRemoveExternalSQLDirectory(node); - } - }, - { - key: 'delete-external-sql-directory', - label: t('sidebar.menu.delete_local_directory'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-folder') { - return [ - { - key: 'new-external-sql-file', - label: t('sidebar.menu.new_sql_file'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory', - label: t('sidebar.menu.new_sql_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { - key: 'rename-external-sql-directory', - label: t('sidebar.menu.rename_sql_directory'), - icon: , - onClick: () => { - openRenameExternalSQLDirectoryModal(node); - } - }, - { - key: 'refresh-external-sql-directory', - label: t('sidebar.menu.refresh_directory'), - icon: , - onClick: () => { - void handleRefreshExternalSQLDirectory(node); - } - }, - { type: 'divider' }, - { - key: 'delete-external-sql-directory', - label: t('sidebar.menu.delete_sql_directory'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLDirectory(node); - } - } - ]; - } - - if (node.type === 'external-sql-file') { - return [ - { - key: 'open-external-sql-file', - label: t('sidebar.menu.open_sql_file'), - icon: , - onClick: () => { - void openExternalSQLFile(node); - } - }, - { - key: 'rename-external-sql-file', - label: t('sidebar.menu.rename_sql_file'), - icon: , - onClick: () => { - openRenameExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-file-sibling', - label: t('sidebar.menu.new_sql_file_in_directory'), - icon: , - onClick: () => { - openCreateExternalSQLFileModal(node); - } - }, - { - key: 'new-external-sql-directory-sibling', - label: t('sidebar.menu.new_sql_directory_in_directory'), - icon: , - onClick: () => { - openCreateExternalSQLDirectoryModal(node); - } - }, - { type: 'divider' }, - { - key: 'delete-external-sql-file', - label: t('sidebar.menu.delete_sql_file'), - icon: , - danger: true, - onClick: () => { - handleDeleteExternalSQLFile(node); - } - } - ]; - } - - return []; - }; + const getNodeMenuItems = (node: any): MenuProps['items'] => buildSidebarLegacyNodeMenuItems(node, { + addTab, + getMetadataDialect, + handleV2DatabaseContextMenuAction, + isPostgresSchemaDialect, + handleExportSchemaSQL, + openRenameSchemaModal, + loadTables, + getDatabaseNodeRef, + handleDeleteSchema, + tableSortPreference, + isStructureOnlyDbType, + openNewTableDesign, + handleTableGroupSortAction, + openCreateView, + openCreateStarRocksMaterializedView, + openCreateRoutine, + createTagForm, + setRenameViewTarget, + setIsCreateTagModalOpen, + removeConnectionTag, + setExpandedKeys, + setLoadedKeys, + loadingNodesRef, + loadDatabases, + buildConnectionRootRedisCommandTabTitle, + buildConnectionRootRedisMonitorTabTitle, + onEditConnection, + handleDuplicateConnection, + disconnectConnectionNode, + deleteConnectionNode, + connectionTags, + moveConnectionToTag, + setTargetConnection, + setIsCreateDbModalOpen, + buildConnectionRootQueryTabTitle, + handleRunSQLFile, + openCreateStarRocksExternalCatalog, + openEditView, + renameViewForm, + setIsRenameViewModalOpen, + handleDropView, + onDoubleClick, + openViewDefinition, + openRoutineDefinition, + openEditRoutine, + handleDropRoutine, + openEventDefinition, + resolveMessagePublishTarget, + openMessagePublishModal, + openDesign, + openCreateStarRocksRollup, + handleCopyTableName, + handleCopyStructure, + handleExport, + setRenameTableTarget, + renameTableForm, + setIsRenameTableModalOpen, + handleTableDataDangerAction, + handleDeleteTable, + openExportDialog, + isSavedQueryUnmatched, + connections, + handleRebindSavedQuery, + openRenameSavedQueryModal, + resolveSavedQueryDisplayName, + deleteQuery, + treeDataRef, + setTreeData, + handleAddExternalSQLDirectory, + openCreateExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleRefreshExternalSQLDirectory, + handleDeleteExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + openExternalSQLFile, + openRenameExternalSQLFileModal, + handleDeleteExternalSQLFile, + extractObjectName, + }); const titleRender = (node: any) => { let status: 'success' | 'error' | 'default' = 'default'; diff --git a/frontend/src/components/SidebarFilterSync.i18n.test.ts b/frontend/src/components/SidebarFilterSync.i18n.test.ts index 1ee101c..7717931 100644 --- a/frontend/src/components/SidebarFilterSync.i18n.test.ts +++ b/frontend/src/components/SidebarFilterSync.i18n.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = [ + readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('./sidebar/SidebarSearchPanel.tsx', import.meta.url), 'utf8'), +].join('\n'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ diff --git a/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts b/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts index 061f762..27c7922 100644 --- a/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts +++ b/frontend/src/components/SidebarRedisDbMenu.i18n.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = [ + readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'), + readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8'), +].join('\n'); describe('Sidebar Redis DB menu i18n', () => { it('localizes Redis database context menu labels and tab titles', () => { diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx new file mode 100644 index 0000000..f6e8c16 --- /dev/null +++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx @@ -0,0 +1,1135 @@ +import { Modal, message, type MenuProps } from 'antd'; +import { + CheckSquareOutlined, + CloudOutlined, + CodeOutlined, + ConsoleSqlOutlined, + CopyOutlined, + DashboardOutlined, + DatabaseOutlined, + DeleteOutlined, + DisconnectOutlined, + EditOutlined, + ExportOutlined, + EyeOutlined, + FileAddOutlined, + FolderAddOutlined, + FolderOpenOutlined, + FolderOutlined, + KeyOutlined, + LinkOutlined, + PlusOutlined, + ReloadOutlined, + SaveOutlined, + SendOutlined, + TableOutlined, + ThunderboltOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { t } from '../../i18n'; +import type { SavedConnection, SavedQuery } from '../../types'; +import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'; +import { buildTableSelectQuery } from '../../utils/objectQueryTemplates'; +import { supportsTableTruncateAction } from '../tableDataDangerActions'; + +type TreeNode = { + type?: string; + title?: string; + key?: string; + dataRef?: any; + children?: TreeNode[]; + [key: string]: any; +}; + +export type SidebarLegacyNodeMenuContext = Record; + +export const buildSidebarLegacyNodeMenuItems = ( + node: any, + context: SidebarLegacyNodeMenuContext, +): MenuProps['items'] => { + const { + addTab, + getMetadataDialect, + handleV2DatabaseContextMenuAction, + isPostgresSchemaDialect, + handleExportSchemaSQL, + openRenameSchemaModal, + loadTables, + getDatabaseNodeRef, + handleDeleteSchema, + tableSortPreference, + isStructureOnlyDbType, + openNewTableDesign, + handleTableGroupSortAction, + openCreateView, + openCreateStarRocksMaterializedView, + openCreateRoutine, + createTagForm, + setRenameViewTarget, + setIsCreateTagModalOpen, + removeConnectionTag, + setExpandedKeys, + setLoadedKeys, + loadingNodesRef, + loadDatabases, + buildConnectionRootRedisCommandTabTitle, + buildConnectionRootRedisMonitorTabTitle, + onEditConnection, + handleDuplicateConnection, + disconnectConnectionNode, + deleteConnectionNode, + connectionTags, + moveConnectionToTag, + setTargetConnection, + setIsCreateDbModalOpen, + buildConnectionRootQueryTabTitle, + handleRunSQLFile, + openCreateStarRocksExternalCatalog, + openEditView, + renameViewForm, + setIsRenameViewModalOpen, + handleDropView, + onDoubleClick, + openViewDefinition, + openRoutineDefinition, + openEditRoutine, + handleDropRoutine, + openEventDefinition, + resolveMessagePublishTarget, + openMessagePublishModal, + openDesign, + openCreateStarRocksRollup, + handleCopyTableName, + handleCopyStructure, + handleExport, + setRenameTableTarget, + renameTableForm, + setIsRenameTableModalOpen, + handleTableDataDangerAction, + handleDeleteTable, + openExportDialog, + isSavedQueryUnmatched, + connections, + handleRebindSavedQuery, + openRenameSavedQueryModal, + resolveSavedQueryDisplayName, + deleteQuery, + treeDataRef, + setTreeData, + handleAddExternalSQLDirectory, + openCreateExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleRefreshExternalSQLDirectory, + handleDeleteExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + openExternalSQLFile, + openRenameExternalSQLFileModal, + handleDeleteExternalSQLFile, + extractObjectName, + } = context; + const conn = node.dataRef as SavedConnection; + const isRedis = conn?.config?.type === 'redis'; + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'schema') { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const schemaName = String(node?.dataRef?.schemaName || '').trim(); + if (!isPostgresSchemaDialect(dialect) || !schemaName) { + return []; + } + return [ + { + key: 'rename-schema', + label: '编辑模式', + icon: , + onClick: () => openRenameSchemaModal(node) + }, + { + key: 'refresh-schema', + label: '刷新', + icon: , + onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName)) + }, + { + key: 'export-schema', + label: '导出当前模式表结构 (SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, false) + }, + { + key: 'backup-schema-sql', + label: '备份当前模式全部表 (结构+数据 SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, true) + }, + { type: 'divider' }, + { + key: 'drop-schema', + label: '删除模式', + icon: , + danger: true, + onClick: () => handleDeleteSchema(node) + }, + ]; + } + + // 表分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { + const groupData = node.dataRef; // { ...conn, dbName, groupKey } + const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; + const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; + const canCreateTable = !isStructureOnlyDbType(String(groupData.id || '')); + + return [ + ...(canCreateTable ? [{ + key: 'new-table', + label: '新建表', + icon: , + onClick: () => openNewTableDesign(node) + }] : []), + { type: 'divider' }, + { + key: 'sort-by-name', + label: '按名称排序', + icon: currentSort === 'name' ? : null, + onClick: () => handleTableGroupSortAction(node, 'name') + }, + { + key: 'sort-by-frequency', + label: '按使用频率排序', + icon: currentSort === 'frequency' ? : null, + onClick: () => handleTableGroupSortAction(node, 'frequency') + } + ]; + } + + // 视图分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'views') { + return [ + { + key: 'create-view', + label: t('sidebar.menu.create_view'), + icon: , + onClick: () => openCreateView(node) + }, + ]; + } + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'materializedViews') { + return [ + { + key: 'create-materialized-view', + label: t('sidebar.v2_database_menu.new_materialized_view'), + icon: , + onClick: () => openCreateStarRocksMaterializedView(node) + }, + ]; + } + + // 函数分组节点的右键菜单 + if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const routineMenu: MenuProps['items'] = [ + { + key: 'create-function', + label: t('sidebar.tab.create_function'), + icon: , + onClick: () => openCreateRoutine(node, 'FUNCTION') + }, + ]; + if (dialect !== 'duckdb') { + routineMenu.push({ + key: 'create-procedure', + label: t('sidebar.tab.create_procedure'), + icon: , + onClick: () => openCreateRoutine(node, 'PROCEDURE') + }); + } + return routineMenu; + } + + if (node.type === 'object-group' && node.dataRef?.groupKey === 'events') { + return [ + { + key: 'create-event-query', + label: '新建事件', + icon: , + onClick: () => { + addTab({ + id: `query-create-event-${Date.now()}`, + title: '新建事件', + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: `CREATE EVENT event_name\nON SCHEDULE EVERY 1 DAY\nDO\nBEGIN\n -- event body\nEND;` + }); + } + }, + ]; + } + + // Connection Tag Menu — must be BEFORE the connection check + if (node.type === 'tag') { + return [ + { + key: 'edit-tag', + label: '编辑标签', + icon: , + onClick: () => { + createTagForm.setFieldsValue({ name: node.title, connectionIds: node.dataRef.connectionIds }); + setRenameViewTarget(node); + setIsCreateTagModalOpen(true); + } + }, + { type: 'divider' }, + { + key: 'delete-tag', + label: '删除标签', + icon: , + danger: true, + onClick: () => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除标签 "${node.title}" 吗?这不会删除里面的连接。`, + onOk: () => { + removeConnectionTag(node.dataRef.id); + } + }); + } + } + ]; + } + + if (node.type === 'connection') { + // Redis connection menu + if (isRedis) { + return [ + { + key: 'refresh', + label: t('sidebar.menu.refresh'), + icon: , + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current as Set).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } + }, + { type: 'divider' }, + { + key: 'new-command', + label: t('sidebar.menu.new_command_window'), + icon: , + onClick: () => { + addTab({ + id: `redis-cmd-${node.key}-${Date.now()}`, + title: buildConnectionRootRedisCommandTabTitle(), + type: 'redis-command', + connectionId: node.key, + redisDB: 0 + }); + } + }, + { + key: 'open-monitor', + label: t('redis_monitor.title.instance'), + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${node.key}-${Date.now()}`, + title: buildConnectionRootRedisMonitorTabTitle(), + type: 'redis-monitor', + connectionId: node.key, + redisDB: 0 + }); + } + }, + { type: 'divider' }, + { + key: 'edit', + label: t('sidebar.menu.edit_connection'), + icon: , + onClick: () => { + if (onEditConnection) onEditConnection(node.dataRef); + } + }, + { + key: 'copy-connection', + label: t('connection.sidebar.menu.copy'), + icon: , + onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) + }, + { + key: 'disconnect', + label: t('connection.sidebar.menu.disconnect'), + icon: , + onClick: () => void disconnectConnectionNode(node) + }, + { + key: 'delete', + label: t('connection.sidebar.menu.delete'), + icon: , + danger: true, + onClick: () => deleteConnectionNode(node) + } + ]; + } + + // Tag submenu for connection + const tagSubMenuItems: NonNullable = connectionTags.map((tag: any) => ({ + key: `move-to-tag-${tag.id}`, + label: tag.name, + icon: , + onClick: () => moveConnectionToTag(node.key, tag.id) + })); + if (connectionTags.length > 0) { + tagSubMenuItems.push({ type: 'divider' }); + } + tagSubMenuItems.push({ + key: 'move-to-ungrouped', + label: t('connection.sidebar.menu.moveOutTag'), + onClick: () => moveConnectionToTag(node.key, null) + }); + + // Regular database connection menu + const connectionCapabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); + return [ + ...(connectionCapabilities.supportsCreateDatabase ? [{ + key: 'new-db', + label: t('connection.sidebar.menu.createDatabase'), + icon: , + onClick: () => { + setTargetConnection(node); + setIsCreateDbModalOpen(true); + } + }] : []), + { + key: 'refresh', + label: t('sidebar.menu.refresh'), + icon: , + onClick: () => { + const connKey = String(node.key); + // 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData + setExpandedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + setLoadedKeys((prev: any[]) => prev.filter((k: any) => !k.toString().startsWith(`${connKey}-`))); + // 清除 loadingNodesRef 中残留的子节点加载标记 + Array.from(loadingNodesRef.current as Set).forEach(lk => { + if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk); + }); + loadDatabases(node); + } + }, + { type: 'divider' }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: buildConnectionRootQueryTabTitle(), + type: 'query', + connectionId: node.key, + dbName: undefined, + query: '' + }); + } + }, + { + key: 'open-sql-file', + label: t('sidebar.sql_file_exec.title'), + icon: , + onClick: () => handleRunSQLFile(node) + }, + { type: 'divider' }, + { + key: 'edit', + label: t('sidebar.menu.edit_connection'), + icon: , + onClick: () => { + if (onEditConnection) onEditConnection(node.dataRef); + } + }, + { + key: 'copy-connection', + label: t('connection.sidebar.menu.copy'), + icon: , + onClick: () => handleDuplicateConnection(node.dataRef as SavedConnection) + }, + { + key: 'move-to-tag', + label: t('connection.sidebar.menu.moveToTag'), + icon: , + children: tagSubMenuItems + }, + { + key: 'disconnect', + label: t('connection.sidebar.menu.disconnect'), + icon: , + onClick: () => void disconnectConnectionNode(node) + }, + { + key: 'delete', + label: t('connection.sidebar.menu.delete'), + icon: , + danger: true, + onClick: () => deleteConnectionNode(node) + } + ]; + } else if (node.type === 'redis-db') { + // Redis database menu + const { id, redisDB } = node.dataRef; + return [ + { + key: 'open-keys', + label: t('redis_viewer.title.key_explorer'), + icon: , + onClick: () => { + addTab({ + id: `redis-keys-${id}-db${redisDB}`, + title: `db${redisDB}`, + type: 'redis-keys', + connectionId: id, + redisDB: redisDB + }); + } + }, + { + key: 'new-command', + label: t('sidebar.menu.new_command_window'), + icon: , + onClick: () => { + addTab({ + id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`, + title: buildConnectionRootRedisCommandTabTitle(`db${redisDB}`), + type: 'redis-command', + connectionId: id, + redisDB: redisDB + }); + } + }, + { + key: 'open-monitor', + label: t('redis_monitor.title.instance'), + icon: , + onClick: () => { + addTab({ + id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, + title: buildConnectionRootRedisMonitorTabTitle(`db${redisDB}`), + type: 'redis-monitor', + connectionId: id, + redisDB: redisDB + }); + } + } + ]; + } else if (node.type === 'database') { + const databaseConn = node.dataRef as SavedConnection; + const dialect = getMetadataDialect(databaseConn); + const capabilities = getDataSourceCapabilities(databaseConn?.config); + const isStarRocks = dialect === 'starrocks'; + const supportsSchemaActions = isPostgresSchemaDialect(dialect); + const canCreateTable = !isStructureOnlyDbType(String(databaseConn?.id || '')); + return [ + ...(canCreateTable ? [{ + key: 'new-table', + label: t('sidebar.menu.create_table'), + icon: , + onClick: () => openNewTableDesign(node) + }] : []), + ...(supportsSchemaActions ? [ + { + key: 'new-schema', + label: t('sidebar.v2_database_menu.new_schema'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-schema') + }, + ] : []), + ...(isStarRocks ? [ + { + key: 'new-materialized-view', + label: t('sidebar.v2_database_menu.new_materialized_view'), + icon: , + onClick: () => openCreateStarRocksMaterializedView(node) + }, + { + key: 'new-external-catalog', + label: t('sidebar.v2_database_menu.new_external_catalog'), + icon: , + onClick: () => openCreateStarRocksExternalCatalog(node) + }, + ] : []), + ...(capabilities.supportsRenameDatabase ? [{ + key: 'rename-db', + label: t('sidebar.menu.rename_database'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') + }] : []), + ...(capabilities.supportsDropDatabase ? [{ + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-db', + label: t('sidebar.v2_table_menu.item_with_suffix', { label: t('sidebar.menu.delete_database'), suffix: 'DROP' }), + icon: , + danger: true, + onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') + } + ] + }] : []), + { + key: 'refresh', + label: t('sidebar.v2_database_menu.refresh_object_tree'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') + }, + { + key: 'export-db-schema', + label: t('sidebar.v2_database_menu.export_all_table_schema_sql'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') + }, + { + key: 'backup-db-sql', + label: t('sidebar.v2_database_menu.backup_all_tables_sql'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') + }, + { type: 'divider' }, + { + key: 'disconnect-db', + label: t('sidebar.menu.close_database'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') + }, + { + key: 'run-sql', + label: t('sidebar.sql_file_exec.title'), + icon: , + onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') + } + ]; + } else if (node.type === 'view') { + return [ + { + key: 'open-view', + label: t('sidebar.menu.browse_view_data'), + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'view-definition', + label: t('sidebar.menu.view_definition'), + icon: , + onClick: () => openViewDefinition(node) + }, + { + key: 'copy-view-name', + label: '复制名称', + icon: , + onClick: () => handleCopyTableName(node) + }, + { type: 'divider' }, + { + key: 'edit-view', + label: t('sidebar.menu.edit_view'), + icon: , + onClick: () => openEditView(node) + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: t('query.new'), + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: '' + }); + } + }, + { type: 'divider' }, + { + key: 'rename-view', + label: t('sidebar.menu.rename_view'), + icon: , + onClick: () => { + setRenameViewTarget(node); + renameViewForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.viewName || node.title) }); + setIsRenameViewModalOpen(true); + } + }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-view', + label: t('sidebar.menu.delete_view'), + icon: , + danger: true, + onClick: () => handleDropView(node) + } + ] + }, + ]; + } else if (node.type === 'materialized-view') { + return [ + { + key: 'open-materialized-view', + label: t('sidebar.menu.browse_materialized_view_data'), + icon: , + onClick: () => onDoubleClick(null, node) + }, + { + key: 'materialized-view-definition', + label: t('sidebar.menu.materialized_view_definition'), + icon: , + onClick: () => openViewDefinition(node) + }, + { + key: 'copy-materialized-view-name', + label: '复制名称', + icon: , + onClick: () => handleCopyTableName(node) + }, + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + addTab({ + id: `query-${Date.now()}`, + title: t('query.new'), + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: buildTableSelectQuery('starrocks', String(node.dataRef?.tableName || node.dataRef?.viewName || '')) + }); + } + }, + ]; + } else if (node.type === 'routine') { + const routineType = node.dataRef?.routineType || 'FUNCTION'; + const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); + return [ + { + key: 'view-routine-def', + label: t('sidebar.menu.view_object_definition'), + icon: , + onClick: () => openRoutineDefinition(node) + }, + { + key: 'edit-routine', + label: t('sidebar.menu.edit_definition'), + icon: , + onClick: () => openEditRoutine(node) + }, + { type: 'divider' }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + { + key: 'drop-routine', + label: t('sidebar.menu.delete_routine', { type: typeLabel }), + icon: , + danger: true, + onClick: () => handleDropRoutine(node) + } + ] + }, + ]; + } else if (node.type === 'db-event') { + return [ + { + key: 'view-event-def', + label: t('sidebar.menu.view_object_definition'), + icon: , + onClick: () => openEventDefinition(node) + }, + { + key: 'edit-event-query', + label: t('sidebar.menu.edit_definition'), + icon: , + onClick: () => { + const { eventName, dbName, id } = node.dataRef; + addTab({ + id: `query-edit-event-${Date.now()}`, + title: t('sidebar.tab.edit_event', { name: eventName }), + type: 'query', + connectionId: id, + dbName, + query: `SHOW CREATE EVENT \`${String(eventName || '').replace(/`/g, '``')}\`;` + }); + } + }, + ]; + } else if (node.type === 'table') { + const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const messagePublishTarget = resolveMessagePublishTarget(node); + return [ + { + key: 'new-query', + label: t('sidebar.menu.new_query'), + icon: , + onClick: () => { + const tableName = String(node.dataRef?.tableName || '').trim(); + const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName); + addTab({ + id: `query-${Date.now()}`, + title: t('query.new'), + type: 'query', + connectionId: node.dataRef.id, + dbName: node.dataRef.dbName, + query: queryTemplate + }); + } + }, + ...(messagePublishTarget ? [{ + key: 'publish-message', + label: '测试发送消息', + icon: , + onClick: () => openMessagePublishModal(node), + }] : []), + { type: 'divider' }, + { + key: 'design-table', + label: isStructureOnlyDbType(String(node.dataRef?.id || '')) ? '表结构' : '设计表', + icon: , + onClick: () => openDesign(node, 'columns', false) + }, + ...(isStarRocks ? [{ + key: 'new-rollup', + label: '新增 Rollup', + icon: , + onClick: () => openCreateStarRocksRollup(node) + }] : []), + { + key: 'copy-table-name', + label: '复制表名', + icon: , + onClick: () => handleCopyTableName(node) + }, + { + key: 'copy-structure', + label: '复制表结构', + icon: , + onClick: () => handleCopyStructure(node) + }, + { + key: 'backup-table', + label: '备份表 (SQL)', + icon: , + onClick: () => handleExport(node, { format: 'sql' }) + }, + { + key: 'rename-table', + label: '重命名表', + icon: , + onClick: () => { + setRenameTableTarget(node); + renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); + setIsRenameTableModalOpen(true); + } + }, + { + key: 'danger-zone', + label: t('sidebar.menu.danger_operations'), + icon: , + children: [ + ...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{ + key: 'truncate-table', + label: '截断表', + danger: true, + onClick: () => handleTableDataDangerAction(node, 'truncate') + }] : []), + { + key: 'clear-table', + label: '清空表', + danger: true, + onClick: () => handleTableDataDangerAction(node, 'clear') + }, + { + key: 'drop-table', + label: '删除表', + icon: , + danger: true, + onClick: () => handleDeleteTable(node) + } + ] + }, + { + type: 'divider' + }, + { + key: 'export', + label: '导出表数据…', + icon: , + onClick: () => openExportDialog(node), + } + ]; + } + + // 已存查询节点的右键菜单 + if (node.type === 'saved-query') { + const q = node.dataRef as SavedQuery; + const rebindMenuItems: MenuProps['items'] = isSavedQueryUnmatched(q) + ? [ + { + key: 'rebind-query', + label: '绑定到连接', + icon: , + disabled: connections.length === 0, + children: connections.length > 0 + ? connections.map((conn: SavedConnection) => ({ + key: `rebind-query-${conn.id}`, + label: conn.name || conn.id, + onClick: () => void handleRebindSavedQuery(q, conn), + })) + : undefined, + }, + ] + : []; + return [ + { + key: 'open-query', + label: t('sidebar.menu.open_query'), + icon: , + onClick: () => { + addTab({ + id: q.id, + title: resolveSavedQueryDisplayName(q.name), + type: 'query', + connectionId: q.connectionId, + dbName: q.dbName, + query: q.sql, + savedQueryId: q.id, + }); + } + }, + ...rebindMenuItems, + { type: 'divider' }, + { + key: 'rename-query', + label: t('sidebar.menu.rename_query'), + icon: , + onClick: () => openRenameSavedQueryModal(q), + }, + { + key: 'delete-query', + label: t('sidebar.menu.delete_query'), + icon: , + danger: true, + onClick: () => { + Modal.confirm({ + title: t('sidebar.modal.confirm_delete.title'), + content: t('sidebar.modal.confirm_delete_saved_query.content', { name: resolveSavedQueryDisplayName(q.name) }), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteQuery(q.id); + } catch (e) { + message.error('删除查询失败: ' + (e instanceof Error ? e.message : String(e))); + throw e; + } + // 从树中移除节点 + const removeNode = (list: TreeNode[]): TreeNode[] => + list + .filter(n => !(n.type === 'saved-query' && n.dataRef?.id === q.id)) + .map(n => n.children ? { ...n, children: removeNode(n.children) } : n); + const nextTreeData = removeNode(treeDataRef.current); + treeDataRef.current = nextTreeData; + setTreeData(nextTreeData); + message.success(t('sidebar.message.saved_query_deleted')); + } + }); + } + } + ]; + } + + if (node.type === 'external-sql-root') { + return [ + { + key: 'add-external-sql-directory', + label: t('sidebar.menu.add_sql_directory'), + icon: , + onClick: () => { + void handleAddExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-directory') { + return [ + { + key: 'new-external-sql-file', + label: t('sidebar.menu.new_sql_file'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: t('sidebar.menu.new_sql_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: t('sidebar.menu.rename_sql_directory'), + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, + { + key: 'refresh-external-sql-directory', + label: t('sidebar.menu.refresh_directory'), + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'remove-external-sql-directory', + label: t('sidebar.menu.remove_directory'), + icon: , + danger: true, + onClick: () => { + void handleRemoveExternalSQLDirectory(node); + } + }, + { + key: 'delete-external-sql-directory', + label: t('sidebar.menu.delete_local_directory'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-folder') { + return [ + { + key: 'new-external-sql-file', + label: t('sidebar.menu.new_sql_file'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: t('sidebar.menu.new_sql_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: t('sidebar.menu.rename_sql_directory'), + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { + key: 'refresh-external-sql-directory', + label: t('sidebar.menu.refresh_directory'), + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-directory', + label: t('sidebar.menu.delete_sql_directory'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-file') { + return [ + { + key: 'open-external-sql-file', + label: t('sidebar.menu.open_sql_file'), + icon: , + onClick: () => { + void openExternalSQLFile(node); + } + }, + { + key: 'rename-external-sql-file', + label: t('sidebar.menu.rename_sql_file'), + icon: , + onClick: () => { + openRenameExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-file-sibling', + label: t('sidebar.menu.new_sql_file_in_directory'), + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory-sibling', + label: t('sidebar.menu.new_sql_directory_in_directory'), + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-file', + label: t('sidebar.menu.delete_sql_file'), + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLFile(node); + } + } + ]; + } + + return []; + }; From d109f2891f289389e57b34d029b9e15f631dc6f6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 16:33:16 +0800 Subject: [PATCH 38/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E5=A4=8D=E7=94=A8=20V2=20=E4=BE=A7=E6=A0=8F=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 523 ++---------------- frontend/src/components/sidebarV2Utils.ts | 16 +- 3 files changed, 46 insertions(+), 494 deletions(-) diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 689b73a..145fedc 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -65,6 +65,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarConnectionRail.tsx'), readSourceFile('./sidebar/SidebarSearchPanel.tsx'), readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), + readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ffe39b0..5d72a4e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -80,12 +80,11 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbo import { buildSidebarRootConnectionToken, buildSidebarRootTagToken, - buildSidebarTablePinKey, resolveSidebarRootOrderTokens, useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { ConnectionTag, SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; + import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; @@ -162,20 +161,56 @@ import { } from './V2TableContextMenu'; import { V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE, + buildSidebarTableChildrenForUi, + buildV2RailConnectionGroups, + buildV2SidebarTableSectionedChildren, estimateV2TreeHorizontalScrollWidth, filterV2CommandSearchTreeItems, + filterV2ExplorerTreeByKind, + isSidebarTablePinned, + normalizeSidebarTreeRelativeDropPosition, + resolveSidebarConnectionIdFromKey, + resolveSidebarDropInsertBefore, + resolveSidebarDropNodeFromDomEvent, + resolveSidebarDropTargetMetricsFromDomEvent, + resolveSidebarNodeConnectionId, + resolveSidebarTagDropInsertBefore, + resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, + shouldSkipSidebarLoadOnExpandWhileDragging, + shouldSkipSidebarSelectWhileDragging, shouldCloseV2CommandSearchOnGlobalKey, shouldRunV2CommandSearchEnter, + sortSidebarTableEntries, + type SidebarTreeNode as TreeNode, + type V2CommandSearchItem, + type V2RailConnectionGroup, } from './sidebarV2Utils'; export { + buildSidebarTableChildrenForUi, + buildV2RailConnectionGroups, + buildV2SidebarTableSectionedChildren, estimateV2TreeHorizontalScrollWidth, filterV2CommandSearchTreeItems, + filterV2ExplorerTreeByKind, + isSidebarTablePinned, + normalizeSidebarTreeRelativeDropPosition, + resolveSidebarConnectionIdFromKey, + resolveSidebarDropInsertBefore, + resolveSidebarDropNodeFromDomEvent, + resolveSidebarDropTargetMetricsFromDomEvent, + resolveSidebarNodeConnectionId, + resolveSidebarTagDropInsertBefore, + resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, + shouldSkipSidebarLoadOnExpandWhileDragging, + shouldSkipSidebarSelectWhileDragging, shouldCloseV2CommandSearchOnGlobalKey, shouldRunV2CommandSearchEnter, + sortSidebarTableEntries, }; +export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils'; const { Search } = Input; type SidebarContextMenuState = { @@ -194,17 +229,6 @@ type SidebarContextMenuState = { const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; -interface TreeNode { - title: string; - key: string; - isLeaf?: boolean; - selectable?: boolean; - children?: TreeNode[]; - icon?: React.ReactNode; - dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'materialized-view' | 'db-trigger' | 'db-event' | 'routine' | 'object-group' | 'v2-table-section' | 'queries-folder' | 'saved-query' | 'all-saved-queries' | 'saved-query-group' | 'unmatched-saved-queries' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring'; -} - // resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; @@ -302,99 +326,6 @@ export const SQLFileExecutionProgressContent: React.FC { - const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName); - return !!key && pinnedKeys.includes(key); -}; - -export const sortSidebarTableEntries = ( - entries: T[], - options: { - connectionId: string; - dbName: string; - sortBy: SidebarTableSortPreference; - tableAccessCount?: Record; - pinnedSidebarTables?: string[]; - }, -): T[] => { - const pinnedKeys = options.pinnedSidebarTables || []; - const accessCount = options.tableAccessCount || {}; - const compareByName = (a: T, b: T) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()); - const compareWithinPinnedGroup = (a: T, b: T) => { - if (options.sortBy === 'frequency') { - const keyA = `${options.connectionId}-${options.dbName}-${a.tableName}`; - const keyB = `${options.connectionId}-${options.dbName}-${b.tableName}`; - const countA = accessCount[keyA] || 0; - const countB = accessCount[keyB] || 0; - if (countA !== countB) { - return countB - countA; - } - } - return compareByName(a, b); - }; - - return [...entries].sort((a, b) => { - const pinnedA = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, a.tableName, a.schemaName || ''); - const pinnedB = isSidebarTablePinned(pinnedKeys, options.connectionId, options.dbName, b.tableName, b.schemaName || ''); - if (pinnedA !== pinnedB) { - return pinnedA ? -1 : 1; - } - return compareWithinPinnedGroup(a, b); - }); -}; - -export const buildV2SidebarTableSectionedChildren = ( - parentKey: string, - tableNodes: TreeNode[], -): TreeNode[] => { - const pinnedTables = tableNodes.filter((node) => node?.dataRef?.pinnedSidebarTable); - if (pinnedTables.length === 0) return tableNodes; - - const regularTables = tableNodes.filter((node) => !node?.dataRef?.pinnedSidebarTable); - const buildSectionNode = (kind: 'pinned' | 'all', title: string): TreeNode => ({ - title, - key: `${parentKey}-v2-${kind}-tables-section`, - type: 'v2-table-section', - isLeaf: true, - selectable: false, - dataRef: { - sectionKind: kind, - }, - }); - - return [ - buildSectionNode('pinned', t('table_overview.section.pinned')), - ...pinnedTables, - buildSectionNode('all', t('table_overview.section.all')), - ...regularTables, - ]; -}; - -export const buildSidebarTableChildrenForUi = ( - parentKey: string, - tableNodes: TreeNode[], - isV2Ui: boolean, -): TreeNode[] => { - if (!isV2Ui) return tableNodes; - return buildV2SidebarTableSectionedChildren(parentKey, tableNodes); -}; - - const buildConnectionRootQueryTabTitle = () => t('query.new'); const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') => @@ -408,93 +339,6 @@ type BatchObjectType = 'table' | 'view'; type BatchObjectFilterType = 'all' | BatchObjectType; type BatchSelectionScope = 'filtered' | 'all'; -export interface V2RailConnectionGroup { - id: string; - name: string; - connections: SavedConnection[]; - isUngrouped?: boolean; - rootToken: string; -} - -export const buildV2RailConnectionGroups = ( - connections: SavedConnection[], - connectionTags: ConnectionTag[], - sidebarRootOrder: string[] = [], -): V2RailConnectionGroup[] => { - const connectionById = new Map(connections.map((conn) => [conn.id, conn])); - const groupedConnectionIds = new Set(); - const tagGroups = new Map(); - - connectionTags.forEach((tag) => { - const tagConnections: SavedConnection[] = []; - tag.connectionIds.forEach((connectionId) => { - const conn = connectionById.get(connectionId); - if (!conn || groupedConnectionIds.has(conn.id)) return; - groupedConnectionIds.add(conn.id); - tagConnections.push(conn); - }); - if (tagConnections.length === 0) return; - tagGroups.set(tag.id, { - id: tag.id, - name: tag.name || t('connection.sidebar.group.untitled'), - connections: tagConnections, - rootToken: buildSidebarRootTagToken(tag.id), - }); - }); - - const ungroupedConnectionMap = new Map( - connections - .filter((conn) => !groupedConnectionIds.has(conn.id)) - .map((conn) => [conn.id, conn]), - ); - const orderedRootTokens = resolveSidebarRootOrderTokens( - sidebarRootOrder, - connectionTags, - connections, - ); - const groups: V2RailConnectionGroup[] = []; - - orderedRootTokens.forEach((token) => { - if (token.startsWith('tag:')) { - const tagId = token.slice('tag:'.length); - const group = tagGroups.get(tagId); - if (!group) return; - groups.push(group); - tagGroups.delete(tagId); - return; - } - if (token.startsWith('connection:')) { - const connectionId = token.slice('connection:'.length); - const conn = ungroupedConnectionMap.get(connectionId); - if (!conn) return; - groups.push({ - id: connectionId, - name: conn.name, - connections: [conn], - isUngrouped: true, - rootToken: buildSidebarRootConnectionToken(connectionId), - }); - ungroupedConnectionMap.delete(connectionId); - } - }); - - tagGroups.forEach((group) => { - groups.push(group); - }); - ungroupedConnectionMap.forEach((conn) => { - groups.push({ - id: conn.id, - name: conn.name, - connections: [conn], - isUngrouped: true, - rootToken: buildSidebarRootConnectionToken(conn.id), - }); - }); - - return groups; -}; - - const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [ { key: 'all', labelKey: 'sidebar.command_search.object_kind.all' }, { key: 'tables', labelKey: 'sidebar.command_search.object_kind.tables' }, @@ -503,55 +347,6 @@ const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: strin { key: 'events', labelKey: 'sidebar.command_search.object_kind.events' }, ]; -const V2_EXPLORER_FILTER_GROUP_KEYS: Record, string[]> = { - tables: ['tables'], - views: ['views', 'materializedViews'], - routines: ['routines'], - events: ['events'], -}; - -export const filterV2ExplorerTreeByKind = ( - nodes: TreeNode[], - filter: V2ExplorerFilter, -): TreeNode[] => { - if (filter === 'all') return nodes; - const allowedGroupKeys = new Set(V2_EXPLORER_FILTER_GROUP_KEYS[filter]); - const objectTypeMatches = (node: TreeNode): boolean => { - if (filter === 'tables') return node.type === 'table'; - if (filter === 'views') return node.type === 'view' || node.type === 'materialized-view'; - if (filter === 'routines') return node.type === 'routine'; - if (filter === 'events') return node.type === 'db-event'; - return false; - }; - - const visit = (node: TreeNode): TreeNode | null => { - if (node.type === 'external-sql-root') { - return null; - } - const groupKey = String(node?.dataRef?.groupKey || ''); - if (node.type === 'object-group') { - if (allowedGroupKeys.has(groupKey)) { - return node; - } - if (groupKey === 'schema') { - const schemaChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[]; - return schemaChildren.length > 0 ? { ...node, children: schemaChildren, isLeaf: false } : null; - } - return null; - } - if (objectTypeMatches(node)) { - return node; - } - if (node.type === 'database') { - const filteredChildren = (node.children || []).map(visit).filter(Boolean) as TreeNode[]; - return filteredChildren.length > 0 ? { ...node, children: filteredChildren, isLeaf: false } : null; - } - return null; - }; - - return nodes.map(visit).filter(Boolean) as TreeNode[]; -}; - type SidebarMessagePublishTarget = { connection: SavedConnection; executionDbName: string; @@ -566,250 +361,6 @@ interface BatchObjectItem { dataRef: any; } -export type V2CommandSearchItem = - | { - key: string; - kind: 'node'; - title: string; - meta: string; - icon: React.ReactNode; - node: TreeNode; - } - | { - key: string; - kind: 'action'; - title: string; - meta: string; - shortcut?: string; - icon: React.ReactNode; - onRun: () => void; - } - | { - key: string; - kind: 'recent'; - title: string; - meta: string; - icon: React.ReactNode; - sql: string; - connectionId?: string; - dbName?: string; - }; - -// V2CommandSearchMode / V2CommandSearchQuery / parseV2CommandSearchQuery 已迁移到 ./sidebar/sidebarHelpers - -export const resolveSidebarConnectionIdFromKey = ( - key: unknown, - connectionIds: string[], -): string => { - const keyText = String(key ?? '').trim(); - if (!keyText) return ''; - - const sortedIds = Array.from(new Set(connectionIds.filter(Boolean))) - .sort((a, b) => b.length - a.length); - return sortedIds.find((id) => keyText === id || keyText.startsWith(`${id}-`)) || ''; -}; - -export const resolveSidebarNodeConnectionId = ( - node: { key?: unknown; dataRef?: Record } | null | undefined, - connectionIds: string[], -): string => { - const directId = String(node?.dataRef?.id || node?.dataRef?.connectionId || '').trim(); - if (directId && connectionIds.includes(directId)) return directId; - return resolveSidebarConnectionIdFromKey(node?.key, connectionIds); -}; - -export const normalizeSidebarTreeRelativeDropPosition = ( - absoluteDropPosition: number, - nodePos: unknown, -): number => { - const segments = String(nodePos || '').split('-'); - const tailIndex = Number(segments[segments.length - 1] || 0); - return absoluteDropPosition - tailIndex; -}; - -export const resolveSidebarDropInsertBefore = ( - relativeDropPosition: number, - metrics?: { - clientY?: number; - top?: number; - height?: number; - } | null, -): boolean => { - if (relativeDropPosition < 0) return true; - if (relativeDropPosition > 0) return false; - const clientY = metrics?.clientY; - const top = metrics?.top; - const height = metrics?.height; - if ( - typeof clientY !== 'number' - || typeof top !== 'number' - || typeof height !== 'number' - || !Number.isFinite(clientY) - || !Number.isFinite(top) - || !Number.isFinite(height) - || height <= 0 - ) { - return false; - } - return clientY < (top + height / 2); -}; - -const resolveSidebarDropBaseElementFromDomEvent = ( - event: { - clientX?: number; - clientY?: number; - target?: EventTarget | null; - } | null | undefined, -): Element | null => { - if (typeof document === 'undefined') return null; - const fallbackTarget = event?.target && typeof (event.target as any).closest === 'function' - ? (event.target as unknown as Element) - : null; - const pointTarget = ( - typeof event?.clientX === 'number' - && typeof event?.clientY === 'number' - ) - ? document.elementFromPoint(event.clientX, event.clientY) - : null; - const baseElement = pointTarget || fallbackTarget; - if (!baseElement || typeof baseElement.closest !== 'function') return null; - return baseElement; -}; - -export const resolveSidebarDropNodeFromDomEvent = ( - event: { - clientX?: number; - clientY?: number; - target?: EventTarget | null; - } | null | undefined, -): { key: string; type: string } | null => { - const baseElement = resolveSidebarDropBaseElementFromDomEvent(event); - if (!baseElement) return null; - const marker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null; - if (!marker) return null; - const key = String(marker.getAttribute('data-sidebar-node-key') || '').trim(); - const type = String(marker.getAttribute('data-sidebar-node-type') || '').trim(); - if (!key || !type) return null; - return { key, type }; -}; - -export const resolveSidebarDropTargetMetricsFromDomEvent = ( - event: { - clientX?: number; - clientY?: number; - target?: EventTarget | null; - } | null | undefined, -): { top: number; height: number } | null => { - const baseElement = resolveSidebarDropBaseElementFromDomEvent(event); - if (!baseElement) return null; - const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null; - if (!treeNode || typeof treeNode.getBoundingClientRect !== 'function') return null; - const rect = treeNode.getBoundingClientRect(); - if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) { - return null; - } - return { - top: rect.top, - height: rect.height, - }; -}; - -export const resolveSidebarTagDropInsertBefore = (options: { - currentTagOrder: string[]; - dragTagId: string; - dropTagId: string; - relativeDropPosition: number; - fallbackInsertBefore: boolean; - metrics?: { - clientY?: number; - top?: number; - height?: number; - } | null; -}): boolean => { - const { - currentTagOrder, - dragTagId, - dropTagId, - relativeDropPosition, - fallbackInsertBefore, - metrics, - } = options; - - if (relativeDropPosition !== 0) { - return fallbackInsertBefore; - } - - const clientY = metrics?.clientY; - const top = metrics?.top; - const height = metrics?.height; - if ( - typeof clientY !== 'number' - || typeof top !== 'number' - || typeof height !== 'number' - || !Number.isFinite(clientY) - || !Number.isFinite(top) - || !Number.isFinite(height) - || height <= 0 - ) { - return fallbackInsertBefore; - } - - const ratio = (clientY - top) / height; - if (ratio < 0.35) return true; - if (ratio > 0.65) return false; - - const dragIndex = currentTagOrder.indexOf(dragTagId); - const dropIndex = currentTagOrder.indexOf(dropTagId); - if (dragIndex === -1 || dropIndex === -1 || dragIndex === dropIndex) { - return fallbackInsertBefore; - } - return dragIndex > dropIndex; -}; - -export const shouldSkipSidebarSelectWhileDragging = ( - isTreeDragging: boolean, - info: { selected?: boolean } | null | undefined, -): boolean => isTreeDragging || !info?.selected; - -export const shouldSkipSidebarLoadOnExpandWhileDragging = ( - isTreeDragging: boolean, - info: { expanded?: boolean; node?: Pick | null } | null | undefined, -): boolean => { - if (isTreeDragging) return true; - if (!info?.expanded) return true; - return !shouldLoadSidebarNodeOnExpand(info.node); -}; - -export const resolveV2ActiveConnectionId = ({ - activeContextConnectionId, - activeTabConnectionId, - selectedKeys, - connectionIds, - fallbackConnectionId, -}: { - activeContextConnectionId?: unknown; - activeTabConnectionId?: unknown; - selectedKeys: unknown[]; - connectionIds: string[]; - fallbackConnectionId?: unknown; -}): string => { - const connectionIdSet = new Set(connectionIds); - const normalizeDirectId = (value: unknown): string => { - const text = String(value || '').trim(); - return text && connectionIdSet.has(text) ? text : ''; - }; - const selectedConnectionId = selectedKeys - .map((key) => resolveSidebarConnectionIdFromKey(key, connectionIds)) - .find(Boolean) || ''; - - return normalizeDirectId(activeContextConnectionId) - || selectedConnectionId - || normalizeDirectId(fallbackConnectionId) - || normalizeDirectId(activeTabConnectionId) - || ''; -}; - - type DriverStatusSnapshot = { type: string; name: string; diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index 67637be..cb24057 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -149,9 +149,9 @@ export const buildV2SidebarTableSectionedChildren = ( }); return [ - buildSectionNode('pinned', '置顶'), + buildSectionNode('pinned', t('table_overview.section.pinned')), ...pinnedTables, - buildSectionNode('all', '全部'), + buildSectionNode('all', t('table_overview.section.all')), ...regularTables, ]; }; @@ -288,12 +288,12 @@ export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = t('c export type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events'; -export const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [ - { key: 'all', label: '全部' }, - { key: 'tables', label: '表' }, - { key: 'views', label: '视图' }, - { key: 'routines', label: '函数' }, - { key: 'events', label: '事件' }, +export const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [ + { key: 'all', labelKey: 'sidebar.command_search.object_kind.all' }, + { key: 'tables', labelKey: 'sidebar.command_search.object_kind.tables' }, + { key: 'views', labelKey: 'sidebar.command_search.object_kind.views' }, + { key: 'routines', labelKey: 'sidebar.command_search.object_kind.routines' }, + { key: 'events', labelKey: 'sidebar.command_search.object_kind.events' }, ]; const V2_EXPLORER_FILTER_GROUP_KEYS: Record, string[]> = { From 5da2c7ff1a3be4ba3c861e8fbe3c6a858c65de1a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 16:45:15 +0800 Subject: [PATCH 39/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E5=85=83=E6=95=B0=E6=8D=AE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 732 +---------------- .../sidebar/sidebarMetadataLoaders.ts | 763 ++++++++++++++++++ 3 files changed, 788 insertions(+), 708 deletions(-) create mode 100644 frontend/src/components/sidebar/sidebarMetadataLoaders.ts diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 145fedc..012f3da 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -65,6 +65,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarConnectionRail.tsx'), readSourceFile('./sidebar/SidebarSearchPanel.tsx'), readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), + readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5d72a4e..662bca4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,6 +2,30 @@ import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; +import { + buildDuckDBMacroDDL, + buildQualifiedName, + buildSidebarObjectKeyName, + buildSidebarTableStatusSQL, + escapeSQLLiteral, + extractSqlServerDefinitionRows, + getCaseInsensitiveRawValue, + getCaseInsensitiveValue, + getMetadataDialect, + getMySQLShowTablesName, + getSidebarTableDisplayName, + isSphinxConnection, + loadDatabaseEvents, + loadDatabaseTriggers, + loadFunctions, + loadSchemas, + loadStarRocksMaterializedViews, + loadViews, + parseMetadataRowCount, + shouldHideSchemaPrefix, + splitQualifiedName, + supportsDatabaseEvents, +} from './sidebar/sidebarMetadataLoaders'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -96,16 +120,10 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { - buildMySQLCompatibleViewMetadataSqls, - isSidebarViewTableType, - normalizeSidebarViewMetadataEntry, - resolveSidebarMetadataDialect, resolveSidebarRuntimeDatabase, type SidebarViewMetadataEntry, } from '../utils/sidebarMetadata'; -import { splitQualifiedNameLast } from '../utils/qualifiedName'; import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql'; -import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay'; import { findSidebarNodePathByKey, @@ -1333,708 +1351,6 @@ const Sidebar: React.FC<{ return null; }; - const SIDEBAR_SCHEMA_DB_TYPES = new Set([ - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'open_gauss', - 'open-gauss', - 'sqlserver', - 'iris', - 'oracle', - 'dameng', - ]); - - const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([ - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'open_gauss', - 'open-gauss', - 'sqlserver', - 'iris', - 'oracle', - 'dm', - ]); - - const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => { - const dbType = String(conn?.config?.type || '').trim().toLowerCase(); - if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true; - if (dbType !== 'custom') return false; - - const customDriver = String(conn?.config?.driver || '').trim().toLowerCase(); - return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver); - }; - - const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => { - const rawName = String(tableName || '').trim(); - if (!rawName) return rawName; - if (!shouldHideSchemaPrefix(conn)) return rawName; - const parsed = splitQualifiedName(rawName); - return parsed.objectName || rawName; - }; - - const getMetadataDialect = (conn: SavedConnection | undefined): string => { - return resolveSidebarMetadataDialect( - conn?.config?.type || '', - conn?.config?.driver || '', - conn?.config?.oceanBaseProtocol, - ); - }; - - const supportsDatabaseEvents = (conn: SavedConnection | undefined): boolean => { - return getMetadataDialect(conn) === 'mysql'; - }; - - const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); - const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; - - type MetadataQuerySpec = { - sql: string; - inferredType?: 'FUNCTION' | 'PROCEDURE'; - }; - - type MetadataQueryResult = { - rows: Record[]; - inferredType?: 'FUNCTION' | 'PROCEDURE'; - }; - - const isSphinxConnection = (conn: SavedConnection | undefined): boolean => { - const type = String(conn?.config?.type || '').trim().toLowerCase(); - if (type === 'sphinx') return true; - if (type !== 'custom') return false; - const driver = String(conn?.config?.driver || '').trim().toLowerCase(); - return driver === 'sphinx' || driver === 'sphinxql'; - }; - - const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => { - const seen = new Set(); - const normalized: MetadataQuerySpec[] = []; - specs.forEach((spec) => { - const sql = String(spec.sql || '').trim(); - if (!sql) return; - const key = `${spec.inferredType || ''}@@${sql}`; - if (seen.has(key)) return; - seen.add(key); - normalized.push({ sql, inferredType: spec.inferredType }); - }); - return normalized; - }; - - const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { - const keyMap = new Map(); - Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); - for (const key of candidateKeys) { - const value = keyMap.get(key.toLowerCase()); - if (value !== undefined && value !== null) { - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - } - return ''; - }; - - const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { - const keyMap = new Map(); - Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); - for (const key of candidateKeys) { - const value = keyMap.get(key.toLowerCase()); - if (value !== undefined && value !== null) { - return value; - } - } - return undefined; - }; - - const getFirstRowValue = (row: Record): string => { - for (const value of Object.values(row || {})) { - if (value !== undefined && value !== null) { - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - } - return ''; - }; - - const extractSqlServerDefinitionRows = (rows: any[], definitionKeys: string[]): string => { - if (!Array.isArray(rows) || rows.length === 0) return ''; - const directDefinition = getCaseInsensitiveRawValue(rows[0] as Record, definitionKeys); - if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { - return String(directDefinition); - } - return rows - .map((row) => getCaseInsensitiveRawValue(row as Record, ['Text', 'text'])) - .filter((value) => value !== undefined && value !== null) - .map((value) => String(value)) - .join(''); - }; - - const getMySQLShowTablesName = (row: Record): string => { - for (const key of Object.keys(row || {})) { - if (!key.toLowerCase().startsWith('tables_in_')) continue; - const value = row[key]; - if (value === undefined || value === null) continue; - const normalized = String(value).trim(); - if (normalized !== '') return normalized; - } - return ''; - }; - - const parseMetadataRowCount = (row: Record): number | undefined => { - const rawValue = getCaseInsensitiveRawValue(row, ['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']); - if (rawValue === undefined || rawValue === null || rawValue === '') { - return undefined; - } - const parsed = Number(String(rawValue).replace(/,/g, '')); - if (!Number.isFinite(parsed) || parsed < 0) { - return undefined; - } - return Math.round(parsed); - }; - - const buildSidebarTableStatusSQL = (conn: SavedConnection, dbName: string): string => { - const dialect = getMetadataDialect(conn); - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': - return [ - 'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS table_rows', - 'FROM information_schema.tables', - `WHERE table_schema = '${safeDbName}'`, - "AND table_type = 'BASE TABLE'", - 'ORDER BY table_name', - ].join('\n'); - case 'postgres': - case 'kingbase': - case 'vastbase': - case 'highgo': - case 'opengauss': - case 'gaussdb': - return [ - "SELECT n.nspname || '.' || c.relname AS table_name, c.reltuples::bigint AS table_rows", - 'FROM pg_class c', - 'JOIN pg_namespace n ON n.oid = c.relnamespace', - "WHERE c.relkind = 'r'", - "AND n.nspname NOT IN ('information_schema', 'pg_catalog')", - "AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'", - 'ORDER BY n.nspname, c.relname', - ].join('\n'); - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName); - return [ - 'SELECT s.name + \'.\' + t.name AS table_name, SUM(p.rows) AS table_rows', - `FROM ${safeDb}.sys.tables t`, - `JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`, - `LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`, - 'WHERE t.type = \'U\'', - 'GROUP BY s.name, t.name', - 'ORDER BY s.name, t.name', - ].join('\n'); - } - case 'clickhouse': - return [ - 'SELECT name AS table_name, total_rows AS table_rows', - 'FROM system.tables', - `WHERE database = '${safeDbName}'`, - "AND engine NOT IN ('View', 'MaterializedView')", - 'ORDER BY name', - ].join('\n'); - case 'oracle': - case 'dm': { - const owner = escapeSQLLiteral(dbName).toUpperCase(); - return [ - 'SELECT table_name, num_rows AS table_rows', - 'FROM all_tables', - `WHERE owner = '${owner}'`, - 'ORDER BY table_name', - ].join('\n'); - } - default: - return ''; - } - }; - - const buildQualifiedName = (schemaName: string, objectName: string): string => { - const schema = String(schemaName || '').trim(); - const name = String(objectName || '').trim(); - if (!name) return ''; - if (!schema) return name; - if (name.includes('.')) return name; - return `${schema}.${name}`; - }; - - const buildSidebarObjectKeyName = (dbName: string, schemaName: string, objectName: string): string => { - const schema = String(schemaName || '').trim(); - const name = String(objectName || '').trim(); - if (!schema || !name || name.includes('.')) return name; - if (schema.toLowerCase() === String(dbName || '').trim().toLowerCase()) return name; - return `${schema}.${name}`; - }; - - const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { - const parsed = splitQualifiedNameLast(qualifiedName); - return { - schemaName: parsed.parentPath, - objectName: parsed.objectName, - }; - }; - - const parseDuckDBParameterNames = (raw: any): string[] => { - if (Array.isArray(raw)) { - return raw - .map((item) => String(item ?? '').trim()) - .filter((item) => item !== '' && item.toLowerCase() !== ''); - } - - const text = String(raw ?? '').trim(); - if (!text) return []; - const normalized = text.startsWith('[') && text.endsWith(']') - ? text.slice(1, -1) - : text; - return normalized - .split(',') - .map((part) => part.trim()) - .filter((part) => part !== '' && part.toLowerCase() !== ''); - }; - - const buildDuckDBMacroDDL = ( - schemaName: string, - functionName: string, - parametersRaw: any, - macroDefinitionRaw: any - ): string => { - const schema = String(schemaName || '').trim(); - const name = String(functionName || '').trim(); - const macroDefinition = String(macroDefinitionRaw || '').trim(); - if (!name || !macroDefinition) return ''; - - const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); - const qualifiedName = schema ? `${schema}.${name}` : name; - const isTableMacro = !macroDefinition.startsWith('('); - if (isTableMacro) { - return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; - } - return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; - }; - - const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': { - return normalizeMetadataQuerySpecs( - buildMySQLCompatibleViewMetadataSqls(dbName).map((sql) => ({ sql })), - ); - } - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }]; - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }]; - } - case 'oracle': - case 'dm': - return normalizeMetadataQuerySpecs([ - { sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }, - { sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` }, - { - sql: safeDbName - ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` - : '', - }, - ]); - case 'sqlite': - return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; - case 'duckdb': - return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; - default: - return []; - } - }; - - const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': { - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME` - : '', - }, - { sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW TRIGGERS` }, - ]); - } - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }]; - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }]; - } - case 'oracle': - case 'dm': - if (!safeDbName) { - return [{ sql: `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME` }]; - } - return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; - case 'sqlite': - return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }]; - case 'duckdb': - return []; - default: - return []; - } - }; - - const buildFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - const safeDbName = escapeSQLLiteral(dbName); - switch (dialect) { - case 'mysql': - case 'starrocks': - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME` - : '', - }, - { - sql: safeDbName - ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` - : `SHOW FUNCTION STATUS`, - inferredType: 'FUNCTION', - }, - { - sql: safeDbName - ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` - : `SHOW PROCEDURE STATUS`, - inferredType: 'PROCEDURE', - }, - ]); - case 'postgres': - case 'kingbase': - case 'highgo': - case 'vastbase': - case 'opengauss': - case 'gaussdb': - return normalizeMetadataQuerySpecs([ - { - // PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE - sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`, - }, - { - // PostgreSQL 10 / 不支持 prokind 的兼容路径 - sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`, - }, - { - // 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示 - sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`, - }, - ]); - case 'sqlserver': { - const safeDb = quoteSqlServerIdentifier(dbName || 'master'); - return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }]; - } - case 'oracle': - case 'dm': - return normalizeMetadataQuerySpecs([ - { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, - { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, - { - sql: safeDbName - ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` - : '', - }, - ]); - case 'duckdb': - return [{ - sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, - inferredType: 'FUNCTION', - }]; - default: - return []; - } - }; - - const buildEventsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { - if (dialect !== 'mysql') { - return []; - } - const safeDbName = escapeSQLLiteral(dbName); - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - return normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT EVENT_SCHEMA AS schema_name, EVENT_NAME AS event_name, EVENT_TYPE AS event_type, STATUS AS status FROM information_schema.events WHERE event_schema = '${safeDbName}' ORDER BY EVENT_NAME` - : '', - }, - { sql: dbIdent ? `SHOW EVENTS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW EVENTS` }, - ]); - }; - - const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => { - if (!isPostgresSchemaDialect(dialect)) { - return []; - } - return [{ - sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`, - }]; - }; - - const queryMetadataRowsBySpecs = async ( - conn: any, - dbName: string, - specs: MetadataQuerySpec[] - ): Promise<{ results: MetadataQueryResult[]; hasSuccessfulQuery: boolean }> => { - const normalizedSpecs = normalizeMetadataQuerySpecs(specs); - if (normalizedSpecs.length === 0) { - return { results: [], hasSuccessfulQuery: false }; - } - const config = buildRuntimeConfig(conn, dbName); - const results: MetadataQueryResult[] = []; - let hasSuccessfulQuery = false; - - for (const spec of normalizedSpecs) { - try { - const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql); - if (!result.success || !Array.isArray(result.data)) { - continue; - } - hasSuccessfulQuery = true; - results.push({ - rows: result.data as Record[], - inferredType: spec.inferredType, - }); - } catch { - // 忽略单条查询失败,继续尝试后续回退语句 - } - } - return { results, hasSuccessfulQuery }; - }; - - const loadViews = async (conn: any, dbName: string): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { - const savedConn = conn as SavedConnection; - const dialect = getMetadataDialect(savedConn); - const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const views: SidebarViewMetadataEntry[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']); - if (!isSidebarViewTableType(tableType)) return; - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']); - const viewName = - getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) - || getMySQLShowTablesName(row) - || getFirstRowValue(row); - const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); - if (!entry) return; - const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - views.push(entry); - }); - }); - return { views, supported: hasSuccessfulQuery }; - }; - - const loadStarRocksMaterializedViews = async ( - conn: any, - dbName: string - ): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - if (dialect !== 'starrocks') { - return { views: [], supported: false }; - } - - const safeDbName = escapeSQLLiteral(dbName); - const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); - const querySpecs = normalizeMetadataQuerySpecs([ - { - sql: safeDbName - ? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME` - : '', - }, - { sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' }, - { sql: `SHOW MATERIALIZED VIEWS` }, - ]); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const views: SidebarViewMetadataEntry[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']); - const viewName = - getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) - || getFirstRowValue(row); - const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); - if (!entry) return; - const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - views.push(entry); - }); - }); - - return { views, supported: hasSuccessfulQuery }; - }; - - const loadDatabaseTriggers = async ( - conn: any, - dbName: string - ): Promise<{ triggers: Array<{ displayName: string; triggerName: string; tableName: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildTriggersMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); - if (!rawTriggerName) return; - - const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); - const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); - - const triggerParts = splitQualifiedName(rawTriggerName); - const tableParts = splitQualifiedName(rawTableName); - - const resolvedSchema = ( - rawSchemaName - || tableParts.schemaName - || triggerParts.schemaName - || dbName - ).trim(); - const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim(); - const resolvedTableName = (tableParts.objectName || rawTableName).trim(); - const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName); - - // MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复 - const uniqueKey = dialect === 'mysql' - ? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` - : `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName; - triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName }); - }); - }); - return { triggers, supported: hasSuccessfulQuery }; - }; - - const loadFunctions = async ( - conn: any, - dbName: string - ): Promise<{ routines: Array<{ displayName: string; routineName: string; routineType: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildFunctionsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const routines: Array<{ displayName: string; routineName: string; routineType: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']); - if (!routineName) return; - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']); - const rawType = getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION'; - const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; - const fullName = buildQualifiedName(schemaName, routineName); - const uniqueKey = `${fullName}@@${normalizedType}`; - if (!fullName || seen.has(uniqueKey)) return; - seen.add(uniqueKey); - const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F'; - routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType }); - }); - }); - return { routines, supported: hasSuccessfulQuery }; - }; - - const loadDatabaseEvents = async ( - conn: any, - dbName: string - ): Promise<{ events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }>; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildEventsMetadataQuerySpecs(dialect, dbName); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }> = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const rawEventName = getCaseInsensitiveValue(row, ['event_name', 'eventname', 'name', 'event']); - if (!rawEventName) return; - - const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'event_schema', 'db', 'database']); - const parsed = splitQualifiedName(rawEventName); - const schemaName = (rawSchemaName || parsed.schemaName || dbName).trim(); - const eventName = (parsed.objectName || rawEventName).trim(); - if (!eventName) return; - - const uniqueKey = `${schemaName.toLowerCase()}@@${eventName.toLowerCase()}`; - if (seen.has(uniqueKey)) return; - seen.add(uniqueKey); - - const eventType = getCaseInsensitiveValue(row, ['event_type', 'type']); - const status = getCaseInsensitiveValue(row, ['status']); - events.push({ - displayName: eventName, - eventName, - schemaName, - eventType, - status, - }); - }); - }); - - return { events, supported: hasSuccessfulQuery }; - }; - - const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => { - const dialect = getMetadataDialect(conn as SavedConnection); - const querySpecs = buildSchemasMetadataQuerySpecs(dialect); - const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); - const seen = new Set(); - const schemas: string[] = []; - - results.forEach((queryResult) => { - queryResult.rows.forEach((row) => { - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row); - if (!schemaName) return; - const key = schemaName.toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - schemas.push(schemaName); - }); - }); - - return { schemas, supported: hasSuccessfulQuery }; - }; - const fetchDriverStatusMap = async (): Promise> => { const cached = driverStatusCacheRef.current; if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts new file mode 100644 index 0000000..7d24823 --- /dev/null +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -0,0 +1,763 @@ +import { DBQuery } from '../../../wailsjs/go/app/App'; +import type { SavedConnection } from '../../types'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import { normalizeOceanBaseProtocol } from '../../utils/oceanBaseProtocol'; +import { splitQualifiedNameLast } from '../../utils/qualifiedName'; +import { + buildMySQLCompatibleViewMetadataSqls, + isSidebarViewTableType, + normalizeSidebarViewMetadataEntry, + resolveSidebarMetadataDialect, + resolveSidebarRuntimeDatabase, + type SidebarViewMetadataEntry, +} from '../../utils/sidebarMetadata'; +import { isPostgresSchemaDialect } from '../sidebarCoreUtils'; + +export const buildSidebarRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { + return buildRpcConnectionConfig(conn.config, { + database: resolveSidebarRuntimeDatabase( + conn?.config?.type, + conn?.config?.driver, + conn?.config?.database, + overrideDatabase, + clearDatabase, + conn?.config?.oceanBaseProtocol, + ), + }); +}; + + const SIDEBAR_SCHEMA_DB_TYPES = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'opengauss', + 'gaussdb', + 'open_gauss', + 'open-gauss', + 'sqlserver', + 'iris', + 'oracle', + 'dameng', + ]); + + const SIDEBAR_SCHEMA_CUSTOM_DRIVERS = new Set([ + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'opengauss', + 'gaussdb', + 'open_gauss', + 'open-gauss', + 'sqlserver', + 'iris', + 'oracle', + 'dm', + ]); + + const shouldHideSchemaPrefix = (conn: SavedConnection | undefined): boolean => { + const dbType = String(conn?.config?.type || '').trim().toLowerCase(); + if (SIDEBAR_SCHEMA_DB_TYPES.has(dbType)) return true; + if (dbType !== 'custom') return false; + + const customDriver = String(conn?.config?.driver || '').trim().toLowerCase(); + return SIDEBAR_SCHEMA_CUSTOM_DRIVERS.has(customDriver); + }; + + const getSidebarTableDisplayName = (conn: SavedConnection | undefined, tableName: string): string => { + const rawName = String(tableName || '').trim(); + if (!rawName) return rawName; + if (!shouldHideSchemaPrefix(conn)) return rawName; + const parsed = splitQualifiedName(rawName); + return parsed.objectName || rawName; + }; + + const getMetadataDialect = (conn: SavedConnection | undefined): string => { + return resolveSidebarMetadataDialect( + conn?.config?.type || '', + conn?.config?.driver || '', + conn?.config?.oceanBaseProtocol, + ); + }; + + const supportsDatabaseEvents = (conn: SavedConnection | undefined): boolean => { + return getMetadataDialect(conn) === 'mysql'; + }; + + const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); + const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`; + + type MetadataQuerySpec = { + sql: string; + inferredType?: 'FUNCTION' | 'PROCEDURE'; + }; + + type MetadataQueryResult = { + rows: Record[]; + inferredType?: 'FUNCTION' | 'PROCEDURE'; + }; + + const isSphinxConnection = (conn: SavedConnection | undefined): boolean => { + const type = String(conn?.config?.type || '').trim().toLowerCase(); + if (type === 'sphinx') return true; + if (type !== 'custom') return false; + const driver = String(conn?.config?.driver || '').trim().toLowerCase(); + return driver === 'sphinx' || driver === 'sphinxql'; + }; + + const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => { + const seen = new Set(); + const normalized: MetadataQuerySpec[] = []; + specs.forEach((spec) => { + const sql = String(spec.sql || '').trim(); + if (!sql) return; + const key = `${spec.inferredType || ''}@@${sql}`; + if (seen.has(key)) return; + seen.add(key); + normalized.push({ sql, inferredType: spec.inferredType }); + }); + return normalized; + }; + + const getCaseInsensitiveValue = (row: Record, candidateKeys: string[]): string => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const getCaseInsensitiveRawValue = (row: Record, candidateKeys: string[]): any => { + const keyMap = new Map(); + Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); + for (const key of candidateKeys) { + const value = keyMap.get(key.toLowerCase()); + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; + }; + + const getFirstRowValue = (row: Record): string => { + for (const value of Object.values(row || {})) { + if (value !== undefined && value !== null) { + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + } + return ''; + }; + + const extractSqlServerDefinitionRows = (rows: any[], definitionKeys: string[]): string => { + if (!Array.isArray(rows) || rows.length === 0) return ''; + const directDefinition = getCaseInsensitiveRawValue(rows[0] as Record, definitionKeys); + if (directDefinition !== undefined && directDefinition !== null && String(directDefinition).trim() !== '') { + return String(directDefinition); + } + return rows + .map((row) => getCaseInsensitiveRawValue(row as Record, ['Text', 'text'])) + .filter((value) => value !== undefined && value !== null) + .map((value) => String(value)) + .join(''); + }; + + const getMySQLShowTablesName = (row: Record): string => { + for (const key of Object.keys(row || {})) { + if (!key.toLowerCase().startsWith('tables_in_')) continue; + const value = row[key]; + if (value === undefined || value === null) continue; + const normalized = String(value).trim(); + if (normalized !== '') return normalized; + } + return ''; + }; + + const parseMetadataRowCount = (row: Record): number | undefined => { + const rawValue = getCaseInsensitiveRawValue(row, ['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']); + if (rawValue === undefined || rawValue === null || rawValue === '') { + return undefined; + } + const parsed = Number(String(rawValue).replace(/,/g, '')); + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + return Math.round(parsed); + }; + + const buildSidebarTableStatusSQL = (conn: SavedConnection, dbName: string): string => { + const dialect = getMetadataDialect(conn); + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return [ + 'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS table_rows', + 'FROM information_schema.tables', + `WHERE table_schema = '${safeDbName}'`, + "AND table_type = 'BASE TABLE'", + 'ORDER BY table_name', + ].join('\n'); + case 'postgres': + case 'kingbase': + case 'vastbase': + case 'highgo': + case 'opengauss': + case 'gaussdb': + return [ + "SELECT n.nspname || '.' || c.relname AS table_name, c.reltuples::bigint AS table_rows", + 'FROM pg_class c', + 'JOIN pg_namespace n ON n.oid = c.relnamespace', + "WHERE c.relkind = 'r'", + "AND n.nspname NOT IN ('information_schema', 'pg_catalog')", + "AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'", + 'ORDER BY n.nspname, c.relname', + ].join('\n'); + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName); + return [ + 'SELECT s.name + \'.\' + t.name AS table_name, SUM(p.rows) AS table_rows', + `FROM ${safeDb}.sys.tables t`, + `JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`, + `LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`, + 'WHERE t.type = \'U\'', + 'GROUP BY s.name, t.name', + 'ORDER BY s.name, t.name', + ].join('\n'); + } + case 'clickhouse': + return [ + 'SELECT name AS table_name, total_rows AS table_rows', + 'FROM system.tables', + `WHERE database = '${safeDbName}'`, + "AND engine NOT IN ('View', 'MaterializedView')", + 'ORDER BY name', + ].join('\n'); + case 'oracle': + case 'dm': { + const owner = escapeSQLLiteral(dbName).toUpperCase(); + return [ + 'SELECT table_name, num_rows AS table_rows', + 'FROM all_tables', + `WHERE owner = '${owner}'`, + 'ORDER BY table_name', + ].join('\n'); + } + default: + return ''; + } + }; + + const buildQualifiedName = (schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!name) return ''; + if (!schema) return name; + if (name.includes('.')) return name; + return `${schema}.${name}`; + }; + + const buildSidebarObjectKeyName = (dbName: string, schemaName: string, objectName: string): string => { + const schema = String(schemaName || '').trim(); + const name = String(objectName || '').trim(); + if (!schema || !name || name.includes('.')) return name; + if (schema.toLowerCase() === String(dbName || '').trim().toLowerCase()) return name; + return `${schema}.${name}`; + }; + + const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { + const parsed = splitQualifiedNameLast(qualifiedName); + return { + schemaName: parsed.parentPath, + objectName: parsed.objectName, + }; + }; + + const parseDuckDBParameterNames = (raw: any): string[] => { + if (Array.isArray(raw)) { + return raw + .map((item) => String(item ?? '').trim()) + .filter((item) => item !== '' && item.toLowerCase() !== ''); + } + + const text = String(raw ?? '').trim(); + if (!text) return []; + const normalized = text.startsWith('[') && text.endsWith(']') + ? text.slice(1, -1) + : text; + return normalized + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '' && part.toLowerCase() !== ''); + }; + + const buildDuckDBMacroDDL = ( + schemaName: string, + functionName: string, + parametersRaw: any, + macroDefinitionRaw: any + ): string => { + const schema = String(schemaName || '').trim(); + const name = String(functionName || '').trim(); + const macroDefinition = String(macroDefinitionRaw || '').trim(); + if (!name || !macroDefinition) return ''; + + const parameters = parseDuckDBParameterNames(parametersRaw).join(', '); + const qualifiedName = schema ? `${schema}.${name}` : name; + const isTableMacro = !macroDefinition.startsWith('('); + if (isTableMacro) { + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`; + } + return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`; + }; + + const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + return normalizeMetadataQuerySpecs( + buildMySQLCompatibleViewMetadataSqls(dbName).map((sql) => ({ sql })), + ); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }]; + } + case 'oracle': + case 'dm': + return normalizeMetadataQuerySpecs([ + { sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }, + { sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` + : '', + }, + ]); + case 'sqlite': + return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }]; + case 'duckdb': + return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }]; + default: + return []; + } + }; + + const buildTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': { + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME` + : '', + }, + { sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW TRIGGERS` }, + ]); + } + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }]; + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }]; + } + case 'oracle': + case 'dm': + if (!safeDbName) { + return [{ sql: `SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME` }]; + } + return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }]; + case 'sqlite': + return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }]; + case 'duckdb': + return []; + default: + return []; + } + }; + + const buildFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME` + : '', + }, + { + sql: safeDbName + ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` + : `SHOW FUNCTION STATUS`, + inferredType: 'FUNCTION', + }, + { + sql: safeDbName + ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` + : `SHOW PROCEDURE STATUS`, + inferredType: 'PROCEDURE', + }, + ]); + case 'postgres': + case 'kingbase': + case 'highgo': + case 'vastbase': + case 'opengauss': + case 'gaussdb': + return normalizeMetadataQuerySpecs([ + { + // PostgreSQL 11+ / 部分 PG-like:通过 prokind 区分 FUNCTION/PROCEDURE + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`, + }, + { + // PostgreSQL 10 / 不支持 prokind 的兼容路径 + sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`, + }, + { + // 最后兜底:仅函数列表,确保 prokind/routines 视图异常时仍可展示 + sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`, + }, + ]); + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName || 'master'); + return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }]; + } + case 'oracle': + case 'dm': + return normalizeMetadataQuerySpecs([ + { sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }, + { + sql: safeDbName + ? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` + : '', + }, + ]); + case 'duckdb': + return [{ + sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`, + inferredType: 'FUNCTION', + }]; + default: + return []; + } + }; + + const buildEventsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => { + if (dialect !== 'mysql') { + return []; + } + const safeDbName = escapeSQLLiteral(dbName); + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + return normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT EVENT_SCHEMA AS schema_name, EVENT_NAME AS event_name, EVENT_TYPE AS event_type, STATUS AS status FROM information_schema.events WHERE event_schema = '${safeDbName}' ORDER BY EVENT_NAME` + : '', + }, + { sql: dbIdent ? `SHOW EVENTS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW EVENTS` }, + ]); + }; + + const buildSchemasMetadataQuerySpecs = (dialect: string): MetadataQuerySpec[] => { + if (!isPostgresSchemaDialect(dialect)) { + return []; + } + return [{ + sql: `SELECT nspname AS schema_name FROM pg_namespace WHERE nspname NOT IN ('pg_catalog', 'information_schema') AND nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY nspname`, + }]; + }; + + const queryMetadataRowsBySpecs = async ( + conn: any, + dbName: string, + specs: MetadataQuerySpec[] + ): Promise<{ results: MetadataQueryResult[]; hasSuccessfulQuery: boolean }> => { + const normalizedSpecs = normalizeMetadataQuerySpecs(specs); + if (normalizedSpecs.length === 0) { + return { results: [], hasSuccessfulQuery: false }; + } + const config = buildSidebarRuntimeConfig(conn, dbName); + const results: MetadataQueryResult[] = []; + let hasSuccessfulQuery = false; + + for (const spec of normalizedSpecs) { + try { + const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql); + if (!result.success || !Array.isArray(result.data)) { + continue; + } + hasSuccessfulQuery = true; + results.push({ + rows: result.data as Record[], + inferredType: spec.inferredType, + }); + } catch { + // 忽略单条查询失败,继续尝试后续回退语句 + } + } + return { results, hasSuccessfulQuery }; + }; + + const loadViews = async (conn: any, dbName: string): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { + const savedConn = conn as SavedConnection; + const dialect = getMetadataDialect(savedConn); + const querySpecs = buildViewsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const views: SidebarViewMetadataEntry[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const tableType = getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']); + if (!isSidebarViewTableType(tableType)) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']); + const viewName = + getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) + || getMySQLShowTablesName(row) + || getFirstRowValue(row); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); + }); + }); + return { views, supported: hasSuccessfulQuery }; + }; + + const loadStarRocksMaterializedViews = async ( + conn: any, + dbName: string + ): Promise<{ views: SidebarViewMetadataEntry[]; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + if (dialect !== 'starrocks') { + return { views: [], supported: false }; + } + + const safeDbName = escapeSQLLiteral(dbName); + const dbIdent = String(dbName || '').replace(/`/g, '``').trim(); + const querySpecs = normalizeMetadataQuerySpecs([ + { + sql: safeDbName + ? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME` + : '', + }, + { sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' }, + { sql: `SHOW MATERIALIZED VIEWS` }, + ]); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const views: SidebarViewMetadataEntry[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']); + const viewName = + getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) + || getFirstRowValue(row); + const entry = normalizeSidebarViewMetadataEntry(dialect, dbName, schemaName, viewName); + if (!entry) return; + const uniqueKey = `${entry.schemaName.toLowerCase()}@@${entry.viewName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + views.push(entry); + }); + }); + + return { views, supported: hasSuccessfulQuery }; + }; + + const loadDatabaseTriggers = async ( + conn: any, + dbName: string + ): Promise<{ triggers: Array<{ displayName: string; triggerName: string; tableName: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildTriggersMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const triggers: Array<{ displayName: string; triggerName: string; tableName: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); + if (!rawTriggerName) return; + + const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); + const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); + + const triggerParts = splitQualifiedName(rawTriggerName); + const tableParts = splitQualifiedName(rawTableName); + + const resolvedSchema = ( + rawSchemaName + || tableParts.schemaName + || triggerParts.schemaName + || dbName + ).trim(); + const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim(); + const resolvedTableName = (tableParts.objectName || rawTableName).trim(); + const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName); + + // MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复 + const uniqueKey = dialect === 'mysql' + ? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` + : `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName; + triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName }); + }); + }); + return { triggers, supported: hasSuccessfulQuery }; + }; + + const loadFunctions = async ( + conn: any, + dbName: string + ): Promise<{ routines: Array<{ displayName: string; routineName: string; routineType: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildFunctionsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const routines: Array<{ displayName: string; routineName: string; routineType: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const routineName = getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']); + if (!routineName) return; + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']); + const rawType = getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION'; + const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'; + const fullName = buildQualifiedName(schemaName, routineName); + const uniqueKey = `${fullName}@@${normalizedType}`; + if (!fullName || seen.has(uniqueKey)) return; + seen.add(uniqueKey); + const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F'; + routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType }); + }); + }); + return { routines, supported: hasSuccessfulQuery }; + }; + + const loadDatabaseEvents = async ( + conn: any, + dbName: string + ): Promise<{ events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }>; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildEventsMetadataQuerySpecs(dialect, dbName); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const events: Array<{ displayName: string; eventName: string; schemaName: string; eventType: string; status: string }> = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const rawEventName = getCaseInsensitiveValue(row, ['event_name', 'eventname', 'name', 'event']); + if (!rawEventName) return; + + const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'event_schema', 'db', 'database']); + const parsed = splitQualifiedName(rawEventName); + const schemaName = (rawSchemaName || parsed.schemaName || dbName).trim(); + const eventName = (parsed.objectName || rawEventName).trim(); + if (!eventName) return; + + const uniqueKey = `${schemaName.toLowerCase()}@@${eventName.toLowerCase()}`; + if (seen.has(uniqueKey)) return; + seen.add(uniqueKey); + + const eventType = getCaseInsensitiveValue(row, ['event_type', 'type']); + const status = getCaseInsensitiveValue(row, ['status']); + events.push({ + displayName: eventName, + eventName, + schemaName, + eventType, + status, + }); + }); + }); + + return { events, supported: hasSuccessfulQuery }; + }; + + const loadSchemas = async (conn: any, dbName: string): Promise<{ schemas: string[]; supported: boolean }> => { + const dialect = getMetadataDialect(conn as SavedConnection); + const querySpecs = buildSchemasMetadataQuerySpecs(dialect); + const { results, hasSuccessfulQuery } = await queryMetadataRowsBySpecs(conn, dbName, querySpecs); + const seen = new Set(); + const schemas: string[] = []; + + results.forEach((queryResult) => { + queryResult.rows.forEach((row) => { + const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'schemaname']) || getFirstRowValue(row); + if (!schemaName) return; + const key = schemaName.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + schemas.push(schemaName); + }); + }); + + return { schemas, supported: hasSuccessfulQuery }; + }; + +export { + buildDuckDBMacroDDL, + buildEventsMetadataQuerySpecs, + buildFunctionsMetadataQuerySpecs, + buildQualifiedName, + buildSchemasMetadataQuerySpecs, + buildSidebarObjectKeyName, + buildSidebarTableStatusSQL, + buildTriggersMetadataQuerySpecs, + buildViewsMetadataQuerySpecs, + escapeSQLLiteral, + extractSqlServerDefinitionRows, + getCaseInsensitiveRawValue, + getCaseInsensitiveValue, + getFirstRowValue, + getMetadataDialect, + getMySQLShowTablesName, + getSidebarTableDisplayName, + isSphinxConnection, + loadDatabaseEvents, + loadDatabaseTriggers, + loadFunctions, + loadSchemas, + loadStarRocksMaterializedViews, + loadViews, + normalizeMetadataQuerySpecs, + parseDuckDBParameterNames, + parseMetadataRowCount, + quoteSqlServerIdentifier, + shouldHideSchemaPrefix, + splitQualifiedName, + supportsDatabaseEvents, +}; From db31513c0b6a4c7a7d2e65916403bd3d094476a6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 16:55:43 +0800 Subject: [PATCH 40/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E6=89=B9=E9=87=8F=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 710 ++--------------- .../SidebarBatchClearFeedback.i18n.test.ts | 2 +- ...arBatchDatabaseExportFeedback.i18n.test.ts | 2 +- ...ebarBatchObjectExportFeedback.i18n.test.ts | 2 +- ...idebarBatchObjectLoadFeedback.i18n.test.ts | 2 +- .../SidebarSchemaExportFeedback.i18n.test.ts | 2 +- .../SidebarTablesExportFeedback.i18n.test.ts | 2 +- .../sidebar/useSidebarBatchExport.ts | 734 ++++++++++++++++++ 9 files changed, 792 insertions(+), 665 deletions(-) create mode 100644 frontend/src/components/sidebar/useSidebarBatchExport.ts diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 012f3da..95c7055 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -66,6 +66,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarSearchPanel.tsx'), readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), + readSourceFile('./sidebar/useSidebarBatchExport.ts'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 662bca4..0c4802b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -26,6 +26,11 @@ import { splitQualifiedName, supportsDatabaseEvents, } from './sidebar/sidebarMetadataLoaders'; +import { + useSidebarBatchExport, + type BatchObjectFilterType, + type BatchSelectionScope, +} from './sidebar/useSidebarBatchExport'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -352,11 +357,6 @@ const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') => const buildConnectionRootRedisMonitorTabTitle = (redisDbLabel = 'db0') => t('sidebar.tab.redis_monitor', { database: redisDbLabel }); -type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; -type BatchObjectType = 'table' | 'view'; -type BatchObjectFilterType = 'all' | BatchObjectType; -type BatchSelectionScope = 'filtered' | 'all'; - const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [ { key: 'all', labelKey: 'sidebar.command_search.object_kind.all' }, { key: 'tables', labelKey: 'sidebar.command_search.object_kind.tables' }, @@ -371,14 +371,6 @@ type SidebarMessagePublishTarget = { destination: string; }; -interface BatchObjectItem { - title: string; - key: string; - objectName: string; - objectType: BatchObjectType; - dataRef: any; -} - type DriverStatusSnapshot = { type: string; name: string; @@ -962,63 +954,52 @@ const Sidebar: React.FC<{ const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); - // Batch Operations Modal - const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); - const [batchTables, setBatchTables] = useState([]); - const [checkedTableKeys, setCheckedTableKeys] = useState([]); - const [batchDbContext, setBatchDbContext] = useState(null); - const [selectedConnection, setSelectedConnection] = useState(''); - const [selectedDatabase, setSelectedDatabase] = useState(''); - const [availableDatabases, setAvailableDatabases] = useState([]); - const [batchFilterKeyword, setBatchFilterKeyword] = useState(''); - const [batchFilterType, setBatchFilterType] = useState('all'); - const [batchSelectionScope, setBatchSelectionScope] = useState('filtered'); - const filteredBatchObjects = useMemo(() => { - const keyword = batchFilterKeyword.trim().toLowerCase(); - return batchTables.filter((item) => { - if (batchFilterType !== 'all' && item.objectType !== batchFilterType) { - return false; - } - if (!keyword) { - return true; - } - return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword); - }); - }, [batchFilterKeyword, batchFilterType, batchTables]); - const groupedBatchObjects = useMemo(() => { - const tables = filteredBatchObjects.filter(item => item.objectType === 'table'); - const views = filteredBatchObjects.filter(item => item.objectType === 'view'); - return { tables, views }; - }, [filteredBatchObjects]); - const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]); - const allBatchObjectKeysByType = useMemo(() => { - if (batchFilterType === 'all') { - return allBatchObjectKeys; - } - return batchTables - .filter((item) => item.objectType === batchFilterType) - .map((item) => item.key); - }, [allBatchObjectKeys, batchFilterType, batchTables]); - const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]); - const selectionScopeTargetKeys = useMemo( - () => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType), - [allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys] - ); - useEffect(() => { - if (batchFilterType === 'all') { - return; - } - const allowed = new Set(allBatchObjectKeysByType); - setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key))); - }, [allBatchObjectKeysByType, batchFilterType]); - - // Batch Database Operations Modal - const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); - const [batchDatabases, setBatchDatabases] = useState([]); - const [checkedDbKeys, setCheckedDbKeys] = useState([]); - const [batchConnContext, setBatchConnContext] = useState(null); - const [selectedDbConnection, setSelectedDbConnection] = useState(''); - + const { + isBatchModalOpen, + setIsBatchModalOpen, + batchTables, + checkedTableKeys, + setCheckedTableKeys, + selectedConnection, + selectedDatabase, + availableDatabases, + batchFilterKeyword, + setBatchFilterKeyword, + batchFilterType, + setBatchFilterType, + batchSelectionScope, + setBatchSelectionScope, + filteredBatchObjects, + groupedBatchObjects, + selectionScopeTargetKeys, + isBatchDbModalOpen, + setIsBatchDbModalOpen, + batchDatabases, + checkedDbKeys, + setCheckedDbKeys, + selectedDbConnection, + handleExportDatabaseSQL, + handleExportSchemaSQL, + openBatchOperationModal, + openBatchTableExportWorkbench, + handleConnectionChange, + handleDatabaseChange, + handleBatchExport, + handleBatchClear, + handleCheckAll, + handleInvertSelection, + openBatchDatabaseModal, + openBatchDatabaseExportWorkbench, + handleDbConnectionChange, + handleBatchDbExport, + handleCheckAllDb, + handleInvertSelectionDb, + } = useSidebarBatchExport({ + connections, + selectedNodesRef, + addTab, + addSqlLog, + }); // Find in Database Modal const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' }); @@ -2668,595 +2649,6 @@ const Sidebar: React.FC<{ }, wasClosed ? 350 : 0); }; - const normalizeConnConfig = (raw: any) => ( - buildRpcConnectionConfig(raw) - ); - - const handleExportDatabaseSQL = async (node: any, includeData: boolean) => { - const conn = node.dataRef; - const dbName = conn.dbName || node.title; - const hide = message.loading( - includeData - ? t('sidebar.message.exporting_database_backup', { database: dbName }) - : t('sidebar.message.exporting_database_schema', { database: dbName }), - 0, - ); - try { - const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData); - hide(); - if (res.success) { - message.success(t('sidebar.message.export_success')); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.export_failed', { error: res.message })); - } - } catch (e: any) { - hide(); - message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); - } - }; - - const handleExportSchemaSQL = async (node: any, includeData: boolean) => { - const conn = node?.dataRef; - const dbName = String(conn?.dbName || '').trim(); - const schemaName = String(conn?.schemaName || '').trim(); - if (!conn || !dbName || !schemaName) { - message.error(t('sidebar.message.schema_export_target_missing')); - return; - } - const hide = message.loading( - includeData - ? t('sidebar.message.exporting_schema_backup', { schema: schemaName }) - : t('sidebar.message.exporting_schema_structure', { schema: schemaName }), - 0, - ); - try { - const res = await (window as any).go.app.App.ExportSchemaSQL( - buildRpcConnectionConfig(conn.config, { database: dbName }) as any, - dbName, - schemaName, - includeData, - ); - hide(); - if (res.success) { - message.success(t('sidebar.message.export_success')); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.export_failed', { error: res.message })); - } - } catch (e: any) { - hide(); - message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); - } - }; - - const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => { - if (!nodes || nodes.length === 0) return; - const first = nodes[0].dataRef; - const dbName = first.dbName; - const connId = first.id; - const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName); - if (!allSame) { - message.error(t('sidebar.message.export_tables_same_database_required')); - return; - } - - const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean); - const hide = message.loading( - includeData - ? t('sidebar.message.backing_up_selected_tables', { count: tableNames.length }) - : t('sidebar.message.exporting_selected_table_schema', { count: tableNames.length }), - 0, - ); - try { - const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData); - hide(); - if (res.success) { - message.success(t('sidebar.message.export_success')); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.export_failed', { error: res.message })); - } - } catch (e: any) { - hide(); - message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); - } - }; - - const openBatchOperationModal = async () => { - // Check if current selected node is database or table - let connId = ''; - let dbName = ''; - - if (selectedNodesRef.current.length > 0) { - const node = selectedNodesRef.current[0]; - if (node.type === 'database') { - connId = node.dataRef.id; - dbName = node.title; - } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { - connId = node.dataRef.id; - dbName = node.dataRef.dbName; - } - } - - setSelectedConnection(connId); - setSelectedDatabase(dbName); - setBatchTables([]); - setCheckedTableKeys([]); - setAvailableDatabases([]); - setBatchFilterKeyword(''); - setBatchFilterType('all'); - setBatchSelectionScope('filtered'); - - if (connId) { - const conn = connections.find(c => c.id === connId); - if (conn) { - await loadDatabasesForBatch(conn); - if (dbName) { - await loadTablesForBatch(conn, dbName); - } - } - } - - setIsBatchModalOpen(true); - }; - - const openBatchTableExportWorkbench = () => { - let connId = ''; - let dbName = ''; - - if (selectedNodesRef.current.length > 0) { - const node = selectedNodesRef.current[0]; - if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { - connId = node.key as string; - } else if (node.type === 'database') { - connId = node.dataRef.id; - dbName = node.title; - } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { - connId = node.dataRef.id; - dbName = node.dataRef.dbName; - } - } - - addTab(buildBatchTableExportWorkbenchTab({ - connectionId: connId, - dbName: dbName || undefined, - title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象', - })); - }; - - const loadDatabasesForBatch = async (conn: SavedConnection) => { - 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 DBGetDatabases(buildRpcConnectionConfig(config) as any); - if (res.success) { - const dbRows: any[] = Array.isArray(res.data) ? res.data : []; - let dbs = dbRows.map((row: any) => { - const dbName = row.Database || row.database; - return { - title: dbName, - key: `${conn.id}-${dbName}`, - dbName: dbName - }; - }); - - if (conn.includeDatabases && conn.includeDatabases.length > 0) { - dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); - } - - setAvailableDatabases(dbs); - } else { - message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); - } - }; - - const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => { - setBatchDbContext({ conn, dbName }); - - 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, viewResult] = await Promise.all([ - DBGetTables(buildRpcConnectionConfig(config) as any, dbName), - loadViews(conn, dbName).catch(() => ({ views: [], supported: false })), - ]); - - if (!res.success) { - message.error(t('sidebar.message.load_table_list_failed', { error: res.message })); - return; - } - - const tableRows: any[] = Array.isArray(res.data) ? res.data : []; - const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewResult.views) ? viewResult.views : []; - const viewSet = new Set( - viewRows.flatMap((view) => { - const names = [view.viewName.toLowerCase()]; - if (view.schemaName && !view.viewName.includes('.')) { - names.push(`${view.schemaName}.${view.viewName}`.toLowerCase()); - } - return names; - }) - ); - - const tableObjects: BatchObjectItem[] = tableRows - .map((row: any) => Object.values(row)[0] as string) - .filter((tableName: string) => !viewSet.has(tableName.toLowerCase())) - .map((tableName: string) => ({ - title: getSidebarTableDisplayName(conn, tableName), - key: `${conn.id}-${dbName}-table-${tableName}`, - objectName: tableName, - objectType: 'table' as const, - dataRef: { ...conn, tableName, dbName, objectType: 'table' }, - })); - - const viewObjects: BatchObjectItem[] = viewRows.map((view) => { - const keyName = buildSidebarObjectKeyName(dbName, view.schemaName, view.viewName); - return { - title: getSidebarTableDisplayName(conn, view.viewName), - key: `${conn.id}-${dbName}-view-${keyName}`, - objectName: view.viewName, - objectType: 'view' as const, - dataRef: { ...conn, tableName: view.viewName, schemaName: view.schemaName, dbName, objectType: 'view' }, - }; - }); - - tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); - viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); - - setBatchTables([...tableObjects, ...viewObjects]); - setCheckedTableKeys([]); - }; - - const handleConnectionChange = async (connId: string) => { - setSelectedConnection(connId); - setSelectedDatabase(''); - setBatchTables([]); - setCheckedTableKeys([]); - setBatchFilterKeyword(''); - setBatchFilterType('all'); - setBatchSelectionScope('filtered'); - - const conn = connections.find(c => c.id === connId); - if (conn) { - await loadDatabasesForBatch(conn); - } - }; - - const handleDatabaseChange = async (dbName: string) => { - setSelectedDatabase(dbName); - setBatchFilterKeyword(''); - setBatchFilterType('all'); - setBatchSelectionScope('filtered'); - - const conn = connections.find(c => c.id === selectedConnection); - if (conn && dbName) { - await loadTablesForBatch(conn, dbName); - } - }; - - const handleBatchExport = async (mode: BatchTableExportMode) => { - const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); - if (selectedObjects.length === 0) { - message.warning(t('sidebar.message.select_object_required')); - return; - } - - setIsBatchModalOpen(false); - - const { conn, dbName } = batchDbContext; - const objectNames = selectedObjects.map(t => t.objectName); - const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length; - - const loadingText = mode === 'backup' - ? t('sidebar.message.backing_up_selected_objects', { count: objectNames.length }) - : mode === 'dataOnly' - ? t('sidebar.message.exporting_selected_object_data', { count: objectNames.length, format: 'INSERT' }) - : t('sidebar.message.exporting_selected_object_schema', { count: objectNames.length }); - const hide = message.loading(loadingText, 0); - try { - const app = (window as any).go.app.App; - const res = mode === 'dataOnly' - ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames) - : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup'); - hide(); - if (res.success) { - if (mode !== 'schema' && selectedViewCount > 0) { - message.success(t('sidebar.message.export_success_skipped_views', { count: selectedViewCount })); - } else { - message.success(t('sidebar.message.export_success')); - } - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.export_failed', { error: res.message })); - } - } catch (e: any) { - hide(); - message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); - } - }; - - const handleBatchClear = async () => { - const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); - if (selectedObjects.length === 0) { - message.warning(t('sidebar.message.select_object_required')); - return; - } - - const { conn, dbName } = batchDbContext; - const objectNames = selectedObjects.map(t => t.objectName); - - const ok = await new Promise((resolve) => { - Modal.confirm({ - title: t('sidebar.modal.confirm_clear_selected_tables.title'), - content: t('sidebar.modal.confirm_clear_selected_tables.content', { - connection: conn.name, - database: dbName, - }), - okText: t('sidebar.action.continue'), - cancelText: t('sidebar.action.cancel'), - onOk: () => resolve(true), - onCancel: () => resolve(false), - }); - }); - if (!ok) return; - - setIsBatchModalOpen(false); - const hide = message.loading(t('sidebar.message.clearing_selected_tables', { count: objectNames.length }), 0); - const startTime = Date.now(); - try { - const app = (window as any).go.app.App; - const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames); - hide(); - const duration = Date.now() - startTime; - if (res.success) { - message.success(t('sidebar.message.clear_success')); - // 构造 SQL 日志 - let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`; - if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { - logSql += res.data.executedSQLs.join(';\n') + ';'; - } else { - logSql += objectNames.map(name => name).join('; '); - } - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: logSql, - status: 'success', - duration, - message: res.message, - dbName, - affectedRows: res.data?.count || 0 - }); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.clear_failed', { error: res.message })); - // 记录失败的日志 - let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`; - if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { - logSql += res.data.executedSQLs.join(';\n') + ';'; - } else { - logSql += objectNames.map(name => name).join('; '); - } - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: logSql, - status: 'error', - duration, - message: res.message, - dbName - }); - } - } catch (e: any) { - const duration = Date.now() - startTime; - hide(); - const errMsg = e?.message || String(e); - message.error(t('sidebar.message.clear_failed', { error: errMsg })); - // 记录异常的日志 - let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`; - logSql += objectNames.map(name => name).join('; '); - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: logSql, - status: 'error', - duration, - message: errMsg, - dbName - }); - } - }; - - const handleCheckAll = (checked: boolean) => { - if (batchSelectionScope === 'all') { - setCheckedTableKeys(checked ? allBatchObjectKeys : []); - return; - } - if (filteredBatchObjectKeys.length === 0) { - return; - } - if (checked) { - setCheckedTableKeys(prev => { - const nextSet = new Set(prev); - filteredBatchObjectKeys.forEach((key) => nextSet.add(key)); - return allBatchObjectKeys.filter((key) => nextSet.has(key)); - }); - return; - } - const filteredKeySet = new Set(filteredBatchObjectKeys); - setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key))); - }; - - const handleInvertSelection = () => { - if (batchSelectionScope === 'all') { - setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key))); - return; - } - if (filteredBatchObjectKeys.length === 0) { - return; - } - setCheckedTableKeys(prev => { - const nextSet = new Set(prev); - filteredBatchObjectKeys.forEach((key) => { - if (nextSet.has(key)) { - nextSet.delete(key); - } else { - nextSet.add(key); - } - }); - return allBatchObjectKeys.filter((key) => nextSet.has(key)); - }); - }; - - const openBatchDatabaseModal = async () => { - // Check if current selected node is connection or database - let connId = ''; - - if (selectedNodesRef.current.length > 0) { - const node = selectedNodesRef.current[0]; - if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { - connId = node.key as string; - } else if (node.type === 'database') { - connId = node.dataRef.id; - } else if (node.type === 'table') { - connId = node.dataRef.id; - } - } - - setSelectedDbConnection(connId); - setBatchDatabases([]); - setCheckedDbKeys([]); - - if (connId) { - const conn = connections.find(c => c.id === connId); - if (conn) { - await loadDatabasesForDbBatch(conn); - } - } - - setIsBatchDbModalOpen(true); - }; - - const openBatchDatabaseExportWorkbench = () => { - let connId = ''; - - if (selectedNodesRef.current.length > 0) { - const node = selectedNodesRef.current[0]; - if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { - connId = node.key as string; - } else if (node.type === 'database' || node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { - connId = node.dataRef.id; - } - } - - addTab(buildBatchDatabaseExportWorkbenchTab({ - connectionId: connId, - title: '批量导出库', - })); - }; - - const loadDatabasesForDbBatch = async (conn: SavedConnection) => { - setBatchConnContext(conn); - - 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 DBGetDatabases(buildRpcConnectionConfig(config) as any); - if (res.success) { - const dbRows: any[] = Array.isArray(res.data) ? res.data : []; - let dbs = dbRows.map((row: any) => { - const dbName = row.Database || row.database; - return { - title: dbName, - key: `${conn.id}-${dbName}`, - dbName: dbName, - dataRef: { ...conn, dbName } - }; - }); - - if (conn.includeDatabases && conn.includeDatabases.length > 0) { - dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); - } - - setBatchDatabases(dbs); - setCheckedDbKeys([]); - } else { - message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); - } - }; - - const handleDbConnectionChange = async (connId: string) => { - setSelectedDbConnection(connId); - - const conn = connections.find(c => c.id === connId); - if (conn) { - await loadDatabasesForDbBatch(conn); - } - }; - - const handleBatchDbExport = async (includeData: boolean) => { - const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key)); - if (selectedDbs.length === 0) { - message.warning(t('sidebar.message.select_database_required')); - return; - } - - setIsBatchDbModalOpen(false); - - for (const db of selectedDbs) { - const hide = message.loading( - includeData - ? t('sidebar.message.exporting_database_backup', { database: db.dbName }) - : t('sidebar.message.exporting_database_schema', { database: db.dbName }), - 0, - ); - try { - const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData); - hide(); - if (res.success) { - message.success(t('sidebar.message.database_export_success', { database: db.dbName })); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: res.message })); - break; - } else { - break; // User cancelled - } - } catch (e: any) { - hide(); - message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: e?.message || String(e) })); - break; - } - } - }; - - const handleCheckAllDb = (checked: boolean) => { - if (checked) { - setCheckedDbKeys(batchDatabases.map(db => db.key)); - } else { - setCheckedDbKeys([]); - } - }; - - const handleInvertSelectionDb = () => { - const allKeys = batchDatabases.map(db => db.key); - const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k)); - setCheckedDbKeys(newChecked); - }; - const handleRunSQLFile = async (node: any) => { const res = await OpenSQLFile(); if (res.success) { diff --git a/frontend/src/components/SidebarBatchClearFeedback.i18n.test.ts b/frontend/src/components/SidebarBatchClearFeedback.i18n.test.ts index fe8bcc3..4c9b669 100644 --- a/frontend/src/components/SidebarBatchClearFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarBatchClearFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.select_object_required', diff --git a/frontend/src/components/SidebarBatchDatabaseExportFeedback.i18n.test.ts b/frontend/src/components/SidebarBatchDatabaseExportFeedback.i18n.test.ts index ba20716..320c19c 100644 --- a/frontend/src/components/SidebarBatchDatabaseExportFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarBatchDatabaseExportFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.load_database_list_failed', diff --git a/frontend/src/components/SidebarBatchObjectExportFeedback.i18n.test.ts b/frontend/src/components/SidebarBatchObjectExportFeedback.i18n.test.ts index 9f6c00d..47df65b 100644 --- a/frontend/src/components/SidebarBatchObjectExportFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarBatchObjectExportFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.select_object_required', diff --git a/frontend/src/components/SidebarBatchObjectLoadFeedback.i18n.test.ts b/frontend/src/components/SidebarBatchObjectLoadFeedback.i18n.test.ts index ccee574..e05c56c 100644 --- a/frontend/src/components/SidebarBatchObjectLoadFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarBatchObjectLoadFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.load_database_list_failed', diff --git a/frontend/src/components/SidebarSchemaExportFeedback.i18n.test.ts b/frontend/src/components/SidebarSchemaExportFeedback.i18n.test.ts index 054a5e2..9832640 100644 --- a/frontend/src/components/SidebarSchemaExportFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarSchemaExportFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.schema_export_target_missing', diff --git a/frontend/src/components/SidebarTablesExportFeedback.i18n.test.ts b/frontend/src/components/SidebarTablesExportFeedback.i18n.test.ts index fa568ce..ca16ecb 100644 --- a/frontend/src/components/SidebarTablesExportFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarTablesExportFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8'); const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; const requiredKeys = [ 'sidebar.message.export_tables_same_database_required', diff --git a/frontend/src/components/sidebar/useSidebarBatchExport.ts b/frontend/src/components/sidebar/useSidebarBatchExport.ts new file mode 100644 index 0000000..dcab1ec --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarBatchExport.ts @@ -0,0 +1,734 @@ +import { useEffect, useMemo, useState, type MutableRefObject } from 'react'; +import { Modal, message } from 'antd'; + +import { DBGetDatabases, DBGetTables } from '../../../wailsjs/go/app/App'; +import type { SavedConnection } from '../../types'; +import { t } from '../../i18n'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import type { SidebarViewMetadataEntry } from '../../utils/sidebarMetadata'; +import { + buildBatchDatabaseExportWorkbenchTab, + buildBatchTableExportWorkbenchTab, +} from '../../utils/tableExportTab'; +import { + buildSidebarObjectKeyName, + getMetadataDialect, + getSidebarTableDisplayName, + loadViews, +} from './sidebarMetadataLoaders'; + +export type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; +export type BatchObjectType = 'table' | 'view'; +export type BatchObjectFilterType = 'all' | BatchObjectType; +export type BatchSelectionScope = 'filtered' | 'all'; + +export interface BatchObjectItem { + title: string; + key: string; + objectName: string; + objectType: BatchObjectType; + dataRef: any; +} + +interface UseSidebarBatchExportArgs { + connections: SavedConnection[]; + selectedNodesRef: MutableRefObject; + addTab: (tab: any) => void; + addSqlLog: (log: any) => void; +} + +export const useSidebarBatchExport = ({ + connections, + selectedNodesRef, + addTab, + addSqlLog, +}: UseSidebarBatchExportArgs) => { + // Batch Operations Modal + const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); + const [batchTables, setBatchTables] = useState([]); + const [checkedTableKeys, setCheckedTableKeys] = useState([]); + const [batchDbContext, setBatchDbContext] = useState(null); + const [selectedConnection, setSelectedConnection] = useState(''); + const [selectedDatabase, setSelectedDatabase] = useState(''); + const [availableDatabases, setAvailableDatabases] = useState([]); + const [batchFilterKeyword, setBatchFilterKeyword] = useState(''); + const [batchFilterType, setBatchFilterType] = useState('all'); + const [batchSelectionScope, setBatchSelectionScope] = useState('filtered'); + const filteredBatchObjects = useMemo(() => { + const keyword = batchFilterKeyword.trim().toLowerCase(); + return batchTables.filter((item) => { + if (batchFilterType !== 'all' && item.objectType !== batchFilterType) { + return false; + } + if (!keyword) { + return true; + } + return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword); + }); + }, [batchFilterKeyword, batchFilterType, batchTables]); + const groupedBatchObjects = useMemo(() => { + const tables = filteredBatchObjects.filter(item => item.objectType === 'table'); + const views = filteredBatchObjects.filter(item => item.objectType === 'view'); + return { tables, views }; + }, [filteredBatchObjects]); + const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]); + const allBatchObjectKeysByType = useMemo(() => { + if (batchFilterType === 'all') { + return allBatchObjectKeys; + } + return batchTables + .filter((item) => item.objectType === batchFilterType) + .map((item) => item.key); + }, [allBatchObjectKeys, batchFilterType, batchTables]); + const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]); + const selectionScopeTargetKeys = useMemo( + () => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType), + [allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys] + ); + useEffect(() => { + if (batchFilterType === 'all') { + return; + } + const allowed = new Set(allBatchObjectKeysByType); + setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key))); + }, [allBatchObjectKeysByType, batchFilterType]); + + // Batch Database Operations Modal + const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); + const [batchDatabases, setBatchDatabases] = useState([]); + const [checkedDbKeys, setCheckedDbKeys] = useState([]); + const [batchConnContext, setBatchConnContext] = useState(null); + const [selectedDbConnection, setSelectedDbConnection] = useState(''); + + + const normalizeConnConfig = (raw: any) => ( + buildRpcConnectionConfig(raw) + ); + + const handleExportDatabaseSQL = async (node: any, includeData: boolean) => { + const conn = node.dataRef; + const dbName = conn.dbName || node.title; + const hide = message.loading( + includeData + ? t('sidebar.message.exporting_database_backup', { database: dbName }) + : t('sidebar.message.exporting_database_schema', { database: dbName }), + 0, + ); + try { + const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData); + hide(); + if (res.success) { + message.success(t('sidebar.message.export_success')); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.export_failed', { error: res.message })); + } + } catch (e: any) { + hide(); + message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); + } + }; + + const handleExportSchemaSQL = async (node: any, includeData: boolean) => { + const conn = node?.dataRef; + const dbName = String(conn?.dbName || '').trim(); + const schemaName = String(conn?.schemaName || '').trim(); + if (!conn || !dbName || !schemaName) { + message.error(t('sidebar.message.schema_export_target_missing')); + return; + } + const hide = message.loading( + includeData + ? t('sidebar.message.exporting_schema_backup', { schema: schemaName }) + : t('sidebar.message.exporting_schema_structure', { schema: schemaName }), + 0, + ); + try { + const res = await (window as any).go.app.App.ExportSchemaSQL( + buildRpcConnectionConfig(conn.config, { database: dbName }) as any, + dbName, + schemaName, + includeData, + ); + hide(); + if (res.success) { + message.success(t('sidebar.message.export_success')); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.export_failed', { error: res.message })); + } + } catch (e: any) { + hide(); + message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); + } + }; + + const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => { + if (!nodes || nodes.length === 0) return; + const first = nodes[0].dataRef; + const dbName = first.dbName; + const connId = first.id; + const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName); + if (!allSame) { + message.error(t('sidebar.message.export_tables_same_database_required')); + return; + } + + const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean); + const hide = message.loading( + includeData + ? t('sidebar.message.backing_up_selected_tables', { count: tableNames.length }) + : t('sidebar.message.exporting_selected_table_schema', { count: tableNames.length }), + 0, + ); + try { + const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData); + hide(); + if (res.success) { + message.success(t('sidebar.message.export_success')); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.export_failed', { error: res.message })); + } + } catch (e: any) { + hide(); + message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); + } + }; + + const openBatchOperationModal = async () => { + // Check if current selected node is database or table + let connId = ''; + let dbName = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'database') { + connId = node.dataRef.id; + dbName = node.title; + } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { + connId = node.dataRef.id; + dbName = node.dataRef.dbName; + } + } + + setSelectedConnection(connId); + setSelectedDatabase(dbName); + setBatchTables([]); + setCheckedTableKeys([]); + setAvailableDatabases([]); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); + + if (connId) { + const conn = connections.find(c => c.id === connId); + if (conn) { + await loadDatabasesForBatch(conn); + if (dbName) { + await loadTablesForBatch(conn, dbName); + } + } + } + + setIsBatchModalOpen(true); + }; + + const openBatchTableExportWorkbench = () => { + let connId = ''; + let dbName = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { + connId = node.key as string; + } else if (node.type === 'database') { + connId = node.dataRef.id; + dbName = node.title; + } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { + connId = node.dataRef.id; + dbName = node.dataRef.dbName; + } + } + + addTab(buildBatchTableExportWorkbenchTab({ + connectionId: connId, + dbName: dbName || undefined, + title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象', + })); + }; + + const loadDatabasesForBatch = async (conn: SavedConnection) => { + 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 DBGetDatabases(buildRpcConnectionConfig(config) as any); + if (res.success) { + const dbRows: any[] = Array.isArray(res.data) ? res.data : []; + let dbs = dbRows.map((row: any) => { + const dbName = row.Database || row.database; + return { + title: dbName, + key: `${conn.id}-${dbName}`, + dbName: dbName + }; + }); + + if (conn.includeDatabases && conn.includeDatabases.length > 0) { + dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); + } + + setAvailableDatabases(dbs); + } else { + message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); + } + }; + + const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => { + setBatchDbContext({ conn, dbName }); + + 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, viewResult] = await Promise.all([ + DBGetTables(buildRpcConnectionConfig(config) as any, dbName), + loadViews(conn, dbName).catch(() => ({ views: [], supported: false })), + ]); + + if (!res.success) { + message.error(t('sidebar.message.load_table_list_failed', { error: res.message })); + return; + } + + const tableRows: any[] = Array.isArray(res.data) ? res.data : []; + const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewResult.views) ? viewResult.views : []; + const viewSet = new Set( + viewRows.flatMap((view) => { + const names = [view.viewName.toLowerCase()]; + if (view.schemaName && !view.viewName.includes('.')) { + names.push(`${view.schemaName}.${view.viewName}`.toLowerCase()); + } + return names; + }) + ); + + const tableObjects: BatchObjectItem[] = tableRows + .map((row: any) => Object.values(row)[0] as string) + .filter((tableName: string) => !viewSet.has(tableName.toLowerCase())) + .map((tableName: string) => ({ + title: getSidebarTableDisplayName(conn, tableName), + key: `${conn.id}-${dbName}-table-${tableName}`, + objectName: tableName, + objectType: 'table' as const, + dataRef: { ...conn, tableName, dbName, objectType: 'table' }, + })); + + const viewObjects: BatchObjectItem[] = viewRows.map((view) => { + const keyName = buildSidebarObjectKeyName(dbName, view.schemaName, view.viewName); + return { + title: getSidebarTableDisplayName(conn, view.viewName), + key: `${conn.id}-${dbName}-view-${keyName}`, + objectName: view.viewName, + objectType: 'view' as const, + dataRef: { ...conn, tableName: view.viewName, schemaName: view.schemaName, dbName, objectType: 'view' }, + }; + }); + + tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); + + setBatchTables([...tableObjects, ...viewObjects]); + setCheckedTableKeys([]); + }; + + const handleConnectionChange = async (connId: string) => { + setSelectedConnection(connId); + setSelectedDatabase(''); + setBatchTables([]); + setCheckedTableKeys([]); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); + + const conn = connections.find(c => c.id === connId); + if (conn) { + await loadDatabasesForBatch(conn); + } + }; + + const handleDatabaseChange = async (dbName: string) => { + setSelectedDatabase(dbName); + setBatchFilterKeyword(''); + setBatchFilterType('all'); + setBatchSelectionScope('filtered'); + + const conn = connections.find(c => c.id === selectedConnection); + if (conn && dbName) { + await loadTablesForBatch(conn, dbName); + } + }; + + const handleBatchExport = async (mode: BatchTableExportMode) => { + const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); + if (selectedObjects.length === 0) { + message.warning(t('sidebar.message.select_object_required')); + return; + } + + setIsBatchModalOpen(false); + + const { conn, dbName } = batchDbContext; + const objectNames = selectedObjects.map(t => t.objectName); + const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length; + + const loadingText = mode === 'backup' + ? t('sidebar.message.backing_up_selected_objects', { count: objectNames.length }) + : mode === 'dataOnly' + ? t('sidebar.message.exporting_selected_object_data', { count: objectNames.length, format: 'INSERT' }) + : t('sidebar.message.exporting_selected_object_schema', { count: objectNames.length }); + const hide = message.loading(loadingText, 0); + try { + const app = (window as any).go.app.App; + const res = mode === 'dataOnly' + ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames) + : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup'); + hide(); + if (res.success) { + if (mode !== 'schema' && selectedViewCount > 0) { + message.success(t('sidebar.message.export_success_skipped_views', { count: selectedViewCount })); + } else { + message.success(t('sidebar.message.export_success')); + } + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.export_failed', { error: res.message })); + } + } catch (e: any) { + hide(); + message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); + } + }; + + const handleBatchClear = async () => { + const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); + if (selectedObjects.length === 0) { + message.warning(t('sidebar.message.select_object_required')); + return; + } + + const { conn, dbName } = batchDbContext; + const objectNames = selectedObjects.map(t => t.objectName); + + const ok = await new Promise((resolve) => { + Modal.confirm({ + title: t('sidebar.modal.confirm_clear_selected_tables.title'), + content: t('sidebar.modal.confirm_clear_selected_tables.content', { + connection: conn.name, + database: dbName, + }), + okText: t('sidebar.action.continue'), + cancelText: t('sidebar.action.cancel'), + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + if (!ok) return; + + setIsBatchModalOpen(false); + const hide = message.loading(t('sidebar.message.clearing_selected_tables', { count: objectNames.length }), 0); + const startTime = Date.now(); + try { + const app = (window as any).go.app.App; + const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames); + hide(); + const duration = Date.now() - startTime; + if (res.success) { + message.success(t('sidebar.message.clear_success')); + // 构造 SQL 日志 + let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`; + if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { + logSql += res.data.executedSQLs.join(';\n') + ';'; + } else { + logSql += objectNames.map(name => name).join('; '); + } + addSqlLog({ + id: Date.now().toString(), + timestamp: Date.now(), + sql: logSql, + status: 'success', + duration, + message: res.message, + dbName, + affectedRows: res.data?.count || 0 + }); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.clear_failed', { error: res.message })); + // 记录失败的日志 + let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`; + if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { + logSql += res.data.executedSQLs.join(';\n') + ';'; + } else { + logSql += objectNames.map(name => name).join('; '); + } + addSqlLog({ + id: Date.now().toString(), + timestamp: Date.now(), + sql: logSql, + status: 'error', + duration, + message: res.message, + dbName + }); + } + } catch (e: any) { + const duration = Date.now() - startTime; + hide(); + const errMsg = e?.message || String(e); + message.error(t('sidebar.message.clear_failed', { error: errMsg })); + // 记录异常的日志 + let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`; + logSql += objectNames.map(name => name).join('; '); + addSqlLog({ + id: Date.now().toString(), + timestamp: Date.now(), + sql: logSql, + status: 'error', + duration, + message: errMsg, + dbName + }); + } + }; + + const handleCheckAll = (checked: boolean) => { + if (batchSelectionScope === 'all') { + setCheckedTableKeys(checked ? allBatchObjectKeys : []); + return; + } + if (filteredBatchObjectKeys.length === 0) { + return; + } + if (checked) { + setCheckedTableKeys(prev => { + const nextSet = new Set(prev); + filteredBatchObjectKeys.forEach((key) => nextSet.add(key)); + return allBatchObjectKeys.filter((key) => nextSet.has(key)); + }); + return; + } + const filteredKeySet = new Set(filteredBatchObjectKeys); + setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key))); + }; + + const handleInvertSelection = () => { + if (batchSelectionScope === 'all') { + setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key))); + return; + } + if (filteredBatchObjectKeys.length === 0) { + return; + } + setCheckedTableKeys(prev => { + const nextSet = new Set(prev); + filteredBatchObjectKeys.forEach((key) => { + if (nextSet.has(key)) { + nextSet.delete(key); + } else { + nextSet.add(key); + } + }); + return allBatchObjectKeys.filter((key) => nextSet.has(key)); + }); + }; + + const openBatchDatabaseModal = async () => { + // Check if current selected node is connection or database + let connId = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { + connId = node.key as string; + } else if (node.type === 'database') { + connId = node.dataRef.id; + } else if (node.type === 'table') { + connId = node.dataRef.id; + } + } + + setSelectedDbConnection(connId); + setBatchDatabases([]); + setCheckedDbKeys([]); + + if (connId) { + const conn = connections.find(c => c.id === connId); + if (conn) { + await loadDatabasesForDbBatch(conn); + } + } + + setIsBatchDbModalOpen(true); + }; + + const openBatchDatabaseExportWorkbench = () => { + let connId = ''; + + if (selectedNodesRef.current.length > 0) { + const node = selectedNodesRef.current[0]; + if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { + connId = node.key as string; + } else if (node.type === 'database' || node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { + connId = node.dataRef.id; + } + } + + addTab(buildBatchDatabaseExportWorkbenchTab({ + connectionId: connId, + title: '批量导出库', + })); + }; + + const loadDatabasesForDbBatch = async (conn: SavedConnection) => { + setBatchConnContext(conn); + + 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 DBGetDatabases(buildRpcConnectionConfig(config) as any); + if (res.success) { + const dbRows: any[] = Array.isArray(res.data) ? res.data : []; + let dbs = dbRows.map((row: any) => { + const dbName = row.Database || row.database; + return { + title: dbName, + key: `${conn.id}-${dbName}`, + dbName: dbName, + dataRef: { ...conn, dbName } + }; + }); + + if (conn.includeDatabases && conn.includeDatabases.length > 0) { + dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); + } + + setBatchDatabases(dbs); + setCheckedDbKeys([]); + } else { + message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); + } + }; + + const handleDbConnectionChange = async (connId: string) => { + setSelectedDbConnection(connId); + + const conn = connections.find(c => c.id === connId); + if (conn) { + await loadDatabasesForDbBatch(conn); + } + }; + + const handleBatchDbExport = async (includeData: boolean) => { + const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key)); + if (selectedDbs.length === 0) { + message.warning(t('sidebar.message.select_database_required')); + return; + } + + setIsBatchDbModalOpen(false); + + for (const db of selectedDbs) { + const hide = message.loading( + includeData + ? t('sidebar.message.exporting_database_backup', { database: db.dbName }) + : t('sidebar.message.exporting_database_schema', { database: db.dbName }), + 0, + ); + try { + const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData); + hide(); + if (res.success) { + message.success(t('sidebar.message.database_export_success', { database: db.dbName })); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: res.message })); + break; + } else { + break; // User cancelled + } + } catch (e: any) { + hide(); + message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: e?.message || String(e) })); + break; + } + } + }; + + const handleCheckAllDb = (checked: boolean) => { + if (checked) { + setCheckedDbKeys(batchDatabases.map(db => db.key)); + } else { + setCheckedDbKeys([]); + } + }; + + const handleInvertSelectionDb = () => { + const allKeys = batchDatabases.map(db => db.key); + const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k)); + setCheckedDbKeys(newChecked); + }; + + return { + isBatchModalOpen, + setIsBatchModalOpen, + batchTables, + checkedTableKeys, + setCheckedTableKeys, + selectedConnection, + selectedDatabase, + availableDatabases, + batchFilterKeyword, + setBatchFilterKeyword, + batchFilterType, + setBatchFilterType, + batchSelectionScope, + setBatchSelectionScope, + filteredBatchObjects, + groupedBatchObjects, + selectionScopeTargetKeys, + isBatchDbModalOpen, + setIsBatchDbModalOpen, + batchDatabases, + checkedDbKeys, + setCheckedDbKeys, + selectedDbConnection, + handleExportDatabaseSQL, + handleExportSchemaSQL, + openBatchOperationModal, + openBatchTableExportWorkbench, + handleConnectionChange, + handleDatabaseChange, + handleBatchExport, + handleBatchClear, + handleCheckAll, + handleInvertSelection, + openBatchDatabaseModal, + openBatchDatabaseExportWorkbench, + handleDbConnectionChange, + handleBatchDbExport, + handleCheckAllDb, + handleInvertSelectionDb, + }; +}; From 3ff5141184d688bf85414a524ebb3097af245ebd Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 17:13:24 +0800 Subject: [PATCH 41/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E5=A4=96=E9=83=A8=20SQL=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 3 +- frontend/src/components/Sidebar.tsx | 726 ++-------------- ...idebarExternalSqlOpenFeedback.i18n.test.ts | 2 +- .../sidebar/SidebarExternalSqlWorkflow.tsx | 817 ++++++++++++++++++ 4 files changed, 871 insertions(+), 677 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 95c7055..045852d 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -67,6 +67,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), readSourceFile('./sidebar/useSidebarBatchExport.ts'), + readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); @@ -2335,7 +2336,7 @@ describe('Sidebar locate toolbar', () => { const externalSqlReadEnd = source.indexOf('const externalSQLTrees = externalSQLDirectoryResults.reduce', externalSqlReadStart); const externalSqlReadSource = source.slice(externalSqlReadStart, externalSqlReadEnd); const externalSqlFlowStart = source.indexOf('const handleAddExternalSQLDirectory = async (node: any) => {'); - const externalSqlFlowEnd = source.indexOf('const handleCreateDatabase = async () => {', externalSqlFlowStart); + const externalSqlFlowEnd = source.indexOf('const cancelSQLFileExecution = () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); const treeTitleStart = source.indexOf('const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {'); const treeTitleEnd = source.indexOf('const selectConnectionFromRail', treeTitleStart); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0c4802b..dcadb91 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -31,6 +31,19 @@ import { type BatchObjectFilterType, type BatchSelectionScope, } from './sidebar/useSidebarBatchExport'; +import { + ExternalSQLFileModal, + SQLFileExecutionModal, + useSidebarExternalSqlWorkflow, +} from './sidebar/SidebarExternalSqlWorkflow'; +export { + buildSQLFileExecutionFooter, + SQLFileExecutionProgressContent, +} from './sidebar/SidebarExternalSqlWorkflow'; +export type { + SQLFileExecutionProgressState, + SQLFileExecutionStatus, +} from './sidebar/SidebarExternalSqlWorkflow'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -61,7 +74,7 @@ export { } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, @@ -115,7 +128,7 @@ import { import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -149,7 +162,7 @@ import { } from '../utils/tableExportTab'; import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; -import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; +import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import { getCurrentLanguage, t } from '../i18n'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; @@ -158,11 +171,9 @@ import MessagePublishModal from './MessagePublishModal'; import { SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, - isExternalSQLDirectoryModalMode, normalizeMySQLViewDDLForEditing, resolveSidebarContextMenuPosition, resolveSidebarObjectDragText, - type ExternalSQLFileModalMode, type SearchScope, } from './sidebarCoreUtils'; export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; @@ -254,97 +265,6 @@ const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; // resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers -export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; - -export type SQLFileExecutionProgressState = { - fileSizeMB: string; - status: SQLFileExecutionStatus; - executed: number; - failed: number; - percent: number; - currentSQL: string; - resultMessage: string; -}; - -const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { - switch (status) { - case 'done': - return `✅ ${t('sidebar.sql_file_exec.status.done')}`; - case 'cancelled': - return `⚠️ ${t('sidebar.sql_file_exec.status.cancelled')}`; - case 'error': - return `❌ ${t('sidebar.sql_file_exec.status.error')}`; - case 'running': - default: - return t('sidebar.sql_file_exec.status.running'); - } -}; - -export const buildSQLFileExecutionFooter = ({ - status, - onCancelExecution, - onClose, -}: { - status: SQLFileExecutionStatus; - onCancelExecution: () => void; - onClose: () => void; -}): React.ReactNode[] => { - if (status === 'running') { - return [ - , - ]; - } - - return [ - , - ]; -}; - -export const SQLFileExecutionProgressContent: React.FC = ({ - fileSizeMB, - status, - executed, - failed, - percent, - currentSQL, - resultMessage, -}) => ( - <> -
- -
-
-
{t('sidebar.sql_file_exec.file_size')}{fileSizeMB} MB
-
{t('sidebar.sql_file_exec.status_label')}{resolveSQLFileExecutionStatusLabel(status)}
-
- {t('sidebar.sql_file_exec.executed_label')} - {executed} - {t('sidebar.sql_file_exec.rows_separator')} - 0 ? '#ff4d4f' : undefined }}>{failed} - {t('sidebar.sql_file_exec.rows_suffix')} -
-
- {currentSQL && status === 'running' && ( -
- {currentSQL} -
- )} - {resultMessage && status !== 'running' && ( -
- {resultMessage} -
- )} - -); - // shouldLoadSidebarNodeOnExpand 已迁移到 ./sidebar/sidebarHelpers // resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers @@ -946,10 +866,6 @@ const Sidebar: React.FC<{ const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false); const [renameSavedQueryForm] = Form.useForm(); const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState(null); - const [isExternalSQLFileModalOpen, setIsExternalSQLFileModalOpen] = useState(false); - const [externalSQLFileForm] = Form.useForm(); - const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); - const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); @@ -1306,6 +1222,36 @@ const Sidebar: React.FC<{ void refreshGlobalExternalSQLRootNode(false); }, [refreshGlobalExternalSQLRootNode]); + const { + handleRunSQLFile, + handleOpenSQLFileFromToolbar, + openExternalSQLFile, + openCreateExternalSQLFileModal, + openRenameExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleDeleteExternalSQLFile, + handleDeleteExternalSQLDirectory, + handleAddExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + handleRefreshExternalSQLDirectory, + externalSQLFileModalProps, + sqlFileExecutionModalProps, + } = useSidebarExternalSqlWorkflow({ + connections, + externalSQLDirectories, + activeTab, + connectionIds, + selectedNodesRef, + addTab, + saveExternalSQLDirectory, + deleteExternalSQLDirectory, + refreshGlobalExternalSQLRootNode, + setExpandedKeys, + setAutoExpandParent, + getActiveContext: () => useStore.getState().activeContext, + }); + const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => { if (!node) return null; if (node.type === 'database') { @@ -2649,130 +2595,6 @@ const Sidebar: React.FC<{ }, wasClosed ? 350 : 0); }; - const handleRunSQLFile = async (node: any) => { - const res = await OpenSQLFile(); - if (res.success) { - const data = normalizeSQLFileDialogData(res.data); - // 大文件:后端返回文件路径,走流式执行 - if (data.isLargeFile) { - const connId = node.type === 'connection' ? node.key : node.dataRef?.id; - const dbName = node.dataRef?.dbName || ''; - const conn = connections.find(c => c.id === connId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB || ''); - return; - } - // 小文件:加载到编辑器 - const { dbName, id } = node.dataRef; - const connectionId = node.type === 'connection' ? String(node.key) : String(id || node.dataRef.id || ''); - addTab({ - id: data.filePath ? buildExternalSQLTabId(connectionId, dbName || '', data.filePath) : `query-${Date.now()}`, - title: data.fileName || t('sidebar.sql_file_exec.title'), - type: 'query', - connectionId, - dbName: dbName, - query: data.content, - filePath: data.filePath || undefined, - }); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.read_file_failed', { error: res.message })); - } - }; - - const handleOpenSQLFileFromToolbar = async () => { - const ctx = useStore.getState().activeContext; - if (!ctx?.connectionId) { - message.warning(t('sidebar.message.select_connection_or_database_first')); - return; - } - const res = await OpenSQLFile(); - if (res.success) { - const data = normalizeSQLFileDialogData(res.data); - // 大文件:后端流式执行 - if (data.isLargeFile) { - const conn = connections.find(c => c.id === ctx.connectionId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB || ''); - return; - } - // 小文件 - addTab({ - id: data.filePath ? buildExternalSQLTabId(ctx.connectionId, ctx.dbName || '', data.filePath) : `query-${Date.now()}`, - title: data.fileName || t('sidebar.sql_file_exec.title'), - type: 'query', - connectionId: ctx.connectionId, - dbName: ctx.dbName || undefined, - query: data.content, - filePath: data.filePath || undefined, - }); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.read_file_failed', { error: res.message })); - } - }; - - // SQL 文件流式执行状态 - const [sqlFileExecState, setSqlFileExecState] = useState<{ - open: boolean; - jobId: string; - fileSizeMB: string; - status: SQLFileExecutionStatus; - executed: number; - failed: number; - total: number; - percent: number; - currentSQL: string; - resultMessage: string; - }>({ - open: false, jobId: '', fileSizeMB: '', status: 'running', - executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: '' - }); - - const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => { - const jobId = `sqlfile-${Date.now()}`; - setSqlFileExecState({ - open: true, jobId, fileSizeMB, status: 'running', - executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: '' - }); - - // 监听进度事件 - const offProgress = EventsOn('sqlfile:progress', (event: any) => { - if (!event || event.jobId !== jobId) return; - setSqlFileExecState(prev => ({ - ...prev, - status: event.status || prev.status, - executed: typeof event.executed === 'number' ? event.executed : prev.executed, - failed: typeof event.failed === 'number' ? event.failed : prev.failed, - total: typeof event.total === 'number' ? event.total : prev.total, - percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent, - currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL, - })); - }); - - // 异步执行 - ExecuteSQLFile(config, dbName, filePath, jobId).then(res => { - offProgress(); - setSqlFileExecState(prev => ({ - ...prev, - status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'), - percent: 100, - resultMessage: res.message || '', - })); - }).catch(err => { - offProgress(); - setSqlFileExecState(prev => ({ - ...prev, - status: 'error', - resultMessage: String(err?.message || err), - })); - }); - }; - const refreshDatabaseNode = async (dbNodeKey: string) => { if (!dbNodeKey) { return; @@ -2783,384 +2605,6 @@ const Sidebar: React.FC<{ } }; - const normalizeExternalSQLFileName = (rawName: unknown): string => { - const name = String(rawName || '').trim(); - if (!name) return ''; - return /\.sql$/i.test(name) ? name : `${name}.sql`; - }; - - const normalizeExternalSQLDirectoryName = (rawName: unknown): string => { - return String(rawName || '').trim(); - }; - - const getExternalSQLParentDirectoryPath = (node: any): string => { - const path = String(node?.dataRef?.path || '').trim(); - if (node?.type === 'external-sql-directory' || node?.type === 'external-sql-folder') { - return path; - } - if (node?.type === 'external-sql-file') { - const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); - return index > 0 ? path.slice(0, index) : ''; - } - return ''; - }; - - const resolveExternalSQLExecutionContext = (): { connectionId: string; dbName: string } => { - const activeStoreContext = useStore.getState().activeContext; - const selectedConnectionId = selectedNodesRef.current - .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) - .find(Boolean) || ''; - return { - connectionId: String( - activeStoreContext?.connectionId - || activeTab?.connectionId - || selectedConnectionId - || '', - ).trim(), - dbName: String( - activeStoreContext?.dbName - || activeTab?.dbName - || '', - ).trim(), - }; - }; - - const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: string; fileName: string; isLargeFile: boolean; fileSizeMB?: string } => { - if (data && typeof data === 'object') { - const payload = data as Record; - const filePath = String(payload.filePath || '').trim(); - return { - content: String(payload.content ?? ''), - filePath, - fileName: String(payload.name || filePath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_file_exec.title')).trim(), - isLargeFile: payload.isLargeFile === true, - fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, - }; - } - return { - content: String(data || ''), - filePath: '', - fileName: t('sidebar.sql_file_exec.title'), - isLargeFile: false, - }; - }; - - const openExternalSQLFile = async (fileNode: any) => { - const fileContext = { - connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), - dbName: String(fileNode?.dataRef?.dbName || '').trim(), - }; - const fallbackContext = resolveExternalSQLExecutionContext(); - const connectionId = fileContext.connectionId || fallbackContext.connectionId; - const dbName = fileContext.dbName || fallbackContext.dbName; - const filePath = String(fileNode?.dataRef?.path || '').trim(); - const fileName = String(fileNode?.dataRef?.name || fileNode?.title || t('sidebar.sql_file.default_name')).trim() || t('sidebar.sql_file.default_name'); - if (!filePath) { - message.error(t('sidebar.message.sql_file_path_incomplete')); - return; - } - - const res = await ReadSQLFile(filePath); - if (!res.success) { - if (res.message !== '已取消') { - message.error(t('sidebar.message.read_sql_file_failed', { error: res.message })); - } - return; - } - - const data = res.data; - if (data && typeof data === 'object' && data.isLargeFile) { - if (!connectionId) { - message.warning(t('sidebar.message.select_host_before_large_sql_file')); - return; - } - const conn = connections.find((item) => item.id === connectionId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); - return; - } - - addTab({ - id: buildExternalSQLTabId(connectionId, dbName, filePath), - title: fileName, - type: 'query', - connectionId, - dbName: dbName || undefined, - query: String(data || ''), - filePath, - }); - }; - - const openCreateExternalSQLFileModal = (node: any) => { - const directoryPath = getExternalSQLParentDirectoryPath(node); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_file_parent_missing')); - return; - } - setExternalSQLFileModalMode('create'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: 'new-query.sql' }); - setIsExternalSQLFileModalOpen(true); - }; - - const openRenameExternalSQLFileModal = (node: any) => { - const currentName = String(node?.dataRef?.name || node?.title || '').trim(); - if (!currentName) { - message.error(t('sidebar.message.external_sql_file_rename_target_missing')); - return; - } - setExternalSQLFileModalMode('rename'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: currentName }); - setIsExternalSQLFileModalOpen(true); - }; - - const openCreateExternalSQLDirectoryModal = (node: any) => { - const directoryPath = getExternalSQLParentDirectoryPath(node); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_parent_missing')); - return; - } - setExternalSQLFileModalMode('create-directory'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: 'new-folder' }); - setIsExternalSQLFileModalOpen(true); - }; - - const openRenameExternalSQLDirectoryModal = (node: any) => { - const currentName = String(node?.dataRef?.name || node?.title || '').trim(); - if (!currentName) { - message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); - return; - } - setExternalSQLFileModalMode('rename-directory'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: currentName }); - setIsExternalSQLFileModalOpen(true); - }; - - const handleExternalSQLFileModalOk = async () => { - try { - const values = await externalSQLFileForm.validateFields(); - const isDirectoryMode = isExternalSQLDirectoryModalMode(externalSQLFileModalMode); - const name = isDirectoryMode - ? normalizeExternalSQLDirectoryName(values.name) - : normalizeExternalSQLFileName(values.name); - if (!name) { - message.error(t(isDirectoryMode ? 'sidebar.message.sql_directory_name_required' : 'sidebar.message.sql_file_name_required')); - return; - } - - if (externalSQLFileModalMode === 'create') { - const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_file_parent_missing')); - return; - } - const res = await CreateSQLFile(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.create_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_created')); - } else if (externalSQLFileModalMode === 'rename') { - const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); - if (!filePath) { - message.error(t('sidebar.message.external_sql_file_rename_target_missing')); - return; - } - const res = await RenameSQLFile(filePath, name); - if (!res.success) { - message.error(t('sidebar.message.rename_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_renamed')); - } else if (externalSQLFileModalMode === 'create-directory') { - const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_parent_missing')); - return; - } - const res = await CreateSQLDirectory(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.create_sql_directory_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_directory_created')); - } else { - const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); - return; - } - const res = await RenameSQLDirectory(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.rename_sql_directory_failed', { error: res.message })); - return; - } - - if (externalSQLFileTarget?.type === 'external-sql-directory') { - const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; - const nextPath = String(payload.directoryPath || payload.path || '').trim(); - const nextName = String(payload.name || name).trim(); - const oldDirectoryId = String(externalSQLFileTarget?.dataRef?.id || '').trim(); - if (!nextPath || !oldDirectoryId) { - message.error(t('sidebar.message.external_sql_directory_rename_sync_failed')); - await refreshGlobalExternalSQLRootNode(false); - return; - } - const nextDirectory: ExternalSQLDirectory = { - id: buildExternalSQLDirectoryId('', '', nextPath), - name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), - path: nextPath, - createdAt: Number(externalSQLFileTarget?.dataRef?.createdAt) || Date.now(), - }; - deleteExternalSQLDirectory(oldDirectoryId); - saveExternalSQLDirectory(nextDirectory); - const nextDirectories = [ - ...externalSQLDirectories.filter((item) => item.id !== oldDirectoryId), - nextDirectory, - ]; - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - } else { - await refreshGlobalExternalSQLRootNode(false); - } - message.success(t('sidebar.message.sql_directory_renamed')); - } - - setIsExternalSQLFileModalOpen(false); - setExternalSQLFileTarget(null); - externalSQLFileForm.resetFields(); - } catch { - // Validate failed - } - }; - - const handleDeleteExternalSQLFile = (node: any) => { - const filePath = String(node?.dataRef?.path || '').trim(); - const fileName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_file.default_name')).trim(); - if (!filePath) { - message.error(t('sidebar.message.external_sql_file_delete_target_missing')); - return; - } - - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_sql_file.title'), - content: t('sidebar.modal.confirm_delete_sql_file.content', { name: fileName }), - okText: t('sidebar.action.delete'), - cancelText: t('sidebar.action.cancel'), - okButtonProps: { danger: true }, - onOk: async () => { - const res = await DeleteSQLFile(filePath); - if (!res.success) { - message.error(t('sidebar.message.delete_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_deleted')); - }, - }); - }; - - const handleDeleteExternalSQLDirectory = (node: any) => { - const directoryPath = String(node?.dataRef?.path || '').trim(); - const directoryName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_directory.default_name')).trim(); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_delete_target_missing')); - return; - } - - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_sql_directory.title'), - content: t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName }), - okText: t('sidebar.action.delete'), - cancelText: t('sidebar.action.cancel'), - okButtonProps: { danger: true }, - onOk: async () => { - const res = await DeleteSQLDirectory(directoryPath); - if (!res.success) { - message.error(t('sidebar.message.delete_sql_directory_failed', { error: res.message })); - return; - } - - if (node?.type === 'external-sql-directory') { - const directoryId = String(node?.dataRef?.id || '').trim(); - if (directoryId) { - deleteExternalSQLDirectory(directoryId); - const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - } else { - await refreshGlobalExternalSQLRootNode(false); - } - } else { - await refreshGlobalExternalSQLRootNode(false); - } - message.success(t('sidebar.message.sql_directory_deleted')); - }, - }); - }; - - const handleAddExternalSQLDirectory = async (node: any) => { - const currentDirectory = externalSQLDirectories[0]?.path || ''; - const selection = await SelectSQLDirectory(currentDirectory); - if (!selection.success) { - if (selection.message !== '已取消') { - message.error(t('sidebar.message.select_sql_directory_failed', { error: selection.message })); - } - return; - } - - const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {}; - const path = String(payload.path || '').trim(); - const name = String(payload.name || '').trim(); - if (!path) { - message.error(t('sidebar.message.sql_directory_path_invalid')); - return; - } - - const directoryId = buildExternalSQLDirectoryId('', '', path); - const nextDirectory: ExternalSQLDirectory = { - id: directoryId, - name: name || path.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), - path, - createdAt: Date.now(), - }; - saveExternalSQLDirectory(nextDirectory); - - const nextDirectories = [ - ...externalSQLDirectories.filter((item) => item.path.replace(/\\/g, '/').toLowerCase() !== path.replace(/\\/g, '/').toLowerCase()), - nextDirectory, - ]; - setExpandedKeys((prev) => Array.from(new Set([...prev, 'external-sql-root']))); - setAutoExpandParent(false); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - message.success(t('sidebar.message.external_sql_directory_added')); - }; - - const handleRemoveExternalSQLDirectory = async (node: any) => { - const directoryId = String(node?.dataRef?.id || '').trim(); - if (!directoryId) { - message.error(t('sidebar.message.external_sql_directory_not_found')); - return; - } - deleteExternalSQLDirectory(directoryId); - const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - message.success(t('sidebar.message.external_sql_directory_removed')); - }; - - const handleRefreshExternalSQLDirectory = async (node: any) => { - void node; - await refreshGlobalExternalSQLRootNode(true); - message.success(t('sidebar.message.external_sql_directory_refreshed')); - }; - const handleCreateDatabase = async () => { try { const values = await createDbForm.validateFields(); @@ -6733,48 +6177,7 @@ const Sidebar: React.FC<{ - { - setIsExternalSQLFileModalOpen(false); - setExternalSQLFileTarget(null); - externalSQLFileForm.resetFields(); - }} - okText={t(externalSQLFileModalMode === 'create' || externalSQLFileModalMode === 'create-directory' ? 'sidebar.external_sql_modal.action.create' : 'sidebar.external_sql_modal.action.rename')} - cancelText={t('common.cancel')} - > -
- { - const name = String(value || '').trim(); - if (!name) return; - if (/[\\/]/.test(name) || name === '.' || name === '..') { - throw new Error(isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.validation.directory_name_no_separator') : t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')); - } - }, - }, - ]} - extra={isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.help.directory') : t('sidebar.external_sql_modal.help.sql_file')} - > - - -
-
+ , "批量操作表", "按对象批量导出结构、数据或完整备份。")} @@ -7072,38 +6475,11 @@ const Sidebar: React.FC<{ )} - {/* SQL 文件流式执行进度 Modal */} - { - CancelSQLFileExecution(sqlFileExecState.jobId); - setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' })); - }, - onClose: () => setSqlFileExecState(prev => ({ ...prev, open: false })), - })} - onCancel={() => { - if (sqlFileExecState.status !== 'running') { - setSqlFileExecState(prev => ({ ...prev, open: false })); - } - }} - styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} - > - - + modalPanelStyle={modalPanelStyle} + {...sqlFileExecutionModalProps} + /> setFindInDbContext({ open: false, connectionId: '', dbName: '' })} diff --git a/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts b/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts index 0a597d4..38e285d 100644 --- a/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/SidebarExternalSqlWorkflow.tsx', import.meta.url), 'utf8'); const externalSqlOpenBlock = source.slice( source.indexOf('const normalizeSQLFileDialogData ='), diff --git a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx new file mode 100644 index 0000000..9c45a6e --- /dev/null +++ b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx @@ -0,0 +1,817 @@ +import React, { useState } from 'react'; +import { Button, Form, Input, Progress, message } from 'antd'; +import type { FormInstance } from 'antd/es/form'; +import Modal from '../common/ResizableDraggableModal'; +import type { SavedConnection, ExternalSQLDirectory } from '../../types'; +import { noAutoCapInputProps } from '../../utils/inputAutoCap'; +import { buildExternalSQLDirectoryId, buildExternalSQLTabId } from '../../utils/externalSqlTree'; +import { t } from '../../i18n'; +import { resolveSidebarNodeConnectionId } from '../sidebarV2Utils'; +import { + isExternalSQLDirectoryModalMode, + type ExternalSQLFileModalMode, +} from '../sidebarCoreUtils'; +import { + OpenSQLFile, + ExecuteSQLFile, + CancelSQLFileExecution, + SelectSQLDirectory, + ReadSQLFile, + CreateSQLFile, + CreateSQLDirectory, + DeleteSQLFile, + DeleteSQLDirectory, + RenameSQLFile, + RenameSQLDirectory, +} from '../../../wailsjs/go/app/App'; +import { EventsOn } from '../../../wailsjs/runtime/runtime'; + +export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; + +export type SQLFileExecutionProgressState = { + fileSizeMB: string; + status: SQLFileExecutionStatus; + executed: number; + failed: number; + percent: number; + currentSQL: string; + resultMessage: string; +}; + +type SQLFileExecutionState = SQLFileExecutionProgressState & { + open: boolean; + jobId: string; + total: number; +}; + +type ActiveExecutionContext = { + connectionId?: string; + dbName?: string; +} | null | undefined; + +type RefreshExternalSQLRootNode = ( + showLoading?: boolean, + directoriesOverride?: ExternalSQLDirectory[], +) => Promise; + +type UseSidebarExternalSqlWorkflowOptions = { + connections: SavedConnection[]; + externalSQLDirectories: ExternalSQLDirectory[]; + activeTab: { + connectionId?: string; + dbName?: string; + } | null; + connectionIds: string[]; + selectedNodesRef: React.MutableRefObject; + addTab: (tab: any) => void; + saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void; + deleteExternalSQLDirectory: (directoryId: string) => void; + refreshGlobalExternalSQLRootNode: RefreshExternalSQLRootNode; + setExpandedKeys: React.Dispatch>; + setAutoExpandParent: React.Dispatch>; + getActiveContext: () => ActiveExecutionContext; +}; + +type ExternalSQLFileModalProps = { + open: boolean; + mode: ExternalSQLFileModalMode; + form: FormInstance; + onOk: () => void; + onCancel: () => void; +}; + +type SQLFileExecutionModalProps = { + title: React.ReactNode; + state: SQLFileExecutionState; + modalPanelStyle: React.CSSProperties; + onCancelExecution: () => void; + onClose: () => void; +}; + +const createInitialSQLFileExecutionState = (): SQLFileExecutionState => ({ + open: false, + jobId: '', + fileSizeMB: '', + status: 'running', + executed: 0, + failed: 0, + total: 0, + percent: 0, + currentSQL: '', + resultMessage: '', +}); + +const normalizeExternalSQLFileName = (rawName: unknown): string => { + const name = String(rawName || '').trim(); + if (!name) return ''; + return /\.sql$/i.test(name) ? name : `${name}.sql`; +}; + +const normalizeExternalSQLDirectoryName = (rawName: unknown): string => { + return String(rawName || '').trim(); +}; + +const getExternalSQLParentDirectoryPath = (node: any): string => { + const path = String(node?.dataRef?.path || '').trim(); + if (node?.type === 'external-sql-directory' || node?.type === 'external-sql-folder') { + return path; + } + if (node?.type === 'external-sql-file') { + const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return index > 0 ? path.slice(0, index) : ''; + } + return ''; +}; + +const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: string; fileName: string; isLargeFile: boolean; fileSizeMB?: string } => { + if (data && typeof data === 'object') { + const payload = data as Record; + const filePath = String(payload.filePath || '').trim(); + return { + content: String(payload.content ?? ''), + filePath, + fileName: String(payload.name || filePath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_file_exec.title')).trim(), + isLargeFile: payload.isLargeFile === true, + fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, + }; + } + return { + content: String(data || ''), + filePath: '', + fileName: t('sidebar.sql_file_exec.title'), + isLargeFile: false, + }; +}; + +const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { + switch (status) { + case 'done': + return `✅ ${t('sidebar.sql_file_exec.status.done')}`; + case 'cancelled': + return `⚠️ ${t('sidebar.sql_file_exec.status.cancelled')}`; + case 'error': + return `❌ ${t('sidebar.sql_file_exec.status.error')}`; + case 'running': + default: + return t('sidebar.sql_file_exec.status.running'); + } +}; + +export const buildSQLFileExecutionFooter = ({ + status, + onCancelExecution, + onClose, +}: { + status: SQLFileExecutionStatus; + onCancelExecution: () => void; + onClose: () => void; +}): React.ReactNode[] => { + if (status === 'running') { + return [ + , + ]; + } + + return [ + , + ]; +}; + +export const SQLFileExecutionProgressContent: React.FC = ({ + fileSizeMB, + status, + executed, + failed, + percent, + currentSQL, + resultMessage, +}) => ( + <> +
+ +
+
+
{t('sidebar.sql_file_exec.file_size')}{fileSizeMB} MB
+
{t('sidebar.sql_file_exec.status_label')}{resolveSQLFileExecutionStatusLabel(status)}
+
+ {t('sidebar.sql_file_exec.executed_label')} + {executed} + {t('sidebar.sql_file_exec.rows_separator')} + 0 ? '#ff4d4f' : undefined }}>{failed} + {t('sidebar.sql_file_exec.rows_suffix')} +
+
+ {currentSQL && status === 'running' && ( +
+ {currentSQL} +
+ )} + {resultMessage && status !== 'running' && ( +
+ {resultMessage} +
+ )} + +); + +export const ExternalSQLFileModal: React.FC = ({ + open, + mode, + form, + onOk, + onCancel, +}) => ( + +
+ { + const name = String(value || '').trim(); + if (!name) return; + if (/[\\/]/.test(name) || name === '.' || name === '..') { + throw new Error(isExternalSQLDirectoryModalMode(mode) ? t('sidebar.external_sql_modal.validation.directory_name_no_separator') : t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')); + } + }, + }, + ]} + extra={isExternalSQLDirectoryModalMode(mode) ? t('sidebar.external_sql_modal.help.directory') : t('sidebar.external_sql_modal.help.sql_file')} + > + + +
+
+); + +export const SQLFileExecutionModal: React.FC = ({ + title, + state, + modalPanelStyle, + onCancelExecution, + onClose, +}) => ( + { + if (state.status !== 'running') { + onClose(); + } + }} + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} + > + + +); + +export const useSidebarExternalSqlWorkflow = ({ + connections, + externalSQLDirectories, + activeTab, + connectionIds, + selectedNodesRef, + addTab, + saveExternalSQLDirectory, + deleteExternalSQLDirectory, + refreshGlobalExternalSQLRootNode, + setExpandedKeys, + setAutoExpandParent, + getActiveContext, +}: UseSidebarExternalSqlWorkflowOptions) => { + const [isExternalSQLFileModalOpen, setIsExternalSQLFileModalOpen] = useState(false); + const [externalSQLFileForm] = Form.useForm(); + const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); + const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); + const [sqlFileExecState, setSqlFileExecState] = useState(createInitialSQLFileExecutionState); + + const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => { + const jobId = `sqlfile-${Date.now()}`; + setSqlFileExecState({ + open: true, + jobId, + fileSizeMB, + status: 'running', + executed: 0, + failed: 0, + total: 0, + percent: 0, + currentSQL: '', + resultMessage: '', + }); + + const offProgress = EventsOn('sqlfile:progress', (event: any) => { + if (!event || event.jobId !== jobId) return; + setSqlFileExecState(prev => ({ + ...prev, + status: event.status || prev.status, + executed: typeof event.executed === 'number' ? event.executed : prev.executed, + failed: typeof event.failed === 'number' ? event.failed : prev.failed, + total: typeof event.total === 'number' ? event.total : prev.total, + percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent, + currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL, + })); + }); + + ExecuteSQLFile(config, dbName, filePath, jobId).then(res => { + offProgress(); + setSqlFileExecState(prev => ({ + ...prev, + status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'), + percent: 100, + resultMessage: res.message || '', + })); + }).catch(err => { + offProgress(); + setSqlFileExecState(prev => ({ + ...prev, + status: 'error', + resultMessage: String(err?.message || err), + })); + }); + }; + + const handleRunSQLFile = async (node: any) => { + const res = await OpenSQLFile(); + if (res.success) { + const data = normalizeSQLFileDialogData(res.data); + if (data.isLargeFile) { + const connId = node.type === 'connection' ? node.key : node.dataRef?.id; + const dbName = node.dataRef?.dbName || ''; + const conn = connections.find(c => c.id === connId); + if (!conn) { + message.error(t('sidebar.message.connection_config_not_found')); + return; + } + startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB || ''); + return; + } + + const { dbName, id } = node.dataRef; + const connectionId = node.type === 'connection' ? String(node.key) : String(id || node.dataRef.id || ''); + addTab({ + id: data.filePath ? buildExternalSQLTabId(connectionId, dbName || '', data.filePath) : `query-${Date.now()}`, + title: data.fileName || t('sidebar.sql_file_exec.title'), + type: 'query', + connectionId, + dbName: dbName, + query: data.content, + filePath: data.filePath || undefined, + }); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.read_file_failed', { error: res.message })); + } + }; + + const handleOpenSQLFileFromToolbar = async () => { + const ctx = getActiveContext(); + if (!ctx?.connectionId) { + message.warning(t('sidebar.message.select_connection_or_database_first')); + return; + } + const res = await OpenSQLFile(); + if (res.success) { + const data = normalizeSQLFileDialogData(res.data); + if (data.isLargeFile) { + const conn = connections.find(c => c.id === ctx.connectionId); + if (!conn) { + message.error(t('sidebar.message.connection_config_not_found')); + return; + } + startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB || ''); + return; + } + + addTab({ + id: data.filePath ? buildExternalSQLTabId(ctx.connectionId, ctx.dbName || '', data.filePath) : `query-${Date.now()}`, + title: data.fileName || t('sidebar.sql_file_exec.title'), + type: 'query', + connectionId: ctx.connectionId, + dbName: ctx.dbName || undefined, + query: data.content, + filePath: data.filePath || undefined, + }); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.read_file_failed', { error: res.message })); + } + }; + + const resolveExternalSQLExecutionContext = (): { connectionId: string; dbName: string } => { + const activeStoreContext = getActiveContext(); + const selectedConnectionId = selectedNodesRef.current + .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) + .find(Boolean) || ''; + return { + connectionId: String( + activeStoreContext?.connectionId + || activeTab?.connectionId + || selectedConnectionId + || '', + ).trim(), + dbName: String( + activeStoreContext?.dbName + || activeTab?.dbName + || '', + ).trim(), + }; + }; + + const openExternalSQLFile = async (fileNode: any) => { + const fileContext = { + connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), + dbName: String(fileNode?.dataRef?.dbName || '').trim(), + }; + const fallbackContext = resolveExternalSQLExecutionContext(); + const connectionId = fileContext.connectionId || fallbackContext.connectionId; + const dbName = fileContext.dbName || fallbackContext.dbName; + const filePath = String(fileNode?.dataRef?.path || '').trim(); + const fileName = String(fileNode?.dataRef?.name || fileNode?.title || t('sidebar.sql_file.default_name')).trim() || t('sidebar.sql_file.default_name'); + if (!filePath) { + message.error(t('sidebar.message.sql_file_path_incomplete')); + return; + } + + const res = await ReadSQLFile(filePath); + if (!res.success) { + if (res.message !== '已取消') { + message.error(t('sidebar.message.read_sql_file_failed', { error: res.message })); + } + return; + } + + const data = res.data; + if (data && typeof data === 'object' && data.isLargeFile) { + if (!connectionId) { + message.warning(t('sidebar.message.select_host_before_large_sql_file')); + return; + } + const conn = connections.find((item) => item.id === connectionId); + if (!conn) { + message.error(t('sidebar.message.connection_config_not_found')); + return; + } + startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); + return; + } + + addTab({ + id: buildExternalSQLTabId(connectionId, dbName, filePath), + title: fileName, + type: 'query', + connectionId, + dbName: dbName || undefined, + query: String(data || ''), + filePath, + }); + }; + + const openCreateExternalSQLFileModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_file_parent_missing')); + return; + } + setExternalSQLFileModalMode('create'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: 'new-query.sql' }); + setIsExternalSQLFileModalOpen(true); + }; + + const openRenameExternalSQLFileModal = (node: any) => { + const currentName = String(node?.dataRef?.name || node?.title || '').trim(); + if (!currentName) { + message.error(t('sidebar.message.external_sql_file_rename_target_missing')); + return; + } + setExternalSQLFileModalMode('rename'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: currentName }); + setIsExternalSQLFileModalOpen(true); + }; + + const openCreateExternalSQLDirectoryModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_parent_missing')); + return; + } + setExternalSQLFileModalMode('create-directory'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: 'new-folder' }); + setIsExternalSQLFileModalOpen(true); + }; + + const openRenameExternalSQLDirectoryModal = (node: any) => { + const currentName = String(node?.dataRef?.name || node?.title || '').trim(); + if (!currentName) { + message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); + return; + } + setExternalSQLFileModalMode('rename-directory'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: currentName }); + setIsExternalSQLFileModalOpen(true); + }; + + const closeExternalSQLFileModal = () => { + setIsExternalSQLFileModalOpen(false); + setExternalSQLFileTarget(null); + externalSQLFileForm.resetFields(); + }; + + const handleExternalSQLFileModalOk = async () => { + try { + const values = await externalSQLFileForm.validateFields(); + const isDirectoryMode = isExternalSQLDirectoryModalMode(externalSQLFileModalMode); + const name = isDirectoryMode + ? normalizeExternalSQLDirectoryName(values.name) + : normalizeExternalSQLFileName(values.name); + if (!name) { + message.error(t(isDirectoryMode ? 'sidebar.message.sql_directory_name_required' : 'sidebar.message.sql_file_name_required')); + return; + } + + if (externalSQLFileModalMode === 'create') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_file_parent_missing')); + return; + } + const res = await CreateSQLFile(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.create_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_created')); + } else if (externalSQLFileModalMode === 'rename') { + const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!filePath) { + message.error(t('sidebar.message.external_sql_file_rename_target_missing')); + return; + } + const res = await RenameSQLFile(filePath, name); + if (!res.success) { + message.error(t('sidebar.message.rename_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_renamed')); + } else if (externalSQLFileModalMode === 'create-directory') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_parent_missing')); + return; + } + const res = await CreateSQLDirectory(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.create_sql_directory_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_directory_created')); + } else { + const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); + return; + } + const res = await RenameSQLDirectory(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.rename_sql_directory_failed', { error: res.message })); + return; + } + + if (externalSQLFileTarget?.type === 'external-sql-directory') { + const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; + const nextPath = String(payload.directoryPath || payload.path || '').trim(); + const nextName = String(payload.name || name).trim(); + const oldDirectoryId = String(externalSQLFileTarget?.dataRef?.id || '').trim(); + if (!nextPath || !oldDirectoryId) { + message.error(t('sidebar.message.external_sql_directory_rename_sync_failed')); + await refreshGlobalExternalSQLRootNode(false); + return; + } + const nextDirectory: ExternalSQLDirectory = { + id: buildExternalSQLDirectoryId('', '', nextPath), + name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), + path: nextPath, + createdAt: Number(externalSQLFileTarget?.dataRef?.createdAt) || Date.now(), + }; + deleteExternalSQLDirectory(oldDirectoryId); + saveExternalSQLDirectory(nextDirectory); + const nextDirectories = [ + ...externalSQLDirectories.filter((item) => item.id !== oldDirectoryId), + nextDirectory, + ]; + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + } else { + await refreshGlobalExternalSQLRootNode(false); + } + message.success(t('sidebar.message.sql_directory_renamed')); + } + + closeExternalSQLFileModal(); + } catch { + // Validate failed + } + }; + + const handleDeleteExternalSQLFile = (node: any) => { + const filePath = String(node?.dataRef?.path || '').trim(); + const fileName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_file.default_name')).trim(); + if (!filePath) { + message.error(t('sidebar.message.external_sql_file_delete_target_missing')); + return; + } + + Modal.confirm({ + title: t('sidebar.modal.confirm_delete_sql_file.title'), + content: t('sidebar.modal.confirm_delete_sql_file.content', { name: fileName }), + okText: t('sidebar.action.delete'), + cancelText: t('sidebar.action.cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLFile(filePath); + if (!res.success) { + message.error(t('sidebar.message.delete_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_deleted')); + }, + }); + }; + + const handleDeleteExternalSQLDirectory = (node: any) => { + const directoryPath = String(node?.dataRef?.path || '').trim(); + const directoryName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_directory.default_name')).trim(); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_delete_target_missing')); + return; + } + + Modal.confirm({ + title: t('sidebar.modal.confirm_delete_sql_directory.title'), + content: t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName }), + okText: t('sidebar.action.delete'), + cancelText: t('sidebar.action.cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLDirectory(directoryPath); + if (!res.success) { + message.error(t('sidebar.message.delete_sql_directory_failed', { error: res.message })); + return; + } + + if (node?.type === 'external-sql-directory') { + const directoryId = String(node?.dataRef?.id || '').trim(); + if (directoryId) { + deleteExternalSQLDirectory(directoryId); + const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + } else { + await refreshGlobalExternalSQLRootNode(false); + } + } else { + await refreshGlobalExternalSQLRootNode(false); + } + message.success(t('sidebar.message.sql_directory_deleted')); + }, + }); + }; + + const handleAddExternalSQLDirectory = async (node: any) => { + void node; + const currentDirectory = externalSQLDirectories[0]?.path || ''; + const selection = await SelectSQLDirectory(currentDirectory); + if (!selection.success) { + if (selection.message !== '已取消') { + message.error(t('sidebar.message.select_sql_directory_failed', { error: selection.message })); + } + return; + } + + const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {}; + const path = String(payload.path || '').trim(); + const name = String(payload.name || '').trim(); + if (!path) { + message.error(t('sidebar.message.sql_directory_path_invalid')); + return; + } + + const directoryId = buildExternalSQLDirectoryId('', '', path); + const nextDirectory: ExternalSQLDirectory = { + id: directoryId, + name: name || path.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), + path, + createdAt: Date.now(), + }; + saveExternalSQLDirectory(nextDirectory); + + const nextDirectories = [ + ...externalSQLDirectories.filter((item) => item.path.replace(/\\/g, '/').toLowerCase() !== path.replace(/\\/g, '/').toLowerCase()), + nextDirectory, + ]; + setExpandedKeys((prev) => Array.from(new Set([...prev, 'external-sql-root']))); + setAutoExpandParent(false); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + message.success(t('sidebar.message.external_sql_directory_added')); + }; + + const handleRemoveExternalSQLDirectory = async (node: any) => { + const directoryId = String(node?.dataRef?.id || '').trim(); + if (!directoryId) { + message.error(t('sidebar.message.external_sql_directory_not_found')); + return; + } + deleteExternalSQLDirectory(directoryId); + const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + message.success(t('sidebar.message.external_sql_directory_removed')); + }; + + const handleRefreshExternalSQLDirectory = async (node: any) => { + void node; + await refreshGlobalExternalSQLRootNode(true); + message.success(t('sidebar.message.external_sql_directory_refreshed')); + }; + + const cancelSQLFileExecution = () => { + CancelSQLFileExecution(sqlFileExecState.jobId); + setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' })); + }; + + const closeSQLFileExecutionModal = () => { + setSqlFileExecState(prev => ({ ...prev, open: false })); + }; + + return { + handleRunSQLFile, + handleOpenSQLFileFromToolbar, + openExternalSQLFile, + openCreateExternalSQLFileModal, + openRenameExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleExternalSQLFileModalOk, + handleDeleteExternalSQLFile, + handleDeleteExternalSQLDirectory, + handleAddExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + handleRefreshExternalSQLDirectory, + externalSQLFileModalProps: { + open: isExternalSQLFileModalOpen, + mode: externalSQLFileModalMode, + form: externalSQLFileForm, + onOk: handleExternalSQLFileModalOk, + onCancel: closeExternalSQLFileModal, + }, + sqlFileExecutionModalProps: { + state: sqlFileExecState, + onCancelExecution: cancelSQLFileExecution, + onClose: closeSQLFileExecutionModal, + }, + }; +}; From 39e52469f2615be338a6f684d94d1cca9a4c76bf Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 17:23:53 +0800 Subject: [PATCH 42/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Sidebar.tsx | 341 ++------------ .../sidebar/SidebarBatchExportModals.tsx | 415 ++++++++++++++++++ 2 files changed, 459 insertions(+), 297 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarBatchExportModals.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index dcadb91..81548a7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -28,9 +28,8 @@ import { } from './sidebar/sidebarMetadataLoaders'; import { useSidebarBatchExport, - type BatchObjectFilterType, - type BatchSelectionScope, } from './sidebar/useSidebarBatchExport'; +import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; import { ExternalSQLFileModal, SQLFileExecutionModal, @@ -6179,301 +6178,49 @@ const Sidebar: React.FC<{ - , "批量操作表", "按对象批量导出结构、数据或完整备份。")} - open={isBatchModalOpen} - onCancel={() => setIsBatchModalOpen(false)} - width={720} - centered - styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} - footer={ -
- - - - - - - -
- } - > -
-
- - -
-
- - -
-
先选择连接与数据库,再决定导出范围和目标对象。
-
- - {batchTables.length > 0 && ( -
- - setBatchFilterKeyword(e.target.value)} - placeholder="筛选表/视图名称" - prefix={} - style={{ width: 260 }} - /> - setBatchSelectionScope(value as BatchSelectionScope)} - style={{ width: 220 }} - options={[ - { label: '勾选作用于:当前筛选结果', value: 'filtered' }, - { label: '勾选作用于:全部对象', value: 'all' }, - ]} - /> - -
- 当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象 -
-
- )} - - {batchTables.length > 0 && ( - <> -
- - - - - - 已选择 {checkedTableKeys.length} / {batchTables.length} 个对象 - - -
-
- setCheckedTableKeys(values as string[])} - style={{ width: '100%' }} - > -
- {groupedBatchObjects.tables.length > 0 && ( -
-
- 表 ({groupedBatchObjects.tables.length}) -
- - {groupedBatchObjects.tables.map(table => ( - - - {table.title} - - ))} - -
- )} - {groupedBatchObjects.views.length > 0 && ( -
-
- 视图 ({groupedBatchObjects.views.length}) -
- - {groupedBatchObjects.views.map(view => ( - - - {view.title} - - ))} - -
- )} - {groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && ( -
- 无匹配对象 -
- )} -
-
-
- - )} -
- - , "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")} - open={isBatchDbModalOpen} - onCancel={() => setIsBatchDbModalOpen(false)} - width={640} - centered - styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} - footer={[ - , - , - - ]} - > -
- - -
连接选定后会加载当前连接下可批量导出的数据库列表。
-
- - {batchDatabases.length > 0 && ( - <> -
- - - - - - 已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库 - - -
-
- setCheckedDbKeys(values as string[])} - style={{ width: '100%' }} - > - - {batchDatabases.map(db => ( - - - {db.title} - - ))} - - -
- - )} -
+ , "批量操作表", "按对象批量导出结构、数据或完整备份。")} + databaseModalTitle={renderSidebarModalTitle(, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")} + isBatchModalOpen={isBatchModalOpen} + setIsBatchModalOpen={setIsBatchModalOpen} + selectedConnection={selectedConnection} + selectedDatabase={selectedDatabase} + availableDatabases={availableDatabases} + batchTables={batchTables} + checkedTableKeys={checkedTableKeys} + setCheckedTableKeys={setCheckedTableKeys} + batchFilterKeyword={batchFilterKeyword} + setBatchFilterKeyword={setBatchFilterKeyword} + batchFilterType={batchFilterType} + setBatchFilterType={setBatchFilterType} + batchSelectionScope={batchSelectionScope} + setBatchSelectionScope={setBatchSelectionScope} + filteredBatchObjects={filteredBatchObjects} + groupedBatchObjects={groupedBatchObjects} + selectionScopeTargetKeys={selectionScopeTargetKeys} + handleConnectionChange={handleConnectionChange} + handleDatabaseChange={handleDatabaseChange} + handleBatchClear={handleBatchClear} + handleBatchExport={handleBatchExport} + handleCheckAll={handleCheckAll} + handleInvertSelection={handleInvertSelection} + isBatchDbModalOpen={isBatchDbModalOpen} + setIsBatchDbModalOpen={setIsBatchDbModalOpen} + selectedDbConnection={selectedDbConnection} + batchDatabases={batchDatabases} + checkedDbKeys={checkedDbKeys} + setCheckedDbKeys={setCheckedDbKeys} + handleDbConnectionChange={handleDbConnectionChange} + handleBatchDbExport={handleBatchDbExport} + handleCheckAllDb={handleCheckAllDb} + handleInvertSelectionDb={handleInvertSelectionDb} + /> void; + selectedConnection: string; + selectedDatabase: string; + availableDatabases: BatchObjectItem[]; + batchTables: BatchObjectItem[]; + checkedTableKeys: string[]; + setCheckedTableKeys: (keys: string[]) => void; + batchFilterKeyword: string; + setBatchFilterKeyword: (value: string) => void; + batchFilterType: BatchObjectFilterType; + setBatchFilterType: (value: BatchObjectFilterType) => void; + batchSelectionScope: BatchSelectionScope; + setBatchSelectionScope: (value: BatchSelectionScope) => void; + filteredBatchObjects: BatchObjectItem[]; + groupedBatchObjects: { + tables: BatchObjectItem[]; + views: BatchObjectItem[]; + }; + selectionScopeTargetKeys: string[]; + handleConnectionChange: (connectionId: string) => void; + handleDatabaseChange: (databaseName: string) => void; + handleBatchClear: () => void; + handleBatchExport: (mode: 'schema' | 'dataOnly' | 'backup') => void; + handleCheckAll: (checked: boolean) => void; + handleInvertSelection: () => void; + isBatchDbModalOpen: boolean; + setIsBatchDbModalOpen: (open: boolean) => void; + selectedDbConnection: string; + batchDatabases: BatchObjectItem[]; + checkedDbKeys: string[]; + setCheckedDbKeys: (keys: string[]) => void; + handleDbConnectionChange: (connectionId: string) => void; + handleBatchDbExport: (includeData: boolean) => void; + handleCheckAllDb: (checked: boolean) => void; + handleInvertSelectionDb: () => void; +}; + +const nonRedisConnections = (connections: SavedConnection[]) => + connections.filter((connection) => connection.config.type !== 'redis'); + +export const SidebarBatchExportModals: React.FC = ({ + connections, + modalPanelStyle, + modalSectionStyle, + modalScrollSectionStyle, + modalHintTextStyle, + darkMode, + tableModalTitle, + databaseModalTitle, + isBatchModalOpen, + setIsBatchModalOpen, + selectedConnection, + selectedDatabase, + availableDatabases, + batchTables, + checkedTableKeys, + setCheckedTableKeys, + batchFilterKeyword, + setBatchFilterKeyword, + batchFilterType, + setBatchFilterType, + batchSelectionScope, + setBatchSelectionScope, + filteredBatchObjects, + groupedBatchObjects, + selectionScopeTargetKeys, + handleConnectionChange, + handleDatabaseChange, + handleBatchClear, + handleBatchExport, + handleCheckAll, + handleInvertSelection, + isBatchDbModalOpen, + setIsBatchDbModalOpen, + selectedDbConnection, + batchDatabases, + checkedDbKeys, + setCheckedDbKeys, + handleDbConnectionChange, + handleBatchDbExport, + handleCheckAllDb, + handleInvertSelectionDb, +}) => ( + <> + setIsBatchModalOpen(false)} + width={720} + centered + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} + footer={ +
+ + + + + + + +
+ } + > +
+
+ + +
+
+ + +
+
先选择连接与数据库,再决定导出范围和目标对象。
+
+ + {batchTables.length > 0 && ( +
+ + setBatchFilterKeyword(e.target.value)} + placeholder="筛选表/视图名称" + prefix={} + style={{ width: 260 }} + /> + setBatchSelectionScope(value as BatchSelectionScope)} + style={{ width: 220 }} + options={[ + { label: '勾选作用于:当前筛选结果', value: 'filtered' }, + { label: '勾选作用于:全部对象', value: 'all' }, + ]} + /> + +
+ 当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象 +
+
+ )} + + {batchTables.length > 0 && ( + <> +
+ + + + + + 已选择 {checkedTableKeys.length} / {batchTables.length} 个对象 + + +
+
+ setCheckedTableKeys(values as string[])} + style={{ width: '100%' }} + > +
+ {groupedBatchObjects.tables.length > 0 && ( +
+
+ 表 ({groupedBatchObjects.tables.length}) +
+ + {groupedBatchObjects.tables.map(table => ( + + + {table.title} + + ))} + +
+ )} + {groupedBatchObjects.views.length > 0 && ( +
+
+ 视图 ({groupedBatchObjects.views.length}) +
+ + {groupedBatchObjects.views.map(view => ( + + + {view.title} + + ))} + +
+ )} + {groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && ( +
+ 无匹配对象 +
+ )} +
+
+
+ + )} +
+ + setIsBatchDbModalOpen(false)} + width={640} + centered + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} + footer={[ + , + , + , + ]} + > +
+ + +
连接选定后会加载当前连接下可批量导出的数据库列表。
+
+ + {batchDatabases.length > 0 && ( + <> +
+ + + + + + 已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库 + + +
+
+ setCheckedDbKeys(values as string[])} + style={{ width: '100%' }} + > + + {batchDatabases.map(db => ( + + + {db.title} + + ))} + + +
+ + )} +
+ +); From 6e422aea330f0e272cb3b7d771110026dc2bcc1f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 17:32:45 +0800 Subject: [PATCH 43/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E6=A0=91=E8=8A=82=E7=82=B9=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 805 +--------------- .../sidebar/useSidebarTreeLoaders.tsx | 870 ++++++++++++++++++ 3 files changed, 900 insertions(+), 776 deletions(-) create mode 100644 frontend/src/components/sidebar/useSidebarTreeLoaders.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 045852d..f8ed6d4 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -68,6 +68,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), readSourceFile('./sidebar/useSidebarBatchExport.ts'), readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'), + readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 81548a7..77ff9f5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -4,32 +4,25 @@ import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/Side import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; import { buildDuckDBMacroDDL, - buildQualifiedName, - buildSidebarObjectKeyName, buildSidebarTableStatusSQL, escapeSQLLiteral, extractSqlServerDefinitionRows, getCaseInsensitiveRawValue, getCaseInsensitiveValue, getMetadataDialect, - getMySQLShowTablesName, getSidebarTableDisplayName, - isSphinxConnection, - loadDatabaseEvents, - loadDatabaseTriggers, - loadFunctions, - loadSchemas, - loadStarRocksMaterializedViews, - loadViews, - parseMetadataRowCount, shouldHideSchemaPrefix, splitQualifiedName, - supportsDatabaseEvents, } from './sidebar/sidebarMetadataLoaders'; import { useSidebarBatchExport, } from './sidebar/useSidebarBatchExport'; import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; +import { + normalizeDriverType, + useSidebarTreeLoaders, +} from './sidebar/useSidebarTreeLoaders'; +export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders'; import { ExternalSQLFileModal, SQLFileExecutionModal, @@ -125,9 +118,9 @@ import { useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; + import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; + import { DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -290,31 +283,6 @@ type SidebarMessagePublishTarget = { destination: string; }; -type DriverStatusSnapshot = { - type: string; - name: string; - connectable: boolean; - expectedRevision?: string; - needsUpdate?: boolean; - updateReason?: string; - message?: string; -}; - -export const formatSidebarDriverAgentUpdateWarning = ( - driverName: string, - status: Pick, -): string => { - const rawMessage = String(status.message || '').trim(); - if (rawMessage) { - return rawMessage; - } - const rawUpdateReason = String(status.updateReason || '').trim(); - if (rawUpdateReason) { - return rawUpdateReason; - } - return t('connection.modal.driver.updateFallback', { name: driverName }); -}; - const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => { if (!conn) return ''; return JSON.stringify({ @@ -329,34 +297,6 @@ const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => { return text === connectionId || text.startsWith(`${connectionId}-`); }; -const DRIVER_STATUS_CACHE_TTL_MS = 30_000; - -const normalizeDriverType = (value: string): string => { - const normalized = String(value || '').trim().toLowerCase(); - if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres'; - if (normalized === 'doris') return 'diros'; - if ( - normalized === 'open_gauss' || - normalized === 'open-gauss' || - normalized === 'opengauss' - ) return 'opengauss'; - if ( - normalized === 'intersystems' || - normalized === 'intersystemsiris' || - normalized === 'inter-systems' || - normalized === 'inter-systems-iris' - ) return 'iris'; - return normalized; -}; - -const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => { - const type = normalizeDriverType(conn?.config?.type || ''); - if (type !== 'custom') { - return type; - } - return normalizeDriverType(conn?.config?.driver || ''); -}; - const isPostgresSchemaDialect = (dialect: string): boolean => ( ['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect)) ); @@ -653,11 +593,6 @@ const Sidebar: React.FC<{ activeContext: null, }); const connectionReloadSignaturesRef = useRef>({}); - const driverStatusCacheRef = useRef<{ - fetchedAt: number; - items: Record; - } | null>(null); - const driverUpdateWarningKeysRef = useRef>(new Set()); const [contextMenu, setContextMenu] = useState(null); const contextMenuPortalRef = useRef(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); @@ -1277,710 +1212,6 @@ const Sidebar: React.FC<{ return null; }; - const fetchDriverStatusMap = async (): Promise> => { - const cached = driverStatusCacheRef.current; - if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { - return cached.items; - } - const result: Record = {}; - const res = await GetDriverStatusList('', ''); - if (!res?.success) { - return result; - } - const data = (res.data || {}) as any; - const drivers = Array.isArray(data.drivers) ? data.drivers : []; - drivers.forEach((item: any) => { - const type = normalizeDriverType(String(item.type || '').trim()); - if (!type) return; - result[type] = { - type, - name: String(item.name || item.type || type).trim(), - connectable: !!item.connectable, - expectedRevision: String(item.expectedRevision || '').trim() || undefined, - needsUpdate: !!item.needsUpdate, - updateReason: String(item.updateReason || '').trim() || undefined, - message: String(item.message || '').trim() || undefined, - }; - }); - driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result }; - return result; - }; - - const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => { - try { - const driverType = resolveSavedConnectionDriverType(conn); - if (!driverType || driverType === 'custom') { - return; - } - const statusMap = await fetchDriverStatusMap(); - const status = statusMap[driverType]; - if (!status?.connectable || !status.needsUpdate) { - return; - } - const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown'; - const warningKey = `${conn.id}:${driverType}:${revisionKey}`; - if (driverUpdateWarningKeysRef.current.has(warningKey)) { - return; - } - driverUpdateWarningKeysRef.current.add(warningKey); - const driverName = status.name || driverType; - message.warning({ - content: formatSidebarDriverAgentUpdateWarning(driverName, status), - key: `driver-agent-update-${conn.id}`, - duration: 10, - }); - } catch (error) { - console.warn('检查驱动代理更新状态失败', error); - } - }; - const loadDatabases = async (node: any) => { - const conn = node.dataRef as SavedConnection; - const loadKey = `dbs-${conn.id}`; - if (loadingNodesRef.current.has(loadKey)) return; - loadingNodesRef.current.add(loadKey); - 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: "" } - }; - - if (conn.config.type === 'jvm') { - try { - const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any); - if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); - const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : []; - const modeNodes: TreeNode[] = capabilities.map((capability) => ({ - title: capability.displayLabel || capability.mode, - key: `${conn.id}-jvm-mode-${capability.mode}`, - icon: , - type: 'jvm-mode', - dataRef: { - ...conn, - providerMode: capability.mode, - canBrowse: capability.canBrowse, - canWrite: capability.canWrite, - reason: capability.reason, - displayLabel: capability.displayLabel, - }, - isLeaf: capability.canBrowse !== true, - })); - const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({ - title: item.title, - key: item.key, - icon: , - type: 'jvm-monitoring', - dataRef: { - ...conn, - providerMode: item.providerMode, - }, - isLeaf: true, - })); - const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); - replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]); - } else { - const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - if (diagnosticNode.length > 0) { - replaceTreeNodeChildren(node.key, diagnosticNode); - message.warning({ - content: t('sidebar.message.jvm_provider_probe_failed_with_diagnostic', { - error: res.message || t('sidebar.error.unknown'), - }), - key: `conn-${conn.id}-jvm-caps`, - }); - } else { - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` }); - } - } - } catch (e: any) { - const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - if (diagnosticNode.length > 0) { - replaceTreeNodeChildren(node.key, diagnosticNode); - message.warning({ - content: t('sidebar.message.jvm_provider_probe_exception_with_diagnostic', { - error: e?.message || String(e), - }), - key: `conn-${conn.id}-jvm-caps`, - }); - } else { - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ - content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), - key: `conn-${conn.id}-jvm-caps`, - }); - } - } finally { - loadingNodesRef.current.delete(loadKey); - } - return; - } - - // Handle Redis connections differently - if (conn.config.type === 'redis') { - try { - const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config)); - if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); - const redisRows: any[] = Array.isArray(res.data) ? res.data : []; - let dbs = redisRows.map((db: any) => ({ - title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`, - key: `${conn.id}-db${db.index}`, - icon: , - type: 'redis-db' as const, - dataRef: { ...conn, redisDB: db.index }, - isLeaf: true, - dbIndex: db.index, - })); - // Filter Redis databases if configured - if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) { - dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex)); - } - replaceTreeNodeChildren(node.key, dbs); - } else { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); - } - } catch (e: any) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - message.error({ - content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), - key: `conn-${conn.id}-dbs`, - }); - } finally { - loadingNodesRef.current.delete(loadKey); - } - return; - } - - try { - const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); - if (res.success) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); - const dbRows: any[] = Array.isArray(res.data) ? res.data : []; - let dbs = dbRows.map((row: any) => ({ - title: row.Database || row.database, - key: `${conn.id}-${row.Database || row.database}`, - icon: , - type: 'database' as const, - dataRef: { ...conn, dbName: row.Database || row.database }, - isLeaf: false, - })); - - // Filter databases if configured - if (conn.includeDatabases && conn.includeDatabases.length > 0) { - dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title)); - } - - if (dbs.length > 0) { - replaceTreeNodeChildren(node.key, dbs); - } else { - // 空列表:清理 loadedKeys 以允许重新加载,不设置 children = [] - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.warning({ content: t('sidebar.message.no_visible_databases'), key: `conn-${conn.id}-dbs` }); - } - } else { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); - } - } catch (e: any) { - setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ - content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), - key: `conn-${conn.id}-dbs`, - }); - } finally { - loadingNodesRef.current.delete(loadKey); - } - }; - - const loadJVMResources = async (node: any) => { - const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string }; - const providerMode = String(conn.providerMode || '').trim().toLowerCase(); - const parentPath = String(conn.resourcePath || '').trim(); - const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`; - if (loadingNodesRef.current.has(loadKey)) return; - loadingNodesRef.current.add(loadKey); - - try { - const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.JVMListResources !== 'function') { - throw new Error(t('sidebar.message.jvm_resources_backend_unavailable')); - } - - const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath); - if (res.success) { - const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : []; - const resourceNodes: TreeNode[] = resourceRows.map((item) => ({ - title: item.name || item.path || item.id, - key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`, - icon: item.hasChildren ? : , - type: 'jvm-resource', - dataRef: { - ...conn, - providerMode: item.providerMode || providerMode, - resourcePath: item.path, - resourceKind: item.kind, - canRead: item.canRead, - canWrite: item.canWrite, - hasChildren: item.hasChildren, - sensitive: item.sensitive, - }, - isLeaf: item.hasChildren !== true, - })); - replaceTreeNodeChildren(node.key, resourceNodes); - } else { - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ content: res.message, key: `jvm-resource-${node.key}` }); - } - } catch (e: any) { - setLoadedKeys(prev => prev.filter(k => k !== node.key)); - message.error({ - content: t('sidebar.message.load_jvm_resources_failed', { error: e?.message || String(e) }), - key: `jvm-resource-${node.key}`, - }); - } finally { - loadingNodesRef.current.delete(loadKey); - } - }; - - const loadTables = async (node: any) => { - const conn = node.dataRef; // has dbName - const dbName = conn.dbName; - const key = node.key; - const loadKey = `tables-${conn.id}-${dbName}`; - if (loadingNodesRef.current.has(loadKey)) return; - loadingNodesRef.current.add(loadKey); - - const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName); - const queriesNode: TreeNode = { - title: t('sidebar.tree.saved_queries'), - key: `${key}-queries`, - icon: , - type: 'queries-folder', - isLeaf: dbQueries.length === 0, - children: dbQueries.map(q => ({ - title: resolveSavedQueryDisplayName(q.name), - key: q.id, - icon: , - type: 'saved-query', - dataRef: q, - isLeaf: true - })) - }; - - 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: "" } - }; - try { - const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName); - if (res.success) { - setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); - - const tableRows: any[] = Array.isArray(res.data) ? res.data : []; - const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName); - const tableStatsResult = tableStatusSql - ? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] })) - : { success: false, data: [] as any[] }; - const tableRowCountMap = new Map(); - if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) { - tableStatsResult.data.forEach((row: Record) => { - const rawTableName = String( - getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Name', 'name']) - || getMySQLShowTablesName(row) - || '' - ).trim(); - if (!rawTableName) return; - const rowCount = parseMetadataRowCount(row); - if (rowCount === undefined) return; - tableRowCountMap.set(rawTableName.toLowerCase(), rowCount); - }); - } - const tableEntries = tableRows.map((row: any) => { - const tableName = Object.values(row)[0] as string; - const parsed = splitQualifiedName(tableName); - return { - tableName, - schemaName: parsed.schemaName, - displayName: getSidebarTableDisplayName(conn, tableName), - rowCount: tableRowCountMap.get(String(tableName || '').trim().toLowerCase()), - }; - }); - - const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult, eventsResult] = await Promise.all([ - loadSchemas(conn, conn.dbName), - loadViews(conn, conn.dbName), - loadStarRocksMaterializedViews(conn, conn.dbName), - loadDatabaseTriggers(conn, conn.dbName), - loadFunctions(conn, conn.dbName), - loadDatabaseEvents(conn, conn.dbName), - ]); - const externalSQLDirectoryResults = await Promise.all( - externalSQLDirectories.map(async (directory: ExternalSQLDirectory) => { - const directoryRes = await ListSQLDirectory(directory.path); - if (!directoryRes.success) { - message.warning({ - key: `external-sql-${directory.id}`, - content: t('sidebar.message.external_sql_directory_read_failed', { - name: directory.name, - error: directoryRes.message, - }), - }); - return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] }; - } - return { - id: directory.id, - entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [], - }; - }), - ); - const externalSQLTrees = externalSQLDirectoryResults.reduce>((accumulator, item) => { - accumulator[item.id] = item.entries; - return accumulator; - }, {}); - const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({ - dbNodeKey: String(key), - connectionId: String(conn.id), - dbName: String(conn.dbName), - directories: externalSQLDirectories, - directoryTrees: externalSQLTrees, - labels: { - root: t('sidebar.external_sql.root'), - directoryFallback: t('sidebar.external_sql.directory_fallback'), - }, - })); - const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; - const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; - const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; - const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : []; - const eventRows: any[] = Array.isArray(eventsResult.events) ? eventsResult.events : []; - const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : []; - - const viewEntries = viewRows.map((entry: SidebarViewMetadataEntry) => { - const parsed = splitQualifiedName(entry.viewName); - return { - viewName: entry.viewName, - schemaName: entry.schemaName || parsed.schemaName, - displayName: getSidebarTableDisplayName(conn, entry.viewName), - }; - }); - - const materializedViewEntries = materializedViewRows.map((entry: SidebarViewMetadataEntry) => { - const parsed = splitQualifiedName(entry.viewName); - return { - viewName: entry.viewName, - schemaName: entry.schemaName || parsed.schemaName, - displayName: getSidebarTableDisplayName(conn, entry.viewName), - }; - }); - - const triggerEntries = (() => { - const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = []; - const triggerSeen = new Set(); - const metadataDialect = getMetadataDialect(conn as SavedConnection); - - triggerRows.forEach((trigger: any) => { - const triggerParsed = splitQualifiedName(trigger.triggerName); - const tableParsed = splitQualifiedName(trigger.tableName); - const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim(); - const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim(); - const tableObjectName = (tableParsed.objectName || trigger.tableName).trim(); - const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; - const dedupeKey = metadataDialect === 'mysql' - ? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}` - : `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`; - - if (triggerSeen.has(dedupeKey)) return; - triggerSeen.add(dedupeKey); - deduped.push({ - ...trigger, - schemaName, - triggerName: triggerObjectName, - tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName, - displayName, - }); - }); - - return deduped; - })(); - - const routineEntries = routineRows.map((routine: any) => { - const parsed = splitQualifiedName(routine.routineName); - const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F'; - return { - ...routine, - schemaName: parsed.schemaName, - displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`, - }; - }); - - const eventEntries = eventRows.map((event: any) => ({ - ...event, - schemaName: String(event.schemaName || conn.dbName || '').trim(), - displayName: String(event.displayName || event.eventName || '').trim(), - })).filter((event: any) => event.eventName && event.displayName); - - if (isSphinxConnection(conn as SavedConnection)) { - const unsupportedObjects: string[] = []; - if (!viewsResult.supported) unsupportedObjects.push(t('sidebar.object_group.views')); - if (!routinesResult.supported) unsupportedObjects.push(t('sidebar.object_group.routines')); - if (!triggersResult.supported) unsupportedObjects.push(t('sidebar.object_group.triggers')); - if (unsupportedObjects.length > 0) { - message.info({ - key: `sphinx-capability-${conn.id}-${conn.dbName}`, - content: t('sidebar.message.sphinx_unsupported_objects', { - objects: unsupportedObjects.join(t('sidebar.punctuation.list_separator')), - }), - }); - } - } - - const currentStoreState = useStore.getState(); - const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference; - const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount; - const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables; - - // 获取当前数据库的排序偏好 - const sortPreferenceKey = `${conn.id}-${conn.dbName}`; - const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name'; - - const sortedTableEntries = sortSidebarTableEntries(tableEntries, { - connectionId: conn.id, - dbName: conn.dbName, - sortBy, - tableAccessCount: currentTableAccessCount, - pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [], - }); - - // Sort views by name (case-insensitive) - viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - - materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - - // Sort triggers by display name (case-insensitive) - triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - - // Sort routines by display name (case-insensitive) - routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - - eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - - const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number }): TreeNode => { - const isPinned = isV2Ui && isSidebarTablePinned( - currentPinnedSidebarTables, - conn.id, - conn.dbName, - entry.tableName, - entry.schemaName, - ); - return { - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-${entry.tableName}`, - icon: , - type: 'table', - dataRef: { - ...conn, - tableName: entry.tableName, - schemaName: entry.schemaName, - rowCount: entry.rowCount, - ...(isPinned ? { pinnedSidebarTable: true } : {}), - }, - isLeaf: false, - }; - }; - - const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { - const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); - return { - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-view-${keyName}`, - icon: , - type: 'view', - dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, - isLeaf: true, - }; - }; - - const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { - const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); - return { - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-materialized-view-${keyName}`, - icon: , - type: 'materialized-view', - dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' }, - isLeaf: true, - }; - }; - - const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`, - icon: , - type: 'db-trigger', - dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, tableName: entry.tableName, schemaName: entry.schemaName }, - isLeaf: true, - }); - - const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`, - icon: , - type: 'routine', - dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, - isLeaf: true, - }); - - const buildEventNode = (entry: { eventName: string; schemaName: string; displayName: string; eventType?: string; status?: string }): TreeNode => ({ - title: entry.displayName, - key: `${conn.id}-${conn.dbName}-event-${entry.schemaName}-${entry.eventName}`, - icon: , - type: 'db-event', - dataRef: { ...conn, eventName: entry.eventName, schemaName: entry.schemaName, eventType: entry.eventType, eventStatus: entry.status }, - isLeaf: true, - }); - - const buildObjectGroup = ( - parentKey: string, - groupKey: string, - groupTitle: string, - groupIcon: React.ReactNode, - children: TreeNode[], - extraData: Record = {} - ): TreeNode => { - const groupNodeKey = `${parentKey}-${groupKey}`; - const groupedChildren = groupKey === 'tables' - ? buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui) - : children; - return { - title: groupTitle, - key: groupNodeKey, - icon: groupIcon, - type: 'object-group', - isLeaf: children.length === 0, - children: groupedChildren.length > 0 ? groupedChildren : undefined, - dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData } - }; - }; - - const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection); - if (shouldGroupBySchema) { - type SchemaBucket = { - schemaName: string; - tables: TreeNode[]; - views: TreeNode[]; - materializedViews: TreeNode[]; - routines: TreeNode[]; - triggers: TreeNode[]; - events: TreeNode[]; - }; - - const schemaMap = new Map(); - const getSchemaBucket = (rawSchemaName: string): SchemaBucket => { - const schemaName = String(rawSchemaName || '').trim(); - const schemaKey = schemaName || '__default__'; - let bucket = schemaMap.get(schemaKey); - if (!bucket) { - bucket = { - schemaName, - tables: [], - views: [], - materializedViews: [], - routines: [], - triggers: [], - events: [], - }; - schemaMap.set(schemaKey, bucket); - } - return bucket; - }; - - schemaRows.forEach((schemaName) => getSchemaBucket(schemaName)); - sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry))); - viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry))); - materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry))); - routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry))); - triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry))); - eventEntries.forEach((entry) => getSchemaBucket(entry.schemaName).events.push(buildEventNode(entry))); - - const dialect = getMetadataDialect(conn as SavedConnection); - const isOracleLike = (dialect === 'oracle' || dialect === 'dm'); - const includeMaterializedViews = dialect === 'starrocks'; - const includeEvents = supportsDatabaseEvents(conn as SavedConnection); - - const schemaNodes: TreeNode[] = Array.from(schemaMap.values()) - .filter((bucket) => !(isOracleLike && !bucket.schemaName)) - .sort((a, b) => { - if (!a.schemaName && !b.schemaName) return 0; - if (!a.schemaName) return -1; - if (!b.schemaName) return 1; - return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase()); - }) - .map((bucket) => { - const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`; - const schemaTitle = bucket.schemaName || t('sidebar.tree.default_schema'); - const groupedNodes: TreeNode[] = [ - buildObjectGroup(schemaNodeKey, 'tables', t('sidebar.object_group.tables'), , bucket.tables, { schemaName: bucket.schemaName }), - buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), , bucket.views, { schemaName: bucket.schemaName }), - ...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), , bucket.materializedViews, { schemaName: bucket.schemaName })] : []), - buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), , bucket.routines, { schemaName: bucket.schemaName }), - buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), , bucket.triggers, { schemaName: bucket.schemaName }), - ...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), , bucket.events, { schemaName: bucket.schemaName })] : []), - ]; - - return { - title: schemaTitle, - key: schemaNodeKey, - icon: , - type: 'object-group' as const, - isLeaf: groupedNodes.length === 0, - children: groupedNodes, - dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName } - }; - }); - - replaceTreeNodeChildren(key, [queriesNode, ...schemaNodes]); - } else { - const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks'; - const includeEvents = supportsDatabaseEvents(conn as SavedConnection); - const groupedNodes: TreeNode[] = [ - buildObjectGroup(key as string, 'tables', t('sidebar.object_group.tables'), , sortedTableEntries.map(buildTableNode)), - buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), , viewEntries.map(buildViewNode)), - ...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), , materializedViewEntries.map(buildMaterializedViewNode))] : []), - buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), , routineEntries.map(buildRoutineNode)), - buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), , triggerEntries.map(buildTriggerNode)), - ...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), , eventEntries.map(buildEventNode))] : []), - ]; - - replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]); - } - } else { - setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); - message.error({ content: res.message, key: `db-${key}-tables` }); - } - } catch (e: any) { - setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); - message.error({ - content: t('sidebar.message.load_table_list_failed', { error: e?.message || String(e) }), - key: `db-${key}-tables`, - }); - } finally { - loadingNodesRef.current.delete(loadKey); - } - }; - const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise>(async () => {}); const waitForSidebarLoadKey = async (loadKey: string): Promise => { @@ -3284,6 +2515,28 @@ const Sidebar: React.FC<{ return rawName || t('query_editor.save_modal.unnamed'); }; + const { + loadDatabases, + loadJVMResources, + loadTables, + } = useSidebarTreeLoaders({ + savedQueries, + externalSQLDirectories, + tableSortPreference, + tableAccessCount, + pinnedSidebarTables, + isV2Ui, + loadingNodesRef, + setConnectionStates, + setLoadedKeys, + replaceTreeNodeChildren, + buildRuntimeConfig, + buildJVMRuntimeConfig, + buildJVMDiagnosticTreeNodes, + resolveSavedQueryDisplayName, + decorateExternalSQLTreeNode, + }); + const handleRenameSavedQuery = async () => { if (!renameSavedQueryTarget) return; try { diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx new file mode 100644 index 0000000..dfcf128 --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -0,0 +1,870 @@ +import React, { useRef } from 'react'; +import { message } from 'antd'; +import { + CodeOutlined, + ClockCircleOutlined, + DashboardOutlined, + DatabaseOutlined, + EyeOutlined, + FileTextOutlined, + FolderOpenOutlined, + FunctionOutlined, + HddOutlined, + TableOutlined, + ThunderboltOutlined, +} from '@ant-design/icons'; +import type { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../../types'; +import { useStore } from '../../store'; +import { t } from '../../i18n'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import { buildJVMMonitoringActionDescriptors } from '../../utils/jvmSidebarActions'; +import { type SidebarViewMetadataEntry } from '../../utils/sidebarMetadata'; +import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../../utils/externalSqlTree'; +import { + buildQualifiedName, + buildSidebarObjectKeyName, + buildSidebarTableStatusSQL, + getCaseInsensitiveValue, + getMetadataDialect, + getMySQLShowTablesName, + getSidebarTableDisplayName, + isSphinxConnection, + loadDatabaseEvents, + loadDatabaseTriggers, + loadFunctions, + loadSchemas, + loadStarRocksMaterializedViews, + loadViews, + parseMetadataRowCount, + shouldHideSchemaPrefix, + splitQualifiedName, + supportsDatabaseEvents, +} from './sidebarMetadataLoaders'; +import { + buildSidebarTableChildrenForUi, + isSidebarTablePinned, + sortSidebarTableEntries, + type SidebarTreeNode as TreeNode, +} from '../sidebarV2Utils'; +import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities, ListSQLDirectory } from '../../../wailsjs/go/app/App'; + +type DriverStatusSnapshot = { + type: string; + name: string; + connectable: boolean; + expectedRevision?: string; + needsUpdate?: boolean; + updateReason?: string; + message?: string; +}; + +export const formatSidebarDriverAgentUpdateWarning = ( + driverName: string, + status: Pick, +): string => { + const rawMessage = String(status.message || '').trim(); + if (rawMessage) { + return rawMessage; + } + const rawUpdateReason = String(status.updateReason || '').trim(); + if (rawUpdateReason) { + return rawUpdateReason; + } + return t('connection.modal.driver.updateFallback', { name: driverName }); +}; + +const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => { + if (!conn) return ''; + return JSON.stringify({ + config: conn.config || {}, + includeDatabases: conn.includeDatabases || [], + includeRedisDatabases: conn.includeRedisDatabases || [], + }); +}; + +const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => { + const text = String(key); + return text === connectionId || text.startsWith(`${connectionId}-`); +}; + +const DRIVER_STATUS_CACHE_TTL_MS = 30_000; + +export const normalizeDriverType = (value: string): string => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres'; + if (normalized === 'doris') return 'diros'; + if ( + normalized === 'open_gauss' || + normalized === 'open-gauss' || + normalized === 'opengauss' + ) return 'opengauss'; + if ( + normalized === 'intersystems' || + normalized === 'intersystemsiris' || + normalized === 'inter-systems' || + normalized === 'inter-systems-iris' + ) return 'iris'; + return normalized; +}; + +const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => { + const type = normalizeDriverType(conn?.config?.type || ''); + if (type !== 'custom') { + return type; + } + return normalizeDriverType(conn?.config?.driver || ''); +}; + + +type UseSidebarTreeLoadersOptions = { + savedQueries: SavedQuery[]; + externalSQLDirectories: ExternalSQLDirectory[]; + tableSortPreference: Record; + tableAccessCount: Record; + pinnedSidebarTables: any[]; + isV2Ui: boolean; + loadingNodesRef: React.MutableRefObject>; + setConnectionStates: React.Dispatch>>; + setLoadedKeys: React.Dispatch>; + replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => TreeNode[]; + buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any; + buildJVMRuntimeConfig: (conn: SavedConnection & { dbName?: string }, providerMode: string) => any; + buildJVMDiagnosticTreeNodes: (conn: SavedConnection) => TreeNode[]; + resolveSavedQueryDisplayName: (name: string | null | undefined) => string; + decorateExternalSQLTreeNode: (node: ExternalSQLTreeNode) => TreeNode; +}; + +export const useSidebarTreeLoaders = ({ + savedQueries, + externalSQLDirectories, + tableSortPreference, + tableAccessCount, + pinnedSidebarTables, + isV2Ui, + loadingNodesRef, + setConnectionStates, + setLoadedKeys, + replaceTreeNodeChildren, + buildRuntimeConfig, + buildJVMRuntimeConfig, + buildJVMDiagnosticTreeNodes, + resolveSavedQueryDisplayName, + decorateExternalSQLTreeNode, +}: UseSidebarTreeLoadersOptions) => { + const driverStatusCacheRef = useRef<{ + fetchedAt: number; + items: Record; + } | null>(null); + const driverUpdateWarningKeysRef = useRef>(new Set()); + + const fetchDriverStatusMap = async (): Promise> => { + const cached = driverStatusCacheRef.current; + if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { + return cached.items; + } + const result: Record = {}; + const res = await GetDriverStatusList('', ''); + if (!res?.success) { + return result; + } + const data = (res.data || {}) as any; + const drivers = Array.isArray(data.drivers) ? data.drivers : []; + drivers.forEach((item: any) => { + const type = normalizeDriverType(String(item.type || '').trim()); + if (!type) return; + result[type] = { + type, + name: String(item.name || item.type || type).trim(), + connectable: !!item.connectable, + expectedRevision: String(item.expectedRevision || '').trim() || undefined, + needsUpdate: !!item.needsUpdate, + updateReason: String(item.updateReason || '').trim() || undefined, + message: String(item.message || '').trim() || undefined, + }; + }); + driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result }; + return result; + }; + + const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => { + try { + const driverType = resolveSavedConnectionDriverType(conn); + if (!driverType || driverType === 'custom') { + return; + } + const statusMap = await fetchDriverStatusMap(); + const status = statusMap[driverType]; + if (!status?.connectable || !status.needsUpdate) { + return; + } + const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown'; + const warningKey = `${conn.id}:${driverType}:${revisionKey}`; + if (driverUpdateWarningKeysRef.current.has(warningKey)) { + return; + } + driverUpdateWarningKeysRef.current.add(warningKey); + const driverName = status.name || driverType; + message.warning({ + content: formatSidebarDriverAgentUpdateWarning(driverName, status), + key: `driver-agent-update-${conn.id}`, + duration: 10, + }); + } catch (error) { + console.warn('检查驱动代理更新状态失败', error); + } + }; + const loadDatabases = async (node: any) => { + const conn = node.dataRef as SavedConnection; + const loadKey = `dbs-${conn.id}`; + if (loadingNodesRef.current.has(loadKey)) return; + loadingNodesRef.current.add(loadKey); + 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: "" } + }; + + if (conn.config.type === 'jvm') { + try { + const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any); + if (res.success) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : []; + const modeNodes: TreeNode[] = capabilities.map((capability) => ({ + title: capability.displayLabel || capability.mode, + key: `${conn.id}-jvm-mode-${capability.mode}`, + icon: , + type: 'jvm-mode', + dataRef: { + ...conn, + providerMode: capability.mode, + canBrowse: capability.canBrowse, + canWrite: capability.canWrite, + reason: capability.reason, + displayLabel: capability.displayLabel, + }, + isLeaf: capability.canBrowse !== true, + })); + const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({ + title: item.title, + key: item.key, + icon: , + type: 'jvm-monitoring', + dataRef: { + ...conn, + providerMode: item.providerMode, + }, + isLeaf: true, + })); + const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); + replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]); + } else { + const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + if (diagnosticNode.length > 0) { + replaceTreeNodeChildren(node.key, diagnosticNode); + message.warning({ + content: t('sidebar.message.jvm_provider_probe_failed_with_diagnostic', { + error: res.message || t('sidebar.error.unknown'), + }), + key: `conn-${conn.id}-jvm-caps`, + }); + } else { + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` }); + } + } + } catch (e: any) { + const diagnosticNode = buildJVMDiagnosticTreeNodes(conn); + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + if (diagnosticNode.length > 0) { + replaceTreeNodeChildren(node.key, diagnosticNode); + message.warning({ + content: t('sidebar.message.jvm_provider_probe_exception_with_diagnostic', { + error: e?.message || String(e), + }), + key: `conn-${conn.id}-jvm-caps`, + }); + } else { + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ + content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), + key: `conn-${conn.id}-jvm-caps`, + }); + } + } finally { + loadingNodesRef.current.delete(loadKey); + } + return; + } + + // Handle Redis connections differently + if (conn.config.type === 'redis') { + try { + const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config)); + if (res.success) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + const redisRows: any[] = Array.isArray(res.data) ? res.data : []; + let dbs = redisRows.map((db: any) => ({ + title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`, + key: `${conn.id}-db${db.index}`, + icon: , + type: 'redis-db' as const, + dataRef: { ...conn, redisDB: db.index }, + isLeaf: true, + dbIndex: db.index, + })); + // Filter Redis databases if configured + if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) { + dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex)); + } + replaceTreeNodeChildren(node.key, dbs); + } else { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); + } + } catch (e: any) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + message.error({ + content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), + key: `conn-${conn.id}-dbs`, + }); + } finally { + loadingNodesRef.current.delete(loadKey); + } + return; + } + + try { + const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); + if (res.success) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' })); + const dbRows: any[] = Array.isArray(res.data) ? res.data : []; + let dbs = dbRows.map((row: any) => ({ + title: row.Database || row.database, + key: `${conn.id}-${row.Database || row.database}`, + icon: , + type: 'database' as const, + dataRef: { ...conn, dbName: row.Database || row.database }, + isLeaf: false, + })); + + // Filter databases if configured + if (conn.includeDatabases && conn.includeDatabases.length > 0) { + dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title)); + } + + if (dbs.length > 0) { + replaceTreeNodeChildren(node.key, dbs); + } else { + // 空列表:清理 loadedKeys 以允许重新加载,不设置 children = [] + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.warning({ content: t('sidebar.message.no_visible_databases'), key: `conn-${conn.id}-dbs` }); + } + } else { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ content: res.message, key: `conn-${conn.id}-dbs` }); + } + } catch (e: any) { + setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' })); + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ + content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }), + key: `conn-${conn.id}-dbs`, + }); + } finally { + loadingNodesRef.current.delete(loadKey); + } + }; + + const loadJVMResources = async (node: any) => { + const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string }; + const providerMode = String(conn.providerMode || '').trim().toLowerCase(); + const parentPath = String(conn.resourcePath || '').trim(); + const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`; + if (loadingNodesRef.current.has(loadKey)) return; + loadingNodesRef.current.add(loadKey); + + try { + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.JVMListResources !== 'function') { + throw new Error(t('sidebar.message.jvm_resources_backend_unavailable')); + } + + const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath); + if (res.success) { + const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : []; + const resourceNodes: TreeNode[] = resourceRows.map((item) => ({ + title: item.name || item.path || item.id, + key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`, + icon: item.hasChildren ? : , + type: 'jvm-resource', + dataRef: { + ...conn, + providerMode: item.providerMode || providerMode, + resourcePath: item.path, + resourceKind: item.kind, + canRead: item.canRead, + canWrite: item.canWrite, + hasChildren: item.hasChildren, + sensitive: item.sensitive, + }, + isLeaf: item.hasChildren !== true, + })); + replaceTreeNodeChildren(node.key, resourceNodes); + } else { + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ content: res.message, key: `jvm-resource-${node.key}` }); + } + } catch (e: any) { + setLoadedKeys(prev => prev.filter(k => k !== node.key)); + message.error({ + content: t('sidebar.message.load_jvm_resources_failed', { error: e?.message || String(e) }), + key: `jvm-resource-${node.key}`, + }); + } finally { + loadingNodesRef.current.delete(loadKey); + } + }; + + const loadTables = async (node: any) => { + const conn = node.dataRef; // has dbName + const dbName = conn.dbName; + const key = node.key; + const loadKey = `tables-${conn.id}-${dbName}`; + if (loadingNodesRef.current.has(loadKey)) return; + loadingNodesRef.current.add(loadKey); + + const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName); + const queriesNode: TreeNode = { + title: t('sidebar.tree.saved_queries'), + key: `${key}-queries`, + icon: , + type: 'queries-folder', + isLeaf: dbQueries.length === 0, + children: dbQueries.map(q => ({ + title: resolveSavedQueryDisplayName(q.name), + key: q.id, + icon: , + type: 'saved-query', + dataRef: q, + isLeaf: true + })) + }; + + 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: "" } + }; + try { + const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName); + if (res.success) { + setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); + + const tableRows: any[] = Array.isArray(res.data) ? res.data : []; + const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName); + const tableStatsResult = tableStatusSql + ? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] })) + : { success: false, data: [] as any[] }; + const tableRowCountMap = new Map(); + if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) { + tableStatsResult.data.forEach((row: Record) => { + const rawTableName = String( + getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Name', 'name']) + || getMySQLShowTablesName(row) + || '' + ).trim(); + if (!rawTableName) return; + const rowCount = parseMetadataRowCount(row); + if (rowCount === undefined) return; + tableRowCountMap.set(rawTableName.toLowerCase(), rowCount); + }); + } + const tableEntries = tableRows.map((row: any) => { + const tableName = Object.values(row)[0] as string; + const parsed = splitQualifiedName(tableName); + return { + tableName, + schemaName: parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, tableName), + rowCount: tableRowCountMap.get(String(tableName || '').trim().toLowerCase()), + }; + }); + + const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult, eventsResult] = await Promise.all([ + loadSchemas(conn, conn.dbName), + loadViews(conn, conn.dbName), + loadStarRocksMaterializedViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + loadFunctions(conn, conn.dbName), + loadDatabaseEvents(conn, conn.dbName), + ]); + const externalSQLDirectoryResults = await Promise.all( + externalSQLDirectories.map(async (directory: ExternalSQLDirectory) => { + const directoryRes = await ListSQLDirectory(directory.path); + if (!directoryRes.success) { + message.warning({ + key: `external-sql-${directory.id}`, + content: t('sidebar.message.external_sql_directory_read_failed', { + name: directory.name, + error: directoryRes.message, + }), + }); + return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] }; + } + return { + id: directory.id, + entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [], + }; + }), + ); + const externalSQLTrees = externalSQLDirectoryResults.reduce>((accumulator, item) => { + accumulator[item.id] = item.entries; + return accumulator; + }, {}); + const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({ + dbNodeKey: String(key), + connectionId: String(conn.id), + dbName: String(conn.dbName), + directories: externalSQLDirectories, + directoryTrees: externalSQLTrees, + labels: { + root: t('sidebar.external_sql.root'), + directoryFallback: t('sidebar.external_sql.directory_fallback'), + }, + })); + const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; + const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; + const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; + const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : []; + const eventRows: any[] = Array.isArray(eventsResult.events) ? eventsResult.events : []; + const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : []; + + const viewEntries = viewRows.map((entry: SidebarViewMetadataEntry) => { + const parsed = splitQualifiedName(entry.viewName); + return { + viewName: entry.viewName, + schemaName: entry.schemaName || parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, entry.viewName), + }; + }); + + const materializedViewEntries = materializedViewRows.map((entry: SidebarViewMetadataEntry) => { + const parsed = splitQualifiedName(entry.viewName); + return { + viewName: entry.viewName, + schemaName: entry.schemaName || parsed.schemaName, + displayName: getSidebarTableDisplayName(conn, entry.viewName), + }; + }); + + const triggerEntries = (() => { + const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = []; + const triggerSeen = new Set(); + const metadataDialect = getMetadataDialect(conn as SavedConnection); + + triggerRows.forEach((trigger: any) => { + const triggerParsed = splitQualifiedName(trigger.triggerName); + const tableParsed = splitQualifiedName(trigger.tableName); + const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim(); + const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim(); + const tableObjectName = (tableParsed.objectName || trigger.tableName).trim(); + const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; + const dedupeKey = metadataDialect === 'mysql' + ? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}` + : `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`; + + if (triggerSeen.has(dedupeKey)) return; + triggerSeen.add(dedupeKey); + deduped.push({ + ...trigger, + schemaName, + triggerName: triggerObjectName, + tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName, + displayName, + }); + }); + + return deduped; + })(); + + const routineEntries = routineRows.map((routine: any) => { + const parsed = splitQualifiedName(routine.routineName); + const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F'; + return { + ...routine, + schemaName: parsed.schemaName, + displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`, + }; + }); + + const eventEntries = eventRows.map((event: any) => ({ + ...event, + schemaName: String(event.schemaName || conn.dbName || '').trim(), + displayName: String(event.displayName || event.eventName || '').trim(), + })).filter((event: any) => event.eventName && event.displayName); + + if (isSphinxConnection(conn as SavedConnection)) { + const unsupportedObjects: string[] = []; + if (!viewsResult.supported) unsupportedObjects.push(t('sidebar.object_group.views')); + if (!routinesResult.supported) unsupportedObjects.push(t('sidebar.object_group.routines')); + if (!triggersResult.supported) unsupportedObjects.push(t('sidebar.object_group.triggers')); + if (unsupportedObjects.length > 0) { + message.info({ + key: `sphinx-capability-${conn.id}-${conn.dbName}`, + content: t('sidebar.message.sphinx_unsupported_objects', { + objects: unsupportedObjects.join(t('sidebar.punctuation.list_separator')), + }), + }); + } + } + + const currentStoreState = useStore.getState(); + const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference; + const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount; + const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables; + + // 获取当前数据库的排序偏好 + const sortPreferenceKey = `${conn.id}-${conn.dbName}`; + const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name'; + + const sortedTableEntries = sortSidebarTableEntries(tableEntries, { + connectionId: conn.id, + dbName: conn.dbName, + sortBy, + tableAccessCount: currentTableAccessCount, + pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [], + }); + + // Sort views by name (case-insensitive) + viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + // Sort triggers by display name (case-insensitive) + triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + // Sort routines by display name (case-insensitive) + routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + + const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number }): TreeNode => { + const isPinned = isV2Ui && isSidebarTablePinned( + currentPinnedSidebarTables, + conn.id, + conn.dbName, + entry.tableName, + entry.schemaName, + ); + return { + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-${entry.tableName}`, + icon: , + type: 'table', + dataRef: { + ...conn, + tableName: entry.tableName, + schemaName: entry.schemaName, + rowCount: entry.rowCount, + ...(isPinned ? { pinnedSidebarTable: true } : {}), + }, + isLeaf: false, + }; + }; + + const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { + const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); + return { + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-view-${keyName}`, + icon: , + type: 'view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName }, + isLeaf: true, + }; + }; + + const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => { + const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName); + return { + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-materialized-view-${keyName}`, + icon: , + type: 'materialized-view', + dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' }, + isLeaf: true, + }; + }; + + const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`, + icon: , + type: 'db-trigger', + dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, tableName: entry.tableName, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`, + icon: , + type: 'routine', + dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName }, + isLeaf: true, + }); + + const buildEventNode = (entry: { eventName: string; schemaName: string; displayName: string; eventType?: string; status?: string }): TreeNode => ({ + title: entry.displayName, + key: `${conn.id}-${conn.dbName}-event-${entry.schemaName}-${entry.eventName}`, + icon: , + type: 'db-event', + dataRef: { ...conn, eventName: entry.eventName, schemaName: entry.schemaName, eventType: entry.eventType, eventStatus: entry.status }, + isLeaf: true, + }); + + const buildObjectGroup = ( + parentKey: string, + groupKey: string, + groupTitle: string, + groupIcon: React.ReactNode, + children: TreeNode[], + extraData: Record = {} + ): TreeNode => { + const groupNodeKey = `${parentKey}-${groupKey}`; + const groupedChildren = groupKey === 'tables' + ? buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui) + : children; + return { + title: groupTitle, + key: groupNodeKey, + icon: groupIcon, + type: 'object-group', + isLeaf: children.length === 0, + children: groupedChildren.length > 0 ? groupedChildren : undefined, + dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData } + }; + }; + + const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection); + if (shouldGroupBySchema) { + type SchemaBucket = { + schemaName: string; + tables: TreeNode[]; + views: TreeNode[]; + materializedViews: TreeNode[]; + routines: TreeNode[]; + triggers: TreeNode[]; + events: TreeNode[]; + }; + + const schemaMap = new Map(); + const getSchemaBucket = (rawSchemaName: string): SchemaBucket => { + const schemaName = String(rawSchemaName || '').trim(); + const schemaKey = schemaName || '__default__'; + let bucket = schemaMap.get(schemaKey); + if (!bucket) { + bucket = { + schemaName, + tables: [], + views: [], + materializedViews: [], + routines: [], + triggers: [], + events: [], + }; + schemaMap.set(schemaKey, bucket); + } + return bucket; + }; + + schemaRows.forEach((schemaName) => getSchemaBucket(schemaName)); + sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry))); + viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry))); + materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry))); + routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry))); + triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry))); + eventEntries.forEach((entry) => getSchemaBucket(entry.schemaName).events.push(buildEventNode(entry))); + + const dialect = getMetadataDialect(conn as SavedConnection); + const isOracleLike = (dialect === 'oracle' || dialect === 'dm'); + const includeMaterializedViews = dialect === 'starrocks'; + const includeEvents = supportsDatabaseEvents(conn as SavedConnection); + + const schemaNodes: TreeNode[] = Array.from(schemaMap.values()) + .filter((bucket) => !(isOracleLike && !bucket.schemaName)) + .sort((a, b) => { + if (!a.schemaName && !b.schemaName) return 0; + if (!a.schemaName) return -1; + if (!b.schemaName) return 1; + return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase()); + }) + .map((bucket) => { + const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`; + const schemaTitle = bucket.schemaName || t('sidebar.tree.default_schema'); + const groupedNodes: TreeNode[] = [ + buildObjectGroup(schemaNodeKey, 'tables', t('sidebar.object_group.tables'), , bucket.tables, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), , bucket.views, { schemaName: bucket.schemaName }), + ...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), , bucket.materializedViews, { schemaName: bucket.schemaName })] : []), + buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), , bucket.routines, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), , bucket.triggers, { schemaName: bucket.schemaName }), + ...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), , bucket.events, { schemaName: bucket.schemaName })] : []), + ]; + + return { + title: schemaTitle, + key: schemaNodeKey, + icon: , + type: 'object-group' as const, + isLeaf: groupedNodes.length === 0, + children: groupedNodes, + dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName } + }; + }); + + replaceTreeNodeChildren(key, [queriesNode, ...schemaNodes]); + } else { + const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks'; + const includeEvents = supportsDatabaseEvents(conn as SavedConnection); + const groupedNodes: TreeNode[] = [ + buildObjectGroup(key as string, 'tables', t('sidebar.object_group.tables'), , sortedTableEntries.map(buildTableNode)), + buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), , viewEntries.map(buildViewNode)), + ...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), , materializedViewEntries.map(buildMaterializedViewNode))] : []), + buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), , routineEntries.map(buildRoutineNode)), + buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), , triggerEntries.map(buildTriggerNode)), + ...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), , eventEntries.map(buildEventNode))] : []), + ]; + + replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]); + } + } else { + setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); + message.error({ content: res.message, key: `db-${key}-tables` }); + } + } catch (e: any) { + setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); + message.error({ + content: t('sidebar.message.load_table_list_failed', { error: e?.message || String(e) }), + key: `db-${key}-tables`, + }); + } finally { + loadingNodesRef.current.delete(loadKey); + } + }; + + + return { + loadDatabases, + loadJVMResources, + loadTables, + }; +}; From 293a8ff3e980ad40421ff43e4c6b22562dcce182 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 17:56:12 +0800 Subject: [PATCH 44/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=E5=AE=9E=E4=BD=93=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 236 ++++---------- .../sidebar/SidebarEntityModals.tsx | 304 ++++++++++++++++++ 3 files changed, 360 insertions(+), 181 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarEntityModals.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index f8ed6d4..3c2a900 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -69,6 +69,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/useSidebarBatchExport.ts'), readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'), readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'), + readSourceFile('./sidebar/SidebarEntityModals.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 77ff9f5..41176a8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { useSidebarBatchExport, } from './sidebar/useSidebarBatchExport'; import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; +import { SidebarEntityModals } from './sidebar/SidebarEntityModals'; import { normalizeDriverType, useSidebarTreeLoaders, @@ -5247,187 +5248,60 @@ const Sidebar: React.FC<{ )} - , - renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_title') : t('sidebar.modal.tag.create_title'), - renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_description') : t('sidebar.modal.tag.create_description') - )} - open={isCreateTagModalOpen} - centered - styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} - onOk={() => { - createTagForm.validateFields().then(values => { - if (renameViewTarget?.type === 'tag') { - // Rename - updateConnectionTag({ - ...renameViewTarget.dataRef, - name: values.name, - connectionIds: values.connectionIds || [] - }); - // update cross-connections - const allOtherTagsIds = connectionTags.filter(t => t.id !== renameViewTarget.dataRef.id).flatMap(t => t.connectionIds); - (values.connectionIds || []).forEach((cid: string) => { - if (allOtherTagsIds.includes(cid)) { - moveConnectionToTag(cid, renameViewTarget.dataRef.id); - } - }); - } else { - // Create - const tagId = Date.now().toString(); - addConnectionTag({ - id: tagId, - name: values.name, - connectionIds: values.connectionIds || [] - }); - (values.connectionIds || []).forEach((cid: string) => { - moveConnectionToTag(cid, tagId); - }); - } - setIsCreateTagModalOpen(false); - }); - }} - onCancel={() => setIsCreateTagModalOpen(false)} - > -
-
- - - - - -
- - {connections.map(conn => ( - - {conn.name} {conn.config.host ? `(${conn.config.host})` : ''} - - ))} - -
-
-
-
-
-
- - setIsCreateDbModalOpen(false)} - > -
- - - - {/* Charset option could be added here */} -
-
- - { - setIsCreateSchemaModalOpen(false); - setCreateSchemaTarget(null); - createSchemaForm.resetFields(); - }} - > -
- - - -
-
- - { - setIsRenameSchemaModalOpen(false); - setRenameSchemaTarget(null); - renameSchemaForm.resetFields(); - }} - > -
- - - -
-
- - { - setIsRenameDbModalOpen(false); - setRenameDbTarget(null); - renameDbForm.resetFields(); - }} - > -
- - - -
-
- - { - setIsRenameTableModalOpen(false); - setRenameTableTarget(null); - renameTableForm.resetFields(); - }} - > -
- - - -
-
- - { - setIsRenameViewModalOpen(false); - setRenameViewTarget(null); - renameViewForm.resetFields(); - }} - > -
- - - -
-
- - { - setIsRenameSavedQueryModalOpen(false); - setRenameSavedQueryTarget(null); - renameSavedQueryForm.resetFields(); - }} - okText={t('query_editor.action.rename_query')} - cancelText={t('common.cancel')} - > -
- - - -
-
+ diff --git a/frontend/src/components/sidebar/SidebarEntityModals.tsx b/frontend/src/components/sidebar/SidebarEntityModals.tsx new file mode 100644 index 0000000..80a3c81 --- /dev/null +++ b/frontend/src/components/sidebar/SidebarEntityModals.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { Checkbox, Form, Input, Space } from 'antd'; +import type { FormInstance } from 'antd/es/form'; +import { FolderOpenOutlined } from '@ant-design/icons'; +import Modal from '../common/ResizableDraggableModal'; +import type { SavedConnection, SavedQuery } from '../../types'; +import { t } from '../../i18n'; +import { noAutoCapInputProps } from '../../utils/inputAutoCap'; + +type ConnectionTag = { + id: string; + name: string; + connectionIds: string[]; +}; + +type SidebarEntityModalsProps = { + connections: SavedConnection[]; + connectionTags: ConnectionTag[]; + modalPanelStyle: React.CSSProperties; + modalSectionStyle: React.CSSProperties; + modalScrollSectionStyle: React.CSSProperties; + renderSidebarModalTitle: (icon: React.ReactNode, title: string, description: string) => React.ReactNode; + isCreateTagModalOpen: boolean; + setIsCreateTagModalOpen: (open: boolean) => void; + createTagForm: FormInstance; + renameViewTarget: any; + updateConnectionTag: (tag: ConnectionTag) => void; + addConnectionTag: (tag: ConnectionTag) => void; + moveConnectionToTag: (connectionId: string, tagId: string) => void; + isCreateDbModalOpen: boolean; + setIsCreateDbModalOpen: (open: boolean) => void; + createDbForm: FormInstance; + handleCreateDatabase: () => void; + isCreateSchemaModalOpen: boolean; + setIsCreateSchemaModalOpen: (open: boolean) => void; + createSchemaForm: FormInstance; + createSchemaTarget: any; + setCreateSchemaTarget: (target: any) => void; + handleCreateSchema: () => void; + isRenameSchemaModalOpen: boolean; + setIsRenameSchemaModalOpen: (open: boolean) => void; + renameSchemaForm: FormInstance; + renameSchemaTarget: any; + setRenameSchemaTarget: (target: any) => void; + handleRenameSchema: () => void; + isRenameDbModalOpen: boolean; + setIsRenameDbModalOpen: (open: boolean) => void; + renameDbForm: FormInstance; + renameDbTarget: any; + setRenameDbTarget: (target: any) => void; + handleRenameDatabase: () => void; + isRenameTableModalOpen: boolean; + setIsRenameTableModalOpen: (open: boolean) => void; + renameTableForm: FormInstance; + renameTableTarget: any; + setRenameTableTarget: (target: any) => void; + handleRenameTable: () => void; + isRenameViewModalOpen: boolean; + setIsRenameViewModalOpen: (open: boolean) => void; + renameViewForm: FormInstance; + setRenameViewTarget: (target: any) => void; + handleRenameView: () => void; + isRenameSavedQueryModalOpen: boolean; + setIsRenameSavedQueryModalOpen: (open: boolean) => void; + renameSavedQueryForm: FormInstance; + renameSavedQueryTarget: SavedQuery | null; + setRenameSavedQueryTarget: (target: SavedQuery | null) => void; + handleRenameSavedQuery: () => void; +}; + +export const SidebarEntityModals: React.FC = ({ + connections, + connectionTags, + modalPanelStyle, + modalSectionStyle, + modalScrollSectionStyle, + renderSidebarModalTitle, + isCreateTagModalOpen, + setIsCreateTagModalOpen, + createTagForm, + renameViewTarget, + updateConnectionTag, + addConnectionTag, + moveConnectionToTag, + isCreateDbModalOpen, + setIsCreateDbModalOpen, + createDbForm, + handleCreateDatabase, + isCreateSchemaModalOpen, + setIsCreateSchemaModalOpen, + createSchemaForm, + createSchemaTarget, + setCreateSchemaTarget, + handleCreateSchema, + isRenameSchemaModalOpen, + setIsRenameSchemaModalOpen, + renameSchemaForm, + renameSchemaTarget, + setRenameSchemaTarget, + handleRenameSchema, + isRenameDbModalOpen, + setIsRenameDbModalOpen, + renameDbForm, + renameDbTarget, + setRenameDbTarget, + handleRenameDatabase, + isRenameTableModalOpen, + setIsRenameTableModalOpen, + renameTableForm, + renameTableTarget, + setRenameTableTarget, + handleRenameTable, + isRenameViewModalOpen, + setIsRenameViewModalOpen, + renameViewForm, + setRenameViewTarget, + handleRenameView, + isRenameSavedQueryModalOpen, + setIsRenameSavedQueryModalOpen, + renameSavedQueryForm, + renameSavedQueryTarget, + setRenameSavedQueryTarget, + handleRenameSavedQuery, +}) => ( + <> + , + renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_title') : t('sidebar.modal.tag.create_title'), + renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_description') : t('sidebar.modal.tag.create_description'), + )} + open={isCreateTagModalOpen} + centered + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} + onOk={() => { + createTagForm.validateFields().then(values => { + if (renameViewTarget?.type === 'tag') { + updateConnectionTag({ + ...renameViewTarget.dataRef, + name: values.name, + connectionIds: values.connectionIds || [], + }); + const allOtherTagsIds = connectionTags.filter(tag => tag.id !== renameViewTarget.dataRef.id).flatMap(tag => tag.connectionIds); + (values.connectionIds || []).forEach((connectionId: string) => { + if (allOtherTagsIds.includes(connectionId)) { + moveConnectionToTag(connectionId, renameViewTarget.dataRef.id); + } + }); + } else { + const tagId = Date.now().toString(); + addConnectionTag({ + id: tagId, + name: values.name, + connectionIds: values.connectionIds || [], + }); + (values.connectionIds || []).forEach((connectionId: string) => { + moveConnectionToTag(connectionId, tagId); + }); + } + setIsCreateTagModalOpen(false); + }); + }} + onCancel={() => setIsCreateTagModalOpen(false)} + > +
+
+ + + + + +
+ + {connections.map(conn => ( + + {conn.name} {conn.config.host ? `(${conn.config.host})` : ''} + + ))} + +
+
+
+
+
+
+ + setIsCreateDbModalOpen(false)} + > +
+ + + +
+
+ + { + setIsCreateSchemaModalOpen(false); + setCreateSchemaTarget(null); + createSchemaForm.resetFields(); + }} + > +
+ + + +
+
+ + { + setIsRenameSchemaModalOpen(false); + setRenameSchemaTarget(null); + renameSchemaForm.resetFields(); + }} + > +
+ + + +
+
+ + { + setIsRenameDbModalOpen(false); + setRenameDbTarget(null); + renameDbForm.resetFields(); + }} + > +
+ + + +
+
+ + { + setIsRenameTableModalOpen(false); + setRenameTableTarget(null); + renameTableForm.resetFields(); + }} + > +
+ + + +
+
+ + { + setIsRenameViewModalOpen(false); + setRenameViewTarget(null); + renameViewForm.resetFields(); + }} + > +
+ + + +
+
+ + { + setIsRenameSavedQueryModalOpen(false); + setRenameSavedQueryTarget(null); + renameSavedQueryForm.resetFields(); + }} + okText={t('query_editor.action.rename_query')} + cancelText={t('common.cancel')} + > +
+ + + +
+
+ +); From 1dea343aa230b6aa29c36eaf74dfefb373ed37df Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 18:03:28 +0800 Subject: [PATCH 45/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=20V2=20=E6=A0=91=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 14 +- frontend/src/components/Sidebar.tsx | 139 ++-------------- .../components/sidebar/SidebarTreeTitle.tsx | 152 ++++++++++++++++++ 3 files changed, 173 insertions(+), 132 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarTreeTitle.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 3c2a900..786cf0f 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -70,6 +70,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'), readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'), readSourceFile('./sidebar/SidebarEntityModals.tsx'), + readSourceFile('./sidebar/SidebarTreeTitle.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); @@ -2340,9 +2341,9 @@ describe('Sidebar locate toolbar', () => { const externalSqlFlowStart = source.indexOf('const handleAddExternalSQLDirectory = async (node: any) => {'); const externalSqlFlowEnd = source.indexOf('const cancelSQLFileExecution = () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); - const treeTitleStart = source.indexOf('const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {'); - const treeTitleEnd = source.indexOf('const selectConnectionFromRail', treeTitleStart); - const treeTitleSource = source.slice(treeTitleStart, treeTitleEnd); + const treeTitleSource = readSourceFile('./sidebar/SidebarTreeTitle.tsx'); + const treeTitleStart = 0; + const treeTitleEnd = treeTitleSource.length; const externalSqlMenuStart = legacyMenuSource.indexOf("if (node.type === 'external-sql-root') {", legacyMenuSource.indexOf('// 已存查询节点的右键菜单')); const externalSqlMenuEnd = legacyMenuSource.indexOf("if (node.type === 'external-sql-directory') {", externalSqlMenuStart); const externalSqlMenuSource = legacyMenuSource.slice(externalSqlMenuStart, externalSqlMenuEnd); @@ -2593,11 +2594,12 @@ describe('Sidebar locate toolbar', () => { expect(tableGroupCallSource).not.toContain(rawSnippet); }); - const treeTitleStart = sidebarSource.indexOf('const renderV2TreeTitle'); - const treeTitleEnd = sidebarSource.indexOf('const selectConnectionFromRail', treeTitleStart); + const treeTitleModuleSource = readSourceFile('./sidebar/SidebarTreeTitle.tsx'); + const treeTitleStart = treeTitleModuleSource.indexOf('export const renderSidebarV2TreeTitle'); + const treeTitleEnd = treeTitleModuleSource.length; expect(treeTitleStart).toBeGreaterThanOrEqual(0); expect(treeTitleEnd).toBeGreaterThan(treeTitleStart); - const treeTitleSource = sidebarSource.slice(treeTitleStart, treeTitleEnd); + const treeTitleSource = treeTitleModuleSource.slice(treeTitleStart, treeTitleEnd); expect(treeTitleSource).toContain('const objectGroupTitle = resolveV2ObjectGroupTitle(node);'); expect(treeTitleSource).toContain('if (objectGroupTitle) return objectGroupTitle;'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 41176a8..e87f193 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { } from './sidebar/useSidebarBatchExport'; import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; import { SidebarEntityModals } from './sidebar/SidebarEntityModals'; +import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle'; import { normalizeDriverType, useSidebarTreeLoaders, @@ -108,9 +109,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbo MoreOutlined, ToolOutlined, SettingOutlined, - BarsOutlined, - StarFilled, - StarOutlined + BarsOutlined } from '@ant-design/icons'; import { buildSidebarRootConnectionToken, @@ -4151,129 +4150,17 @@ const Sidebar: React.FC<{ void fetchV2TableContextMenuStats(node); }; - const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => { - const rawTitle = String(node.title ?? ''); - const groupKey = String(node?.dataRef?.groupKey || ''); - const dragText = resolveSidebarObjectDragText(node); - if (node.type === 'v2-table-section') { - return ( - - {rawTitle} - - ); - } - const displayTitle = (() => { - if (node.type === 'queries-folder') return t('sidebar.tree.saved_queries'); - if (node.type === 'external-sql-root') return t('sidebar.external_sql.root'); - if (node.type === 'object-group') { - const objectGroupTitle = resolveV2ObjectGroupTitle(node); - if (objectGroupTitle) return objectGroupTitle; - } - return rawTitle; - })(); - const metaText = getV2TreeMetaText(node); - const isMono = node.type === 'table' - || node.type === 'view' - || node.type === 'materialized-view' - || node.type === 'db-trigger' - || node.type === 'db-event' - || node.type === 'routine' - || node.type === 'saved-query' - || node.type === 'external-sql-file'; - const titleClassName = [ - 'gn-v2-tree-title', - isMono ? 'is-mono' : '', - node.type === 'object-group' ? 'is-group' : '', - node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '', - ].filter(Boolean).join(' '); - const tablePinAction = node.type === 'table' ? ( - - ) : null; - if (node.type === 'connection') { - return ( - - {statusBadge} - - {displayTitle} - - - ); - } - return ( - <> - { - snapshotTreeSelectionBeforeDrag(); - treeDragSelectSuppressUntilRef.current = Date.now() + 600; - setIsTreeDragging(true); - event.stopPropagation(); - event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('text/plain', dragText); - event.dataTransfer.setData( - SIDEBAR_SQL_EDITOR_DRAG_MIME, - encodeSidebarSqlEditorDragPayload({ - text: dragText, - nodeType: node.type, - connectionId: String(node?.dataRef?.id || ''), - dbName: String(node?.dataRef?.dbName || ''), - }), - ); - } : undefined} - onDragEnd={dragText ? () => { - restoreTreeSelectionAfterDrag(); - setIsTreeDragging(false); - } : undefined} - > - {statusBadge} - {displayTitle} - {metaText && {metaText}} - - {tablePinAction} - - ); - }; + const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({ + node, + hoverTitle, + statusBadge, + getV2TreeMetaText, + toggleSidebarTablePinned, + snapshotTreeSelectionBeforeDrag, + restoreTreeSelectionAfterDrag, + treeDragSelectSuppressUntilRef, + setIsTreeDragging, + }); const selectConnectionFromRail = useCallback((conn: SavedConnection) => { const key = conn.id; diff --git a/frontend/src/components/sidebar/SidebarTreeTitle.tsx b/frontend/src/components/sidebar/SidebarTreeTitle.tsx new file mode 100644 index 0000000..3db6508 --- /dev/null +++ b/frontend/src/components/sidebar/SidebarTreeTitle.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { StarFilled, StarOutlined } from '@ant-design/icons'; +import { t } from '../../i18n'; +import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../../utils/sidebarSqlDrag'; +import { resolveSidebarObjectDragText } from '../sidebarCoreUtils'; +import { resolveV2ObjectGroupTitle } from './sidebarHelpers'; + +type SidebarV2TreeTitleOptions = { + node: any; + hoverTitle: string; + statusBadge: React.ReactNode; + getV2TreeMetaText: (node: any) => string; + toggleSidebarTablePinned: (node: any) => void; + snapshotTreeSelectionBeforeDrag: () => void; + restoreTreeSelectionAfterDrag: () => void; + treeDragSelectSuppressUntilRef: React.MutableRefObject; + setIsTreeDragging: (dragging: boolean) => void; +}; + +export const renderSidebarV2TreeTitle = ({ + node, + hoverTitle, + statusBadge, + getV2TreeMetaText, + toggleSidebarTablePinned, + snapshotTreeSelectionBeforeDrag, + restoreTreeSelectionAfterDrag, + treeDragSelectSuppressUntilRef, + setIsTreeDragging, +}: SidebarV2TreeTitleOptions): React.ReactNode => { + const rawTitle = String(node.title ?? ''); + const groupKey = String(node?.dataRef?.groupKey || ''); + const dragText = resolveSidebarObjectDragText(node); + if (node.type === 'v2-table-section') { + return ( + + {rawTitle} + + ); + } + const displayTitle = (() => { + if (node.type === 'queries-folder') return t('sidebar.tree.saved_queries'); + if (node.type === 'external-sql-root') return t('sidebar.external_sql.root'); + if (node.type === 'object-group') { + const objectGroupTitle = resolveV2ObjectGroupTitle(node); + if (objectGroupTitle) return objectGroupTitle; + } + return rawTitle; + })(); + const metaText = getV2TreeMetaText(node); + const isMono = node.type === 'table' + || node.type === 'view' + || node.type === 'materialized-view' + || node.type === 'db-trigger' + || node.type === 'db-event' + || node.type === 'routine' + || node.type === 'saved-query' + || node.type === 'external-sql-file'; + const titleClassName = [ + 'gn-v2-tree-title', + isMono ? 'is-mono' : '', + node.type === 'object-group' ? 'is-group' : '', + node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '', + ].filter(Boolean).join(' '); + const tablePinAction = node.type === 'table' ? ( + + ) : null; + if (node.type === 'connection') { + return ( + + {statusBadge} + + {displayTitle} + + + ); + } + return ( + <> + { + snapshotTreeSelectionBeforeDrag(); + treeDragSelectSuppressUntilRef.current = Date.now() + 600; + setIsTreeDragging(true); + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('text/plain', dragText); + event.dataTransfer.setData( + SIDEBAR_SQL_EDITOR_DRAG_MIME, + encodeSidebarSqlEditorDragPayload({ + text: dragText, + nodeType: node.type, + connectionId: String(node?.dataRef?.id || ''), + dbName: String(node?.dataRef?.dbName || ''), + }), + ); + } : undefined} + onDragEnd={dragText ? () => { + restoreTreeSelectionAfterDrag(); + setIsTreeDragging(false); + } : undefined} + > + {statusBadge} + {displayTitle} + {metaText && {metaText}} + + {tablePinAction} + + ); +}; From 13705f9098c6645b456da564f1c8985a1f5749df Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 18:08:54 +0800 Subject: [PATCH 46/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8A=BD=E5=87=BA=20V2=20=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 1 + frontend/src/components/Sidebar.tsx | 461 ++------------- .../sidebar/useSidebarV2ContextMenu.tsx | 524 ++++++++++++++++++ 3 files changed, 565 insertions(+), 421 deletions(-) create mode 100644 frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 786cf0f..8d97182 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -71,6 +71,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'), readSourceFile('./sidebar/SidebarEntityModals.tsx'), readSourceFile('./sidebar/SidebarTreeTitle.tsx'), + readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e87f193..be22253 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -20,6 +20,10 @@ import { import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals'; import { SidebarEntityModals } from './sidebar/SidebarEntityModals'; import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle'; +import { + useSidebarV2ContextMenu, + type SidebarContextMenuState, +} from './sidebar/useSidebarV2ContextMenu'; import { normalizeDriverType, useSidebarTreeLoaders, @@ -239,19 +243,6 @@ export { export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils'; const { Search } = Input; -type SidebarContextMenuState = { - x: number; - y: number; - sourceX?: number; - sourceY?: number; - items: MenuProps['items']; - kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group'; - node?: any; - rootClassName?: string; - overlayStyle?: React.CSSProperties; - maxHeight?: number; -}; - const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; @@ -593,9 +584,6 @@ const Sidebar: React.FC<{ activeContext: null, }); const connectionReloadSignaturesRef = useRef>({}); - const [contextMenu, setContextMenu] = useState(null); - const contextMenuPortalRef = useRef(null); - const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]); const unmatchedSavedQueries = useMemo( @@ -3744,411 +3732,42 @@ const Sidebar: React.FC<{ justifyContent: 'center', }; - const connectionStatusMap = useMemo(() => { - const statusMap = new Map(); - const sortedConnectionIds = connections - .map((conn) => conn.id) - .sort((a, b) => b.length - a.length); - connections.forEach((conn) => { - statusMap.set(conn.id, 'idle'); - }); - Object.entries(connectionStates).forEach(([key, value]) => { - const ownState = statusMap.get(key); - if (ownState !== undefined) { - statusMap.set(key, value === 'success' ? 'live' : 'error'); - return; - } - if (value !== 'success') return; - const ownerId = sortedConnectionIds.find((id) => key.startsWith(`${id}-`)); - if (ownerId && statusMap.get(ownerId) === 'idle') { - statusMap.set(ownerId, 'live'); - } - }); - return statusMap; - }, [connectionStates, connections]); - - const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => { - return connectionStatusMap.get(connectionId) || 'idle'; - }, [connectionStatusMap]); - - const openV2ConnectionContextMenu = ( - event: React.MouseEvent, - connOrNode: SavedConnection | TreeNode, - ) => { - event.preventDefault(); - event.stopPropagation(); - const node = (connOrNode as TreeNode).type === 'connection' - ? connOrNode as TreeNode - : getConnectionNodeForAction(connOrNode as SavedConnection); - if (!node?.key || !node?.dataRef) return; - const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); - setContextMenu({ - x: position.x, - y: position.y, - sourceX: event.clientX, - sourceY: event.clientY, - items: [], - kind: 'v2-connection', - node, - rootClassName: 'gn-v2-table-context-menu-popup', - overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, - maxHeight: position.maxHeight, - }); - }; - - const getV2TreeMetaText = (node: any): string => { - if (node.type === 'tag') { - const count = flattenConnectionNodes(node.children || []).length; - return count > 0 ? count.toLocaleString() : ''; - } - if (node.type === 'database') { - const count = v2TreeMetrics.databaseTableCounts.get(node.key) || 0; - return count > 0 ? count.toLocaleString() : ''; - } - if (node.type === 'object-group') { - const count = v2TreeMetrics.objectGroupCounts.get(node.key) || 0; - return count > 0 ? count.toLocaleString() : ''; - } - if (node.type === 'redis-db') { - const match = String(node.title || '').match(/\((\d+)\)/); - return match?.[1] || ''; - } - if (node.type === 'table') { - const rowCount = Number(node?.dataRef?.rowCount); - return Number.isFinite(rowCount) && rowCount >= 0 ? formatSidebarRowCount(rowCount) : ''; - } - return ''; - }; - - const getV2TableContextMenuStatsKey = (node: any): string => { - const id = String(node?.dataRef?.id || ''); - const dbName = String(node?.dataRef?.dbName || ''); - const tableName = String(node?.dataRef?.tableName || node?.title || ''); - return `${id}::${dbName}::${tableName}`; - }; - - const readNumericMetadataValue = (row: Record, keys: string[]): number | undefined => { - const value = getCaseInsensitiveRawValue(row, keys); - if (value === undefined || value === null || value === '') return undefined; - const normalized = Number(String(value).replace(/,/g, '')); - return Number.isFinite(normalized) ? normalized : undefined; - }; - - const buildV2TableStatusSQL = (node: any): string => { - const conn = node.dataRef as SavedConnection & { dbName?: string; tableName?: string; schemaName?: string }; - const dialect = getMetadataDialect(conn); - const dbName = String(conn?.dbName || '').trim(); - const tableName = String(conn?.tableName || node?.title || '').trim(); - const objectName = extractObjectName(tableName); - const schemaName = String(conn?.schemaName || splitQualifiedName(tableName).schemaName || '').trim(); - switch (dialect) { - case 'mysql': - case 'starrocks': - return [ - 'SELECT TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine', - 'FROM information_schema.tables', - `WHERE table_schema = '${escapeSQLLiteral(dbName)}'`, - `AND table_name = '${escapeSQLLiteral(objectName)}'`, - 'LIMIT 1', - ].join('\n'); - case 'postgres': - case 'kingbase': - case 'vastbase': - case 'highgo': - case 'opengauss': - case 'gaussdb': { - const schema = schemaName || 'public'; - return [ - "SELECT c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length, 'heap' AS engine", - 'FROM pg_class c', - 'JOIN pg_namespace n ON n.oid = c.relnamespace', - "WHERE c.relkind = 'r'", - `AND n.nspname = '${escapeSQLLiteral(schema)}'`, - `AND c.relname = '${escapeSQLLiteral(objectName)}'`, - 'LIMIT 1', - ].join('\n'); - } - case 'sqlserver': { - const safeTable = tableName.replace(/'/g, "''"); - return [ - 'SELECT SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length, NULL AS engine', - 'FROM sys.tables t', - 'JOIN sys.indexes i ON t.object_id = i.object_id', - 'JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id', - 'JOIN sys.allocation_units a ON p.partition_id = a.container_id', - `WHERE t.object_id = OBJECT_ID('${safeTable}')`, - ].join('\n'); - } - case 'clickhouse': - return [ - 'SELECT total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length, engine AS engine', - 'FROM system.tables', - `WHERE database = '${escapeSQLLiteral(dbName)}'`, - `AND name = '${escapeSQLLiteral(objectName)}'`, - 'LIMIT 1', - ].join('\n'); - case 'oracle': - case 'dm': { - const owner = (schemaName || dbName || '').toUpperCase(); - return [ - 'SELECT num_rows AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine', - 'FROM all_tables', - `WHERE owner = '${escapeSQLLiteral(owner)}'`, - `AND table_name = '${escapeSQLLiteral(objectName.toUpperCase())}'`, - 'FETCH FIRST 1 ROWS ONLY', - ].join('\n'); - } - case 'sqlite': - case 'duckdb': - return `SELECT COUNT(*) AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine FROM ${tableName}`; - default: - return ''; - } - }; - - const renderV2TableContextMenu = (node: any) => { - const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); - const statsKey = getV2TableContextMenuStatsKey(node); - const stats = v2TableContextMenuStats[statsKey]; - const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; - const supportsMessagePublish = Boolean(resolveMessagePublishTarget(node)); - const isPinned = isSidebarTablePinned( - pinnedSidebarTables, - String(node?.dataRef?.id || ''), - String(node?.dataRef?.dbName || ''), - tableName, - String(node?.dataRef?.schemaName || ''), - ); - return ( - { - setContextMenu(null); - handleV2TableContextMenuAction(node, action); - }} - /> - ); - }; - - const renderV2TableGroupContextMenu = (node: any) => { - const groupData = node.dataRef || {}; - const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; - const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; - return ( - { - setContextMenu(null); - handleV2TableGroupContextMenuAction(node, action); - }} - /> - ); - }; - - const renderV2DatabaseContextMenu = (node: any) => { - const dialect = getMetadataDialect(node.dataRef as SavedConnection); - const capabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); - return ( - { - setContextMenu(null); - handleV2DatabaseContextMenuAction(node, action); - }} - /> - ); - }; - - const handleV2SchemaContextMenuAction = (node: any, action: V2SchemaContextMenuActionKey) => { - switch (action) { - case 'rename-schema': - openRenameSchemaModal(node); - return; - case 'refresh-schema': - void loadTables(getDatabaseNodeRef(node?.dataRef, String(node?.dataRef?.dbName || '').trim())); - return; - case 'export-schema': - void handleExportSchemaSQL(node, false); - return; - case 'backup-schema-sql': - void handleExportSchemaSQL(node, true); - return; - case 'drop-schema': - handleDeleteSchema(node); - return; - default: - return; - } - }; - - const renderV2SchemaContextMenu = (node: any) => ( - { - setContextMenu(null); - handleV2SchemaContextMenuAction(node, action); - }} - /> - ); - - const renderV2ConnectionContextMenu = (node: any) => { - const conn = node.dataRef as SavedConnection; - const capabilities = getDataSourceCapabilities(conn?.config); - const currentTagId = connectionTags.find((tag) => tag.connectionIds.includes(String(conn.id || node.key)))?.id || ''; - return ( - ({ - id: tag.id, - name: tag.name, - selected: tag.id === currentTagId, - }))} - onAction={(action) => { - setContextMenu(null); - handleV2ConnectionContextMenuAction(node, action); - }} - /> - ); - }; - - const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => ( - { - setContextMenu(null); - handleV2ConnectionGroupContextMenuAction(group, action); - }} - /> - ); - - const renderV2SidebarContextMenuContent = (menu: SidebarContextMenuState) => { - if (!menu.node) return null; - if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); - if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); - if (menu.kind === 'v2-schema') return renderV2SchemaContextMenu(menu.node); - if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); - if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); - if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); - return null; - }; - - useEffect(() => { - if (!contextMenu?.kind) return; - const onPointerDown = (event: MouseEvent) => { - const target = event.target instanceof Node ? event.target : null; - if (target && contextMenuPortalRef.current?.contains(target)) return; - setContextMenu(null); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setContextMenu(null); - }; - document.addEventListener('mousedown', onPointerDown); - document.addEventListener('keydown', onKeyDown); - return () => { - document.removeEventListener('mousedown', onPointerDown); - document.removeEventListener('keydown', onKeyDown); - }; - }, [contextMenu?.kind]); - - useEffect(() => { - if (!contextMenu?.kind) return; - const frame = requestAnimationFrame(() => { - const portal = contextMenuPortalRef.current; - if (!portal) return; - const rect = portal.getBoundingClientRect(); - const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; - const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); - const position = resolveSidebarContextMenuPosition(contextMenu.sourceX ?? contextMenu.x, contextMenu.sourceY ?? contextMenu.y, { - width: rect.width || SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, - height: measuredHeight || SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, - }); - setContextMenu(prev => { - if (!prev?.kind) return prev; - if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; - return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; - }); - }); - return () => cancelAnimationFrame(frame); - }, [contextMenu?.kind, contextMenu?.x, contextMenu?.y]); - - const fetchV2TableContextMenuStats = async (node: any) => { - const statsKey = getV2TableContextMenuStatsKey(node); - if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return; - const sql = buildV2TableStatusSQL(node); - if (!sql) { - setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); - return; - } - - setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { ...prev[statsKey], loading: true } })); - const startTime = Date.now(); - try { - const conn = node.dataRef; - const res = await DBQuery(buildRuntimeConfig(conn, conn.dbName) as any, conn.dbName || '', sql); - if (!res.success || !Array.isArray(res.data) || res.data.length === 0) { - setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); - return; - } - const row = res.data[0] as Record; - setV2TableContextMenuStats(prev => ({ - ...prev, - [statsKey]: { - rowCount: readNumericMetadataValue(row, ['table_rows', 'TABLE_ROWS', 'rows', 'num_rows', 'reltuples', 'total_rows']), - dataLength: readNumericMetadataValue(row, ['data_length', 'DATA_LENGTH', 'total_bytes']), - indexLength: readNumericMetadataValue(row, ['index_length', 'INDEX_LENGTH']), - engine: getCaseInsensitiveValue(row, ['engine', 'ENGINE']), - }, - })); - addSqlLog({ - id: `${Date.now()}-table-stats`, - timestamp: Date.now(), - sql, - status: 'success', - duration: Date.now() - startTime, - dbName: conn.dbName, - }); - } catch (error: any) { - setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); - addSqlLog({ - id: `${Date.now()}-table-stats-error`, - timestamp: Date.now(), - sql, - status: 'error', - duration: Date.now() - startTime, - message: error?.message || String(error), - dbName: node?.dataRef?.dbName, - }); - } - }; - - const refreshV2TableContextMenuStats = (node: any) => { - const statsKey = getV2TableContextMenuStatsKey(node); - setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { loading: true } })); - void fetchV2TableContextMenuStats(node); - }; + const { + contextMenu, + setContextMenu, + contextMenuPortalRef, + buildRailConnectionStatus, + openV2ConnectionContextMenu, + getV2TreeMetaText, + renderV2SidebarContextMenuContent, + fetchV2TableContextMenuStats, + refreshV2TableContextMenuStats, + } = useSidebarV2ContextMenu({ + connections, + connectionStates, + connectionTags, + activeShortcutPlatform, + flattenConnectionNodes, + v2TreeMetrics, + tableSortPreference, + pinnedSidebarTables, + getConnectionNodeForAction, + buildRuntimeConfig, + extractObjectName, + isPostgresSchemaDialect, + loadTables, + getDatabaseNodeRef, + handleExportSchemaSQL, + handleDeleteSchema, + openRenameSchemaModal, + resolveMessagePublishTarget, + addSqlLog, + handleV2TableContextMenuAction, + handleV2TableGroupContextMenuAction, + handleV2DatabaseContextMenuAction, + handleV2ConnectionContextMenuAction, + handleV2ConnectionGroupContextMenuAction, + }); const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({ node, diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx new file mode 100644 index 0000000..17caa5a --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx @@ -0,0 +1,524 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + V2DatabaseContextMenuView, + V2ConnectionGroupContextMenuView, + V2ConnectionContextMenuView, + V2SchemaContextMenuView, + V2TableContextMenuView, + V2TableGroupContextMenuView, + type V2DatabaseContextMenuActionKey, + type V2ConnectionGroupContextMenuActionKey, + type V2ConnectionContextMenuActionKey, + type V2SchemaContextMenuActionKey, + type V2TableContextMenuActionKey, + type V2TableContextMenuStats, + type V2TableGroupContextMenuActionKey, +} from '../V2TableContextMenu'; +import type { SavedConnection } from '../../types'; +import { t } from '../../i18n'; +import { DBQuery } from '../../../wailsjs/go/app/App'; +import { getCaseInsensitiveRawValue, getCaseInsensitiveValue, getMetadataDialect, splitQualifiedName, buildSidebarTableStatusSQL, escapeSQLLiteral } from './sidebarMetadataLoaders'; +import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'; +import { resolveConnectionHostSummary } from '../../utils/tabDisplay'; +import { resolveConnectionIconType } from '../../utils/connectionVisual'; +import { formatSidebarRowCount } from './sidebarHelpers'; +import { isSidebarTablePinned, type SidebarTreeNode as TreeNode, type V2RailConnectionGroup } from '../sidebarV2Utils'; +import { getTableDataDangerActionMeta, supportsTableTruncateAction } from '../tableDataDangerActions'; +import { + SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, + SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, + resolveSidebarContextMenuPosition, +} from '../sidebarCoreUtils'; + +export type SidebarContextMenuState = { + x: number; + y: number; + sourceX?: number; + sourceY?: number; + items: any; + kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group'; + node?: any; + rootClassName?: string; + overlayStyle?: React.CSSProperties; + maxHeight?: number; +}; + +type SidebarV2ContextMenuOptions = { + connections: SavedConnection[]; + connectionStates: Record; + connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>; + activeShortcutPlatform: any; + flattenConnectionNodes: (nodes: TreeNode[]) => TreeNode[]; + v2TreeMetrics: { + databaseTableCounts: Map; + objectGroupCounts: Map; + }; + tableSortPreference: Record; + pinnedSidebarTables: any[]; + getConnectionNodeForAction: (conn: SavedConnection) => TreeNode; + buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any; + extractObjectName: (fullName: string) => string; + isPostgresSchemaDialect: (dialect: string) => boolean; + loadTables: (node: any) => Promise; + getDatabaseNodeRef: (connRef: any, dbName: string) => any; + handleExportSchemaSQL: (node: any, includeData: boolean) => Promise; + handleDeleteSchema: (node: any) => void; + openRenameSchemaModal: (node: any) => void; + resolveMessagePublishTarget: (node: any) => unknown; + addSqlLog: (log: any) => void; + handleV2TableContextMenuAction: (node: any, action: V2TableContextMenuActionKey) => void; + handleV2TableGroupContextMenuAction: (node: any, action: V2TableGroupContextMenuActionKey) => void; + handleV2DatabaseContextMenuAction: (node: any, action: V2DatabaseContextMenuActionKey) => void; + handleV2ConnectionContextMenuAction: (node: any, action: V2ConnectionContextMenuActionKey) => void; + handleV2ConnectionGroupContextMenuAction: (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => void; +}; + +export const useSidebarV2ContextMenu = ({ + connections, + connectionStates, + connectionTags, + activeShortcutPlatform, + flattenConnectionNodes, + v2TreeMetrics, + tableSortPreference, + pinnedSidebarTables, + getConnectionNodeForAction, + buildRuntimeConfig, + extractObjectName, + isPostgresSchemaDialect, + loadTables, + getDatabaseNodeRef, + handleExportSchemaSQL, + handleDeleteSchema, + openRenameSchemaModal, + resolveMessagePublishTarget, + addSqlLog, + handleV2TableContextMenuAction, + handleV2TableGroupContextMenuAction, + handleV2DatabaseContextMenuAction, + handleV2ConnectionContextMenuAction, + handleV2ConnectionGroupContextMenuAction, +}: SidebarV2ContextMenuOptions) => { + const [contextMenu, setContextMenu] = useState(null); + const contextMenuPortalRef = useRef(null); + const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); + + const connectionStatusMap = useMemo(() => { + const statusMap = new Map(); + const sortedConnectionIds = connections + .map((conn) => conn.id) + .sort((a, b) => b.length - a.length); + connections.forEach((conn) => { + statusMap.set(conn.id, 'idle'); + }); + Object.entries(connectionStates).forEach(([key, value]) => { + const ownState = statusMap.get(key); + if (ownState !== undefined) { + statusMap.set(key, value === 'success' ? 'live' : 'error'); + return; + } + if (value !== 'success') return; + const ownerId = sortedConnectionIds.find((id) => key.startsWith(`${id}-`)); + if (ownerId && statusMap.get(ownerId) === 'idle') { + statusMap.set(ownerId, 'live'); + } + }); + return statusMap; + }, [connectionStates, connections]); + + const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => { + return connectionStatusMap.get(connectionId) || 'idle'; + }, [connectionStatusMap]); + + const openV2ConnectionContextMenu = ( + event: React.MouseEvent, + connOrNode: SavedConnection | TreeNode, + ) => { + event.preventDefault(); + event.stopPropagation(); + const node = (connOrNode as TreeNode).type === 'connection' + ? connOrNode as TreeNode + : getConnectionNodeForAction(connOrNode as SavedConnection); + if (!node?.key || !node?.dataRef) return; + const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); + setContextMenu({ + x: position.x, + y: position.y, + sourceX: event.clientX, + sourceY: event.clientY, + items: [], + kind: 'v2-connection', + node, + rootClassName: 'gn-v2-table-context-menu-popup', + overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, + maxHeight: position.maxHeight, + }); + }; + + const getV2TreeMetaText = (node: any): string => { + if (node.type === 'tag') { + const count = flattenConnectionNodes(node.children || []).length; + return count > 0 ? count.toLocaleString() : ''; + } + if (node.type === 'database') { + const count = v2TreeMetrics.databaseTableCounts.get(node.key) || 0; + return count > 0 ? count.toLocaleString() : ''; + } + if (node.type === 'object-group') { + const count = v2TreeMetrics.objectGroupCounts.get(node.key) || 0; + return count > 0 ? count.toLocaleString() : ''; + } + if (node.type === 'redis-db') { + const match = String(node.title || '').match(/\((\d+)\)/); + return match?.[1] || ''; + } + if (node.type === 'table') { + const rowCount = Number(node?.dataRef?.rowCount); + return Number.isFinite(rowCount) && rowCount >= 0 ? formatSidebarRowCount(rowCount) : ''; + } + return ''; + }; + + const getV2TableContextMenuStatsKey = (node: any): string => { + const id = String(node?.dataRef?.id || ''); + const dbName = String(node?.dataRef?.dbName || ''); + const tableName = String(node?.dataRef?.tableName || node?.title || ''); + return `${id}::${dbName}::${tableName}`; + }; + + const readNumericMetadataValue = (row: Record, keys: string[]): number | undefined => { + const value = getCaseInsensitiveRawValue(row, keys); + if (value === undefined || value === null || value === '') return undefined; + const normalized = Number(String(value).replace(/,/g, '')); + return Number.isFinite(normalized) ? normalized : undefined; + }; + + const buildV2TableStatusSQL = (node: any): string => { + const conn = node.dataRef as SavedConnection & { dbName?: string; tableName?: string; schemaName?: string }; + const dialect = getMetadataDialect(conn); + const dbName = String(conn?.dbName || '').trim(); + const tableName = String(conn?.tableName || node?.title || '').trim(); + const objectName = extractObjectName(tableName); + const schemaName = String(conn?.schemaName || splitQualifiedName(tableName).schemaName || '').trim(); + switch (dialect) { + case 'mysql': + case 'starrocks': + return [ + 'SELECT TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine', + 'FROM information_schema.tables', + `WHERE table_schema = '${escapeSQLLiteral(dbName)}'`, + `AND table_name = '${escapeSQLLiteral(objectName)}'`, + 'LIMIT 1', + ].join('\n'); + case 'postgres': + case 'kingbase': + case 'vastbase': + case 'highgo': + case 'opengauss': + case 'gaussdb': { + const schema = schemaName || 'public'; + return [ + "SELECT c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length, 'heap' AS engine", + 'FROM pg_class c', + 'JOIN pg_namespace n ON n.oid = c.relnamespace', + "WHERE c.relkind = 'r'", + `AND n.nspname = '${escapeSQLLiteral(schema)}'`, + `AND c.relname = '${escapeSQLLiteral(objectName)}'`, + 'LIMIT 1', + ].join('\n'); + } + case 'sqlserver': { + const safeTable = tableName.replace(/'/g, "''"); + return [ + 'SELECT SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length, NULL AS engine', + 'FROM sys.tables t', + 'JOIN sys.indexes i ON t.object_id = i.object_id', + 'JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id', + 'JOIN sys.allocation_units a ON p.partition_id = a.container_id', + `WHERE t.object_id = OBJECT_ID('${safeTable}')`, + ].join('\n'); + } + case 'clickhouse': + return [ + 'SELECT total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length, engine AS engine', + 'FROM system.tables', + `WHERE database = '${escapeSQLLiteral(dbName)}'`, + `AND name = '${escapeSQLLiteral(objectName)}'`, + 'LIMIT 1', + ].join('\n'); + case 'oracle': + case 'dm': { + const owner = (schemaName || dbName || '').toUpperCase(); + return [ + 'SELECT num_rows AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine', + 'FROM all_tables', + `WHERE owner = '${escapeSQLLiteral(owner)}'`, + `AND table_name = '${escapeSQLLiteral(objectName.toUpperCase())}'`, + 'FETCH FIRST 1 ROWS ONLY', + ].join('\n'); + } + case 'sqlite': + case 'duckdb': + return `SELECT COUNT(*) AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine FROM ${tableName}`; + default: + return ''; + } + }; + + const renderV2TableContextMenu = (node: any) => { + const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); + const statsKey = getV2TableContextMenuStatsKey(node); + const stats = v2TableContextMenuStats[statsKey]; + const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; + const supportsMessagePublish = Boolean(resolveMessagePublishTarget(node)); + const isPinned = isSidebarTablePinned( + pinnedSidebarTables, + String(node?.dataRef?.id || ''), + String(node?.dataRef?.dbName || ''), + tableName, + String(node?.dataRef?.schemaName || ''), + ); + return ( + { + setContextMenu(null); + handleV2TableContextMenuAction(node, action); + }} + /> + ); + }; + + const renderV2TableGroupContextMenu = (node: any) => { + const groupData = node.dataRef || {}; + const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; + const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; + return ( + { + setContextMenu(null); + handleV2TableGroupContextMenuAction(node, action); + }} + /> + ); + }; + + const renderV2DatabaseContextMenu = (node: any) => { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const capabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); + return ( + { + setContextMenu(null); + handleV2DatabaseContextMenuAction(node, action); + }} + /> + ); + }; + + const handleV2SchemaContextMenuAction = (node: any, action: V2SchemaContextMenuActionKey) => { + switch (action) { + case 'rename-schema': + openRenameSchemaModal(node); + return; + case 'refresh-schema': + void loadTables(getDatabaseNodeRef(node?.dataRef, String(node?.dataRef?.dbName || '').trim())); + return; + case 'export-schema': + void handleExportSchemaSQL(node, false); + return; + case 'backup-schema-sql': + void handleExportSchemaSQL(node, true); + return; + case 'drop-schema': + handleDeleteSchema(node); + return; + default: + return; + } + }; + + const renderV2SchemaContextMenu = (node: any) => ( + { + setContextMenu(null); + handleV2SchemaContextMenuAction(node, action); + }} + /> + ); + + const renderV2ConnectionContextMenu = (node: any) => { + const conn = node.dataRef as SavedConnection; + const capabilities = getDataSourceCapabilities(conn?.config); + const currentTagId = connectionTags.find((tag) => tag.connectionIds.includes(String(conn.id || node.key)))?.id || ''; + return ( + ({ + id: tag.id, + name: tag.name, + selected: tag.id === currentTagId, + }))} + onAction={(action) => { + setContextMenu(null); + handleV2ConnectionContextMenuAction(node, action); + }} + /> + ); + }; + + const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => ( + { + setContextMenu(null); + handleV2ConnectionGroupContextMenuAction(group, action); + }} + /> + ); + + const renderV2SidebarContextMenuContent = (menu: SidebarContextMenuState) => { + if (!menu.node) return null; + if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); + if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); + if (menu.kind === 'v2-schema') return renderV2SchemaContextMenu(menu.node); + if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); + if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); + if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); + return null; + }; + + useEffect(() => { + if (!contextMenu?.kind) return; + const onPointerDown = (event: MouseEvent) => { + const target = event.target instanceof Node ? event.target : null; + if (target && contextMenuPortalRef.current?.contains(target)) return; + setContextMenu(null); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setContextMenu(null); + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [contextMenu?.kind]); + + useEffect(() => { + if (!contextMenu?.kind) return; + const frame = requestAnimationFrame(() => { + const portal = contextMenuPortalRef.current; + if (!portal) return; + const rect = portal.getBoundingClientRect(); + const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; + const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); + const position = resolveSidebarContextMenuPosition(contextMenu.sourceX ?? contextMenu.x, contextMenu.sourceY ?? contextMenu.y, { + width: rect.width || SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, + height: measuredHeight || SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, + }); + setContextMenu(prev => { + if (!prev?.kind) return prev; + if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; + return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; + }); + }); + return () => cancelAnimationFrame(frame); + }, [contextMenu?.kind, contextMenu?.x, contextMenu?.y]); + + const fetchV2TableContextMenuStats = async (node: any) => { + const statsKey = getV2TableContextMenuStatsKey(node); + if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return; + const sql = buildV2TableStatusSQL(node); + if (!sql) { + setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); + return; + } + + setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { ...prev[statsKey], loading: true } })); + const startTime = Date.now(); + try { + const conn = node.dataRef; + const res = await DBQuery(buildRuntimeConfig(conn, conn.dbName) as any, conn.dbName || '', sql); + if (!res.success || !Array.isArray(res.data) || res.data.length === 0) { + setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); + return; + } + const row = res.data[0] as Record; + setV2TableContextMenuStats(prev => ({ + ...prev, + [statsKey]: { + rowCount: readNumericMetadataValue(row, ['table_rows', 'TABLE_ROWS', 'rows', 'num_rows', 'reltuples', 'total_rows']), + dataLength: readNumericMetadataValue(row, ['data_length', 'DATA_LENGTH', 'total_bytes']), + indexLength: readNumericMetadataValue(row, ['index_length', 'INDEX_LENGTH']), + engine: getCaseInsensitiveValue(row, ['engine', 'ENGINE']), + }, + })); + addSqlLog({ + id: `${Date.now()}-table-stats`, + timestamp: Date.now(), + sql, + status: 'success', + duration: Date.now() - startTime, + dbName: conn.dbName, + }); + } catch (error: any) { + setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); + addSqlLog({ + id: `${Date.now()}-table-stats-error`, + timestamp: Date.now(), + sql, + status: 'error', + duration: Date.now() - startTime, + message: error?.message || String(error), + dbName: node?.dataRef?.dbName, + }); + } + }; + + const refreshV2TableContextMenuStats = (node: any) => { + const statsKey = getV2TableContextMenuStatsKey(node); + setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { loading: true } })); + void fetchV2TableContextMenuStats(node); + }; + + + return { + contextMenu, + setContextMenu, + contextMenuPortalRef, + buildRailConnectionStatus, + openV2ConnectionContextMenu, + getV2TreeMetaText, + renderV2SidebarContextMenuContent, + fetchV2TableContextMenuStats, + refreshV2TableContextMenuStats, + }; +}; From 6179c3fbd96df4913443cc90f745b48b4677cc4c Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 18:46:08 +0800 Subject: [PATCH 47/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):?= =?UTF-8?q?=20=E6=8B=86=E5=88=86=E5=8A=A8=E4=BD=9C=E4=B8=8E=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 57 +- frontend/src/components/Sidebar.tsx | 2466 ++--------------- .../components/Sidebar.v2-metadata.test.tsx | 2 +- .../sidebar/useSidebarCommandSearchRunner.ts | 168 ++ .../sidebar/useSidebarObjectActions.tsx | 1210 ++++++++ .../sidebar/useSidebarSearchModel.tsx | 669 +++++ .../sidebar/useSidebarTitleRender.tsx | 168 ++ .../sidebar/useSidebarV2ActionHandlers.tsx | 538 ++++ 8 files changed, 3053 insertions(+), 2225 deletions(-) create mode 100644 frontend/src/components/sidebar/useSidebarCommandSearchRunner.ts create mode 100644 frontend/src/components/sidebar/useSidebarObjectActions.tsx create mode 100644 frontend/src/components/sidebar/useSidebarSearchModel.tsx create mode 100644 frontend/src/components/sidebar/useSidebarTitleRender.tsx create mode 100644 frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 8d97182..83e0e5a 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -72,6 +72,11 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/SidebarEntityModals.tsx'), readSourceFile('./sidebar/SidebarTreeTitle.tsx'), readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx'), + readSourceFile('./sidebar/useSidebarObjectActions.tsx'), + readSourceFile('./sidebar/useSidebarSearchModel.tsx'), + readSourceFile('./sidebar/useSidebarV2ActionHandlers.tsx'), + readSourceFile('./sidebar/useSidebarCommandSearchRunner.ts'), + readSourceFile('./sidebar/useSidebarTitleRender.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); @@ -1319,17 +1324,17 @@ describe('Sidebar locate toolbar', () => { ], }]; - expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node) => node.key)).toEqual([ + expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node: { key: string }) => node.key)).toEqual([ 'conn-main-queries', 'conn-main-tables', 'conn-main-views', 'conn-main-routines', 'conn-main-events', ]); - expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node) => node.key)).toEqual(['conn-main-tables']); - expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node) => node.key)).toEqual(['conn-main-views']); - expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node) => node.key)).toEqual(['conn-main-routines']); - expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node) => node.key)).toEqual(['conn-main-events']); + expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-tables']); + expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-views']); + expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-routines']); + expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-events']); }); it('hides external SQL roots from v2 object kind filters', () => { @@ -1362,11 +1367,11 @@ describe('Sidebar locate toolbar', () => { }, ]; - expect(filterV2ExplorerTreeByKind(tree, 'all').map((node) => node.key)).toEqual([ + expect(filterV2ExplorerTreeByKind(tree, 'all').map((node: { key: string }) => node.key)).toEqual([ 'conn-main', 'external-sql-root', ]); - expect(filterV2ExplorerTreeByKind(tree, 'tables').map((node) => node.key)).toEqual(['conn-main']); + expect(filterV2ExplorerTreeByKind(tree, 'tables').map((node: { key: string }) => node.key)).toEqual(['conn-main']); }); it('adds rename to the saved query context menu', () => { @@ -2042,25 +2047,27 @@ describe('Sidebar locate toolbar', () => { it('routes v2 database context menu shell copy through i18n wrappers in Sidebar', () => { const source = readSidebarSource(); - const createSchemaSource = source.slice( - source.indexOf('const openCreateSchemaModal = (node: any) => {'), - source.indexOf('const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {'), + const objectActionsSource = readSourceFile('./sidebar/useSidebarObjectActions.tsx'); + const v2ActionHandlersSource = readSourceFile('./sidebar/useSidebarV2ActionHandlers.tsx'); + const createSchemaSource = objectActionsSource.slice( + objectActionsSource.indexOf('const openCreateSchemaModal = (node: any) => {'), + objectActionsSource.indexOf('const openRenameSchemaModal = (node: any) => {'), ); const runSqlSource = source.slice( source.indexOf('const handleRunSQLFile = async (node: any) => {'), source.indexOf('const handleOpenSQLFileFromToolbar = async () => {'), ); - const databaseShellSource = source.slice( - source.indexOf('const handleRenameDatabase = async () => {'), - source.indexOf('const handleRenameTable = async () => {'), + const databaseShellSource = objectActionsSource.slice( + objectActionsSource.indexOf('const handleRenameDatabase = async () => {'), + objectActionsSource.indexOf('const handleRenameTable = async () => {'), ); - const databaseActionSource = source.slice( - source.indexOf('const closeDatabaseNode = (node: any) => {'), - source.indexOf('const refreshConnectionNode = (node: any) => {'), + const databaseActionSource = v2ActionHandlersSource.slice( + v2ActionHandlersSource.indexOf('const closeDatabaseNode = (node: any) => {'), + v2ActionHandlersSource.indexOf('const openDatabaseQuery = (node: any) => {'), ); - const starRocksSource = source.slice( - source.indexOf('const openCreateStarRocksMaterializedView = (node: any) => {'), - source.indexOf('const openCreateStarRocksRollup = (node: any) => {'), + const starRocksSource = objectActionsSource.slice( + objectActionsSource.indexOf('const openCreateStarRocksMaterializedView = (node: any) => {'), + objectActionsSource.indexOf('const openCreateStarRocksRollup = (node: any) => {'), ); expect(createSchemaSource).toContain("message.warning(t('sidebar.message.schema_create_unsupported'))"); @@ -2354,9 +2361,9 @@ describe('Sidebar locate toolbar', () => { const externalSqlFileMenuStart = externalSqlDirectoryMenuEnd; const externalSqlFileMenuEnd = legacyMenuSource.indexOf('return [];', externalSqlFileMenuStart); const externalSqlFileMenuSource = legacyMenuSource.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd); - const titleRenderStart = source.indexOf('const titleRender = (node: any) => {'); - const titleRenderEnd = source.indexOf('const handleDrop = (info: any) => {', titleRenderStart); - const titleRenderSource = source.slice(titleRenderStart, titleRenderEnd); + const titleRenderSource = readSourceFile('./sidebar/useSidebarTitleRender.tsx'); + const titleRenderStart = titleRenderSource.indexOf('export const useSidebarTitleRender ='); + const titleRenderEnd = titleRenderSource.length; [ loadTablesStart, @@ -2621,11 +2628,11 @@ describe('Sidebar locate toolbar', () => { expect(objectGroupTitleSource).toContain(catalogLookup); }); - const titleRenderStart = sidebarSource.indexOf('const titleRender = (node: any) => {'); - const titleRenderEnd = sidebarSource.indexOf('const handleDrop = (info: any) => {', titleRenderStart); + const titleRenderSource = readSourceFile('./sidebar/useSidebarTitleRender.tsx'); + const titleRenderStart = titleRenderSource.indexOf('export const useSidebarTitleRender ='); + const titleRenderEnd = titleRenderSource.length; expect(titleRenderStart).toBeGreaterThanOrEqual(0); expect(titleRenderEnd).toBeGreaterThan(titleRenderStart); - const titleRenderSource = sidebarSource.slice(titleRenderStart, titleRenderEnd); expect(titleRenderSource).toContain("} else if (node.type === 'object-group') {"); expect(titleRenderSource).toContain('const objectGroupTitle = resolveV2ObjectGroupTitle(node);'); expect(titleRenderSource).toContain('hoverTitle = objectGroupTitle;'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index be22253..d8933b3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,16 +1,8 @@ -import Modal from './common/ResizableDraggableModal'; -import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; +import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu'; import { - buildDuckDBMacroDDL, - buildSidebarTableStatusSQL, - escapeSQLLiteral, - extractSqlServerDefinitionRows, - getCaseInsensitiveRawValue, - getCaseInsensitiveValue, getMetadataDialect, - getSidebarTableDisplayName, shouldHideSchemaPrefix, splitQualifiedName, } from './sidebar/sidebarMetadataLoaders'; @@ -22,8 +14,15 @@ import { SidebarEntityModals } from './sidebar/SidebarEntityModals'; import { renderSidebarV2TreeTitle } from './sidebar/SidebarTreeTitle'; import { useSidebarV2ContextMenu, - type SidebarContextMenuState, } from './sidebar/useSidebarV2ContextMenu'; +import { + useSidebarObjectActions, + type SidebarMessagePublishTarget, +} from './sidebar/useSidebarObjectActions'; +import { useSidebarSearchModel } from './sidebar/useSidebarSearchModel'; +import { useSidebarV2ActionHandlers } from './sidebar/useSidebarV2ActionHandlers'; +import { useSidebarCommandSearchRunner } from './sidebar/useSidebarCommandSearchRunner'; +import { useSidebarTitleRender } from './sidebar/useSidebarTitleRender'; import { normalizeDriverType, useSidebarTreeLoaders, @@ -49,13 +48,7 @@ import { shouldClearSidebarActiveContextOnEmptySelect, shouldLoadSidebarNodeOnExpand, getV2RailConnectionGroupBadgeText, - isV2SidebarObjectNode, - resolveV2ObjectGroupTitle, - resolveSidebarTableNameForCopy, - parseV2CommandSearchQuery, type V2ExplorerFilter, - type V2CommandSearchMode, - type V2CommandSearchQuery, } from './sidebar/sidebarHelpers'; // 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容 export { @@ -72,11 +65,10 @@ export { } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Switch } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Popover, Tooltip } from 'antd'; import { DatabaseOutlined, TableOutlined, - EyeOutlined, ConsoleSqlOutlined, HddOutlined, FolderOutlined, @@ -99,16 +91,10 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbo SendOutlined, DeleteOutlined, DisconnectOutlined, - CloudOutlined, CheckSquareOutlined, - CodeOutlined, - TagOutlined, - CheckOutlined, FilterOutlined, DashboardOutlined, WarningOutlined, - ClockCircleOutlined, - RobotOutlined, AimOutlined, MoreOutlined, ToolOutlined, @@ -124,21 +110,18 @@ import { import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory } from '../../wailsjs/go/app/App'; -import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; + import { ListSQLDirectory } from '../../wailsjs/go/app/App'; +import { supportsTableTruncateAction } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; -import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; +import { resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { resolveSidebarRuntimeDatabase, - type SidebarViewMetadataEntry, } from '../utils/sidebarMetadata'; -import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql'; -import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay'; import { findSidebarNodePathByKey, findSidebarNodePathForLocate, @@ -150,47 +133,24 @@ import { import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual'; import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions'; -import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; import { buildBatchDatabaseExportWorkbenchTab, buildBatchTableExportWorkbenchTab, - buildTableExportTab, } from '../utils/tableExportTab'; import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; -import { getCurrentLanguage, t } from '../i18n'; -import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; -import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; -import JVMModeBadge from './jvm/JVMModeBadge'; +import { t } from '../i18n'; import MessagePublishModal from './MessagePublishModal'; import { SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, - normalizeMySQLViewDDLForEditing, resolveSidebarContextMenuPosition, - resolveSidebarObjectDragText, type SearchScope, } from './sidebarCoreUtils'; export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils'; import { - V2DatabaseContextMenuView, - V2ConnectionGroupContextMenuView, - V2ConnectionContextMenuView, - V2SchemaContextMenuView, - V2TableContextMenuView, - V2TableGroupContextMenuView, - type V2DatabaseContextMenuActionKey, - type V2ConnectionGroupContextMenuActionKey, - type V2ConnectionContextMenuActionKey, - type V2SchemaContextMenuActionKey, - type V2TableContextMenuActionKey, - type V2TableContextMenuStats, - type V2TableGroupContextMenuActionKey, -} from './V2TableContextMenu'; -import { - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE, buildSidebarTableChildrenForUi, buildV2RailConnectionGroups, buildV2SidebarTableSectionedChildren, @@ -214,7 +174,6 @@ import { sortSidebarTableEntries, type SidebarTreeNode as TreeNode, type V2CommandSearchItem, - type V2RailConnectionGroup, } from './sidebarV2Utils'; export { @@ -268,12 +227,6 @@ const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: strin { key: 'events', labelKey: 'sidebar.command_search.object_kind.events' }, ]; -type SidebarMessagePublishTarget = { - connection: SavedConnection; - executionDbName: string; - destination: string; -}; - const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => { if (!conn) return ''; return JSON.stringify({ @@ -292,26 +245,6 @@ const isPostgresSchemaDialect = (dialect: string): boolean => ( ['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect)) ); -const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; labelKey: string }> = [ - { value: 'smart', labelKey: 'sidebar.command_search.scope.smart' }, - { value: 'object', labelKey: 'sidebar.command_search.scope.object' }, - { value: 'database', labelKey: 'sidebar.command_search.scope.database' }, - { value: 'host', labelKey: 'sidebar.command_search.scope.host' }, - { value: 'tag', labelKey: 'sidebar.command_search.scope.tag' }, -]; - -const SEARCH_SCOPE_LABEL_KEY_MAP: Record = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { - acc[option.value] = option.labelKey; - return acc; -}, {} as Record); -const SEARCH_SCOPE_ICON_MAP: Record = { - smart: , - object: , - database: , - host: , - tag: , -}; - const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set): boolean => ( query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId) ); @@ -1675,301 +1608,6 @@ const Sidebar: React.FC<{ } }; - const handleCopyStructure = async (node: any) => { - const { config, dbName, tableName } = node.dataRef; - const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName); - if (res.success) { - navigator.clipboard.writeText(res.data as string); - message.success(t('table_overview.message.copy_structure_success')); - } else { - message.error(res.message); - } - }; - - const resolveCopyObjectNameLabel = (node: any): string => { - if (node?.type === 'view') return t('sidebar.copy_object_name.label.view'); - if (node?.type === 'materialized-view') return t('sidebar.copy_object_name.label.materialized_view'); - if (node?.type === 'db-event') return t('sidebar.copy_object_name.label.event'); - return t('sidebar.copy_object_name.label.table'); - }; - - const handleCopyTableName = async (node: any) => { - const objectName = resolveSidebarTableNameForCopy(node); - const label = resolveCopyObjectNameLabel(node); - if (!objectName) { - message.warning(t('sidebar.copy_object_name.empty', { label })); - return; - } - try { - await navigator.clipboard.writeText(objectName); - message.success(t('sidebar.copy_object_name.copied', { label })); - } catch (e: any) { - message.error(t('sidebar.copy_object_name.failed', { label, error: e?.message || String(e) })); - } - }; - - const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => { - const { config, dbName, tableName } = node.dataRef; - const rowCount = Number(node?.dataRef?.rowCount); - const totalRowsKnown = Number.isFinite(rowCount) && rowCount > 0; - await runExportWithProgress({ - title: `导出 ${tableName}`, - targetName: tableName, - format: options.format, - totalRows: totalRowsKnown ? rowCount : undefined, - run: (jobId) => ExportTableWithOptions( - buildRpcConnectionConfig(config) as any, - dbName, - tableName, - { - ...options, - jobId, - totalRowsHint: totalRowsKnown ? rowCount : 0, - totalRowsKnown, - } as any, - ), - }); - }; - - const openExportDialog = async (node: any) => { - const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); - if (!tableName) { - message.warning('未识别到表名,无法导出'); - return; - } - const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim(); - const dbName = String(node?.dataRef?.dbName || '').trim(); - addTab(buildTableExportTab({ - connectionId, - dbName, - tableName, - title: `导出 ${tableName}`, - objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'), - schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined, - sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined, - rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) > 0 - ? { all: Math.trunc(Number(node.dataRef.rowCount)) } - : undefined, - })); - }; - - const handleCopyTableAsInsert = async (node: any) => { - await handleExport(node, { format: 'sql' }); - }; - - const openTableDdlInDesigner = (node: any) => { - openDesign(node, 'ddl', true); - }; - - const openTableInERView = (node: any) => { - onDoubleClick(null, node); - setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', { - detail: { - connectionId: node.dataRef?.id, - dbName: node.dataRef?.dbName, - tableName: node.dataRef?.tableName, - viewMode: 'er', - }, - })); - }, 0); - }; - - const injectTablePromptToAI = async (node: any, promptKind: 'explain' | 'query') => { - const conn = node.dataRef; - const tableName = String(conn?.tableName || node?.title || '').trim(); - if (!conn?.id || !conn?.dbName || !tableName) { - message.warning('当前表缺少连接上下文,无法发送给 AI'); - return; - } - - let ddl = ''; - try { - const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, conn.dbName, tableName); - if (res.success) { - ddl = String(res.data || '').trim(); - addAIContext(conn.id, { dbName: conn.dbName, tableName, ddl }); - } - } catch { - // AI 入口仍可基于表名工作,DDL 获取失败不阻断打开面板。 - } - - const prompt = promptKind === 'explain' - ? [ - `请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。`, - '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', - ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', - ].filter(Boolean).join('\n') - : [ - `请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。`, - '要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。', - ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', - ].filter(Boolean).join('\n'); - - const wasClosed = !useStore.getState().aiPanelVisible; - if (wasClosed) setAIPanelVisible(true); - setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); - }, wasClosed ? 350 : 0); - }; - - const refreshDatabaseNode = async (dbNodeKey: string) => { - if (!dbNodeKey) { - return; - } - const dbNode = findTreeNodeByKey(treeData, dbNodeKey); - if (dbNode && dbNode.type === 'database') { - await loadTables(dbNode); - } - }; - - const handleCreateDatabase = async () => { - try { - const values = await createDbForm.validateFields(); - const conn = targetConnection.dataRef; - const config = { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || "", - database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "", - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } - }; - - const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name); - if (res.success) { - message.success("数据库创建成功"); - setIsCreateDbModalOpen(false); - createDbForm.resetFields(); - // Refresh node - loadDatabases(targetConnection); - } else { - message.error("创建失败: " + res.message); - } - } catch (e) { - // Validate failed - } - }; - - const openCreateSchemaModal = (node: any) => { - const dialect = getMetadataDialect(node?.dataRef as SavedConnection); - if (!isPostgresSchemaDialect(dialect)) { - message.warning(t('sidebar.message.schema_create_unsupported')); - return; - } - setCreateSchemaTarget(node); - createSchemaForm.resetFields(); - setIsCreateSchemaModalOpen(true); - }; - - const handleCreateSchema = async () => { - try { - const values = await createSchemaForm.validateFields(); - const node = createSchemaTarget; - const conn = node?.dataRef; - const dbName = String(conn?.dbName || node?.title || '').trim(); - if (!conn || !dbName) { - message.error(t('sidebar.message.schema_target_missing')); - return; - } - - const res = await CreateSchema(buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, values.name); - if (res.success) { - message.success(t('sidebar.message.schema_created')); - setIsCreateSchemaModalOpen(false); - setCreateSchemaTarget(null); - createSchemaForm.resetFields(); - await loadTables(node); - } else { - message.error(t('sidebar.message.operation_create_failed', { error: res.message })); - } - } catch (e) { - // Validate failed - } - }; - - const openRenameSchemaModal = (node: any) => { - const dialect = getMetadataDialect(node?.dataRef as SavedConnection); - const schemaName = String(node?.dataRef?.schemaName || '').trim(); - if (!isPostgresSchemaDialect(dialect) || !schemaName) { - message.warning('当前节点不支持通过此入口编辑模式'); - return; - } - setRenameSchemaTarget(node); - renameSchemaForm.setFieldsValue({ newName: schemaName }); - setIsRenameSchemaModalOpen(true); - }; - - const handleRenameSchema = async () => { - try { - const values = await renameSchemaForm.validateFields(); - const node = renameSchemaTarget; - const conn = node?.dataRef; - const dbName = String(conn?.dbName || '').trim(); - const oldSchemaName = String(conn?.schemaName || '').trim(); - const newSchemaName = String(values?.newName || '').trim(); - if (!conn || !dbName || !oldSchemaName || !newSchemaName) { - message.error('未找到目标模式,无法编辑'); - return; - } - if (oldSchemaName === newSchemaName) { - message.warning('新旧模式名称相同,无需修改'); - return; - } - - const res = await (window as any).go.app.App.RenameSchema( - buildRpcConnectionConfig(conn.config, { database: dbName }) as any, - dbName, - oldSchemaName, - newSchemaName, - ); - if (res.success) { - message.success('模式重命名成功'); - const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${oldSchemaName || 'default'}`; - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); - await loadTables(getDatabaseNodeRef(conn, dbName)); - setIsRenameSchemaModalOpen(false); - setRenameSchemaTarget(null); - renameSchemaForm.resetFields(); - } else { - message.error('编辑失败: ' + res.message); - } - } catch (e) { - // Validate failed - } - }; - - const handleDeleteSchema = (node: any) => { - const conn = node?.dataRef; - const dbName = String(conn?.dbName || '').trim(); - const schemaName = String(conn?.schemaName || '').trim(); - if (!conn || !dbName || !schemaName) { - message.error('未找到目标模式,无法删除'); - return; - } - Modal.confirm({ - title: '确认删除模式', - content: `确定删除模式 "${schemaName}" 吗?这将删除该模式及其中所有对象,操作不可恢复。`, - okButtonProps: { danger: true }, - onOk: async () => { - const res = await (window as any).go.app.App.DropSchema( - buildRpcConnectionConfig(conn.config, { database: dbName }) as any, - dbName, - schemaName, - ); - if (res.success) { - message.success('模式删除成功'); - const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${schemaName || 'default'}`; - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); - await loadTables(getDatabaseNodeRef(conn, dbName)); - } else { - message.error('删除失败: ' + res.message); - } - } - }); - }; const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { return buildRpcConnectionConfig(conn.config, { @@ -2076,427 +1714,6 @@ const Sidebar: React.FC<{ return splitQualifiedName(String(fullName || '').trim()).objectName || String(fullName || '').trim(); }; - const handleRenameDatabase = async () => { - if (!renameDbTarget) return; - try { - const values = await renameDbForm.validateFields(); - const conn = renameDbTarget.dataRef; - const oldDbName = String(conn.dbName || '').trim(); - const newDbName = String(values.newName || '').trim(); - if (!oldDbName || !newDbName) { - message.error(t('sidebar.message.database_name_required')); - return; - } - if (oldDbName === newDbName) { - message.warning(t('sidebar.message.database_name_unchanged')); - return; - } - - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName); - if (res.success) { - message.success(t('sidebar.message.database_renamed')); - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); - await loadDatabases(getConnectionNodeRef(conn)); - setIsRenameDbModalOpen(false); - setRenameDbTarget(null); - renameDbForm.resetFields(); - } else { - message.error(t('sidebar.message.operation_rename_failed', { error: res.message })); - } - } catch (e) { - // Validate failed - } - }; - - const handleDeleteDatabase = (node: any) => { - const conn = node.dataRef; - const dbName = String(conn.dbName || '').trim(); - if (!dbName) return; - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_database.title'), - content: t('sidebar.modal.confirm_delete_database.content', { name: dbName }), - okButtonProps: { danger: true }, - onOk: async () => { - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName); - if (res.success) { - message.success(t('sidebar.message.database_deleted')); - closeTabsByDatabase(conn.id, dbName); - setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); - setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); - await loadDatabases(getConnectionNodeRef(conn)); - } else { - message.error(t('sidebar.message.operation_drop_failed', { error: res.message })); - } - } - }); - }; - - const handleRenameTable = async () => { - if (!renameTableTarget) return; - try { - const values = await renameTableForm.validateFields(); - const conn = renameTableTarget.dataRef; - const oldTableName = String(conn.tableName || '').trim(); - const newTableName = String(values.newName || '').trim(); - if (!oldTableName || !newTableName) { - message.error("表名不能为空"); - return; - } - if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) { - message.warning("新旧表名相同,无需修改"); - return; - } - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName); - if (res.success) { - message.success("表重命名成功"); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - setIsRenameTableModalOpen(false); - setRenameTableTarget(null); - renameTableForm.resetFields(); - } else { - message.error("重命名失败: " + res.message); - } - } catch (e) { - // Validate failed - } - }; - - const handleDeleteTable = (node: any) => { - const conn = node.dataRef; - const tableName = String(conn.tableName || '').trim(); - if (!tableName) return; - Modal.confirm({ - title: '确认删除表', - content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, - okButtonProps: { danger: true }, - onOk: async () => { - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName); - if (res.success) { - message.success("表删除成功"); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - } else { - message.error("删除失败: " + res.message); - } - } - }); - }; - - const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => { - const conn = node.dataRef; - const tableName = String(conn.tableName || '').trim(); - if (!tableName) return; - - const { label, progressLabel } = getTableDataDangerActionMeta(action); - const confirmed = await new Promise((resolve) => { - Modal.confirm({ - title: `确认${label}`, - content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`, - okText: '继续', - cancelText: '取消', - okButtonProps: { danger: true }, - onOk: () => resolve(true), - onCancel: () => resolve(false), - }); - }); - if (!confirmed) return; - - const config = buildRuntimeConfig(conn, conn.dbName); - const app = (window as any).go.app.App; - const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables'; - const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0); - const startTime = Date.now(); - try { - const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]); - hide(); - const duration = Date.now() - startTime; - const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : []; - const logSql = executedSQLs.length > 0 - ? executedSQLs.join(';\n') + ';' - : `/* ${label} ${tableName} */`; - - if (res.success) { - message.success(`${progressLabel}成功`); - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: logSql, - status: 'success', - duration, - message: res.message, - dbName: conn.dbName, - affectedRows: res.data?.count || 0, - }); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - return; - } - - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: logSql, - status: 'error', - duration, - message: res.message, - dbName: conn.dbName, - }); - if (res.message !== '已取消') { - message.error(`${progressLabel}失败: ${res.message}`); - } - } catch (e: any) { - const duration = Date.now() - startTime; - const errMsg = e?.message || String(e); - hide(); - addSqlLog({ - id: Date.now().toString(), - timestamp: Date.now(), - sql: `/* ${label} ${tableName} - ERROR */`, - status: 'error', - duration, - message: errMsg, - dbName: conn.dbName, - }); - message.error(`${progressLabel}失败: ${errMsg}`); - } - }; - - // --- 视图操作 --- - const openViewDefinition = (node: any) => { - const { viewName, dbName, id, schemaName } = node.dataRef; - const isMaterialized = node.type === 'materialized-view' || node.dataRef?.objectKind === 'materialized-view'; - addTab({ - id: `view-def-${id}-${dbName}-${viewName}`, - title: t(isMaterialized ? 'sidebar.tab.materialized_view_definition' : 'sidebar.tab.view_definition', { name: viewName }), - type: 'view-def', - connectionId: id, - dbName, - viewName, - viewKind: isMaterialized ? 'materialized' : 'view', - schemaName, - sidebarLocateKey: String(node.key || ''), - }); - }; - - const openEditView = async (node: any) => { - const conn = node.dataRef; - const { viewName, dbName, id } = conn; - // 获取视图定义后打开查询编辑器 - const dialect = getMetadataDialect(conn as SavedConnection); - const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_view', { name: viewName })}`; - let template = `${sqlTemplateHeader}\n-- ${t('sidebar.sql_template.modify_then_execute')}\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`; - - try { - const config = buildRuntimeConfig(conn, dbName); - let queries: string[] = []; - switch (dialect) { - case 'mysql': - case 'starrocks': - queries = [`SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``]; - break; - case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { - const parts = splitQualifiedName(viewName); - const schema = parts.schemaName || 'public'; - const name = parts.objectName || viewName; - queries = [`SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`]; - break; - } - case 'sqlserver': - queries = buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition'); - break; - case 'sqlite': - queries = [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`]; - break; - case 'duckdb': { - const parts = splitQualifiedName(viewName); - const viewSchema = escapeSQLLiteral(parts.schemaName || 'main'); - const viewObject = escapeSQLLiteral(parts.objectName || viewName); - queries = [`SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`]; - break; - } - } - for (const query of queries) { - const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query); - if (result.success && Array.isArray(result.data) && result.data.length > 0) { - const row = result.data[0] as Record; - const def = dialect === 'sqlserver' - ? extractSqlServerDefinitionRows(result.data, ['view_definition', 'definition']) - : row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || ''; - if (def) { - if (dialect === 'mysql') { - template = `${sqlTemplateHeader}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`; - } else if (dialect === 'sqlserver') { - template = /^\s*create\s+view\b/i.test(String(def)) - ? `${sqlTemplateHeader}\n${def}` - : `${sqlTemplateHeader}\nCREATE VIEW ${viewName} AS\n${def}`; - } else { - template = `${sqlTemplateHeader}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`; - } - break; - } - } - } - } catch { /* 降级使用模板 */ } - - addTab({ - id: `query-edit-view-${Date.now()}`, - title: t('sidebar.tab.edit_view', { name: viewName }), - type: 'query', - connectionId: id, - dbName, - query: template - }); - }; - - const openCreateView = (node: any) => { - const conn = node.dataRef; - const { dbName, id } = conn; - const dialect = getMetadataDialect(conn as SavedConnection); - let template: string; - switch (dialect) { - case 'mysql': - case 'starrocks': - template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - break; - case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': - template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - break; - case 'sqlserver': - template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - break; - case 'oracle': case 'dm': - template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - break; - case 'sqlite': - case 'duckdb': - template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - break; - default: - template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; - } - addTab({ - id: `query-create-view-${Date.now()}`, - title: t('sidebar.tab.create_view'), - type: 'query', - connectionId: id, - dbName, - query: template - }); - }; - - const openCreateStarRocksMaterializedView = (node: any) => { - const conn = node.dataRef; - const { dbName, id } = conn; - const schemaPrefix = String(conn.schemaName || dbName || '').trim(); - const mvName = schemaPrefix ? `${schemaPrefix}.mv_name` : 'mv_name'; - const template = buildStarRocksMaterializedViewPreviewSql({ - name: mvName, - query: 'SELECT\n column1,\n COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1', - distributionColumnNames: ['column1'], - refreshClause: 'REFRESH ASYNC', - properties: '"replication_num" = "1"', - }); - addTab({ - id: `query-create-starrocks-mv-${Date.now()}`, - title: t('sidebar.v2_database_menu.new_materialized_view'), - type: 'query', - connectionId: id, - dbName, - query: template, - }); - }; - - const openCreateStarRocksExternalCatalog = (node: any) => { - const conn = node.dataRef; - const { dbName, id } = conn; - addTab({ - id: `query-create-starrocks-catalog-${Date.now()}`, - title: t('sidebar.v2_database_menu.new_external_catalog'), - type: 'query', - connectionId: id, - dbName, - query: `CREATE EXTERNAL CATALOG catalog_name\nPROPERTIES (\n "type" = "hive",\n "hive.metastore.uris" = "thrift://127.0.0.1:9083"\n);`, - }); - }; - - const openCreateStarRocksRollup = (node: any) => { - const conn = node.dataRef; - const { tableName, dbName, id } = conn; - const safeTable = String(tableName || 'table_name').trim(); - const safeTableParts = [splitQualifiedName(safeTable).schemaName, splitQualifiedName(safeTable).objectName].filter(Boolean); - const quotedTable = safeTable.includes('`') - ? safeTable - : (safeTableParts.length > 0 ? safeTableParts : [safeTable]).map(part => `\`${part.replace(/`/g, '``')}\``).join('.'); - addTab({ - id: `query-create-starrocks-rollup-${Date.now()}`, - title: '新增 Rollup', - type: 'query', - connectionId: id, - dbName, - query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`, - }); - }; - - const handleDropView = (node: any) => { - const conn = node.dataRef; - const viewName = String(conn.viewName || '').trim(); - if (!viewName) return; - Modal.confirm({ - title: '确认删除视图', - content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`, - okButtonProps: { danger: true }, - onOk: async () => { - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName); - if (res.success) { - message.success("视图删除成功"); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - } else { - message.error("删除失败: " + res.message); - } - } - }); - }; - - const handleRenameView = async () => { - if (!renameViewTarget) return; - try { - const values = await renameViewForm.validateFields(); - const conn = renameViewTarget.dataRef; - const oldViewName = String(conn.viewName || '').trim(); - const newViewName = String(values.newName || '').trim(); - if (!oldViewName || !newViewName) { - message.error("视图名称不能为空"); - return; - } - if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) { - message.warning("新旧视图名相同,无需修改"); - return; - } - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName); - if (res.success) { - message.success("视图重命名成功"); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - setIsRenameViewModalOpen(false); - setRenameViewTarget(null); - renameViewForm.resetFields(); - } else { - message.error("重命名失败: " + res.message); - } - } catch (e) { - // Validate failed - } - }; - - const openRenameSavedQueryModal = (query: SavedQuery) => { - setRenameSavedQueryTarget(query); - renameSavedQueryForm.setFieldsValue({ name: query.name || t('query_editor.save_modal.unnamed') }); - setIsRenameSavedQueryModalOpen(true); - }; const resolveSavedQueryDisplayName = (name: string | null | undefined) => { const rawName = String(name || '').trim(); @@ -2525,1189 +1742,230 @@ const Sidebar: React.FC<{ decorateExternalSQLTreeNode, }); - const handleRenameSavedQuery = async () => { - if (!renameSavedQueryTarget) return; - try { - const values = await renameSavedQueryForm.validateFields(); - const nextName = String(values.name || '').trim(); - if (!nextName) { - message.error(t('query_editor.save_modal.name_required')); - return; - } - if (nextName === renameSavedQueryTarget.name) { - message.warning(t('sidebar.message.saved_query_name_unchanged')); - return; - } - - const persisted = await saveQuery({ - ...renameSavedQueryTarget, - name: nextName, - }); - const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] => - list.map(node => { - if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) { - return { - ...node, - title: persisted.name, - dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted }, - }; - } - return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node; - }); - const nextTreeData = updateSavedQueryNode(treeDataRef.current); - treeDataRef.current = nextTreeData; - setTreeData(nextTreeData); - tabs - .filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id)) - .forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name })); - message.success(t('sidebar.message.saved_query_renamed')); - setIsRenameSavedQueryModalOpen(false); - setRenameSavedQueryTarget(null); - renameSavedQueryForm.resetFields(); - } catch (e) { - if (e instanceof Error) { - message.error('重命名查询失败: ' + e.message); - } - } - }; - - const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => { - return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId); - }, [connectionIdSet]); - - const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => { - if (!query?.id || !target?.id) return; - try { - const backendApp = (window as any).go?.app?.App; - let persisted: SavedQuery; - if (typeof backendApp?.RebindSavedQuery === 'function') { - persisted = await backendApp.RebindSavedQuery(query.id, target.id); - await saveQuery(persisted); - } else { - persisted = await saveQuery({ - ...query, - connectionId: target.id, - originalConnectionId: query.originalConnectionId || query.connectionId, - bindingStatus: 'active', - }); - } - message.success(`查询已绑定到 ${target.name || target.id}`); - tabs - .filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id)) - .forEach(tab => updateQueryTabDraft(tab.id, { - title: persisted.name, - connectionId: persisted.connectionId, - dbName: persisted.dbName, - })); - } catch (error) { - message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error))); - } - }, [saveQuery, tabs, updateQueryTabDraft]); - - // --- 函数/存储过程操作 --- - const openRoutineDefinition = (node: any) => { - const { routineName, routineType, dbName, id } = node.dataRef; - const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); - addTab({ - id: `routine-def-${id}-${dbName}-${routineName}`, - title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }), - type: 'routine-def', - connectionId: id, - dbName, - routineName, - routineType - }); - }; - - const openEventDefinition = (node: any) => { - const { eventName, dbName, id } = node.dataRef; - addTab({ - id: `event-def-${id}-${dbName}-${eventName}`, - title: t('sidebar.tab.event', { name: eventName }), - type: 'event-def', - connectionId: id, - dbName, - eventName, - }); - }; - - const openEditRoutine = async (node: any) => { - const conn = node.dataRef; - const { routineName, routineType, dbName, id } = conn; - const dialect = getMetadataDialect(conn as SavedConnection); - const tabTypeKey = routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'; - const tabTypeLabel = t(tabTypeKey); - const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_routine', { type: tabTypeLabel, name: routineName })}`; - let template = sqlTemplateHeader; - - try { - const config = buildRuntimeConfig(conn, dbName); - let query = ''; - const parsedRoutine = splitQualifiedName(routineName); - const name = parsedRoutine.objectName || routineName; - const schema = parsedRoutine.schemaName; - - switch (dialect) { - case 'mysql': - case 'starrocks': - query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``; - break; - case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { - const schemaRef = schema || 'public'; - query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`; - break; - } - case 'sqlserver': - query = ''; - break; - case 'oracle': case 'dm': { - const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : ''; - if (owner) { - query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`; - } else { - query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`; - } - break; - } - case 'duckdb': { - const schemaRef = schema || 'main'; - query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`; - break; - } - } - const queries = dialect === 'sqlserver' - ? buildSqlServerObjectDefinitionQueries('routine', routineName, dbName, 'routine_definition') - : [query].filter(Boolean); - for (const queryText of queries) { - const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, queryText); - if (result.success && Array.isArray(result.data) && result.data.length > 0) { - if (dialect === 'oracle' || dialect === 'dm') { - const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join(''); - if (lines) { - template = `${sqlTemplateHeader}\nCREATE OR REPLACE ${lines}`; - break; - } - } else if (dialect === 'duckdb') { - const row = result.data[0] as Record; - const ddl = buildDuckDBMacroDDL( - String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(), - String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(), - getCaseInsensitiveRawValue(row, ['parameters']), - getCaseInsensitiveRawValue(row, ['macro_definition']) - ); - if (ddl) { - template = `${sqlTemplateHeader}\n${ddl}`; - break; - } - } else { - const row = result.data[0] as Record; - const def = dialect === 'sqlserver' - ? extractSqlServerDefinitionRows(result.data, ['routine_definition', 'definition']) - : row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || ''; - if (def) { - template = `${sqlTemplateHeader}\n${def}`; - break; - } - } - } - } - } catch { /* 降级使用模板 */ } - - addTab({ - id: `query-edit-routine-${Date.now()}`, - title: t('sidebar.tab.edit_routine', { type: tabTypeLabel, name: routineName }), - type: 'query', - connectionId: id, - dbName, - query: template - }); - }; - - const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => { - const conn = node.dataRef; - const { dbName, id } = conn; - const dialect = getMetadataDialect(conn as SavedConnection); - const isProc = type === 'PROCEDURE'; - let template: string; - - switch (dialect) { - case 'mysql': - case 'starrocks': - template = isProc - ? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;` - : `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`; - break; - case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': - template = isProc - ? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;` - : `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`; - break; - case 'sqlserver': - template = isProc - ? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;` - : `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`; - break; - case 'oracle': case 'dm': - template = isProc - ? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;` - : `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`; - break; - case 'duckdb': - template = isProc - ? `-- ${t('sidebar.sql_template.duckdb_procedure_unsupported')}\n-- ${t('sidebar.sql_template.duckdb_macro_hint')}\nCREATE MACRO func_name(param1) AS (param1 * 2);` - : `CREATE MACRO func_name(param1) AS (param1 * 2);`; - break; - default: - template = isProc - ? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;` - : `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`; - } - - addTab({ - id: `query-create-routine-${Date.now()}`, - title: isProc ? t('sidebar.tab.create_procedure') : t('sidebar.tab.create_function'), - type: 'query', - connectionId: id, - dbName, - query: template - }); - }; - - const handleDropRoutine = (node: any) => { - const conn = node.dataRef; - const routineName = String(conn.routineName || '').trim(); - const routineType = String(conn.routineType || 'FUNCTION').trim(); - if (!routineName) return; - const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_routine.title', { type: typeLabel }), - content: t('sidebar.modal.confirm_delete_routine.content', { type: typeLabel, name: routineName }), - okButtonProps: { danger: true }, - onOk: async () => { - const config = buildRuntimeConfig(conn, conn.dbName); - const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType); - if (res.success) { - message.success(t('sidebar.message.routine_deleted', { type: typeLabel })); - await loadTables(getDatabaseNodeRef(conn, conn.dbName)); - } else { - message.error(t('sidebar.message.delete_failed', { error: res.message })); - } - } - }); - }; - - const resolveMessagePublishTarget = (node: any): SidebarMessagePublishTarget | null => { - const connectionId = String(node?.dataRef?.id || '').trim(); - const liveConnection = connections.find((item) => item.id === connectionId); - const sourceConnection = (liveConnection || node?.dataRef) as SavedConnection | undefined; - if (!sourceConnection?.config) return null; - const capabilities = getDataSourceCapabilities(sourceConnection.config); - if (!capabilities.supportsMessagePublish) return null; - - return { - connection: sourceConnection, - executionDbName: String(node?.dataRef?.dbName || ''), - destination: String(node?.dataRef?.tableName || node?.title || '').trim(), - }; - }; - - const openMessagePublishModal = (node: any) => { - const target = resolveMessagePublishTarget(node); - if (!target) { - message.warning('当前对象不支持测试发送消息'); - return; - } - setMessagePublishTarget(target); - }; - - const handleMessagePublishSuccess = (result: { destination: string; affectedRows: number }) => { - const destination = String(result.destination || '').trim(); - const suffix = result.affectedRows > 0 ? `(已提交 ${result.affectedRows} 条)` : ''; - message.success(`测试消息已发送到 ${destination || '目标'}${suffix}`); - setMessagePublishTarget(null); - }; - - const handleV2TableContextMenuAction = (node: any, action: V2TableContextMenuActionKey) => { - switch (action) { - case 'pin-table': - case 'unpin-table': { - toggleSidebarTablePinned(node, action === 'pin-table'); - return; - } - case 'open-data': - case 'open-new-tab': - onDoubleClick(null, node); - return; - case 'design-table': - openDesign(node, 'columns', false); - return; - case 'new-query': { - const tableName = String(node.dataRef?.tableName || '').trim(); - const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName); - addTab({ - id: `query-${Date.now()}`, - title: t('query.new'), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.dataRef.dbName, - query: queryTemplate - }); - return; - } - case 'publish-message': - openMessagePublishModal(node); - return; - case 'view-ddl': - openTableDdlInDesigner(node); - return; - case 'view-er': - openTableInERView(node); - return; - case 'copy-table-name': - void handleCopyTableName(node); - return; - case 'copy-structure': - void handleCopyStructure(node); - return; - case 'copy-insert': - void handleCopyTableAsInsert(node); - return; - case 'rename-table': - setRenameTableTarget(node); - renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); - setIsRenameTableModalOpen(true); - return; - case 'new-rollup': - openCreateStarRocksRollup(node); - return; - case 'backup-table': - void handleExport(node, { format: 'sql' }); - return; - case 'refresh-stats': - refreshV2TableContextMenuStats(node); - return; - case 'export-data': - void openExportDialog(node); - return; - case 'ai-explain': - void injectTablePromptToAI(node, 'explain'); - return; - case 'ai-generate-query': - void injectTablePromptToAI(node, 'query'); - return; - case 'truncate-table': - void handleTableDataDangerAction(node, 'truncate'); - return; - case 'drop-table': - handleDeleteTable(node); - return; - default: - return; - } - }; - - const toggleSidebarTablePinned = (node: any, pinned?: boolean) => { - const conn = node?.dataRef || {}; - const tableName = String(conn.tableName || node?.title || '').trim(); - const dbName = String(conn.dbName || '').trim(); - if (!conn.id || !dbName || !tableName) return; - const currentlyPinned = isSidebarTablePinned( - pinnedSidebarTables, - String(conn.id || ''), - dbName, - tableName, - String(conn.schemaName || ''), - ); - const shouldPin = pinned ?? !currentlyPinned; - setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin); - void loadTables(getDatabaseNodeRef(conn, dbName)); - message.success(shouldPin ? t('sidebar.message.table_pinned') : t('sidebar.message.table_unpinned')); - }; - - const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => { - const groupData = node.dataRef; - setTableSortPreference(groupData.id, groupData.dbName, sortBy); - const dbNode = { - key: `${groupData.id}-${groupData.dbName}`, - dataRef: groupData - }; - loadTables(dbNode); - }; - - const handleV2TableGroupContextMenuAction = (node: any, action: V2TableGroupContextMenuActionKey) => { - switch (action) { - case 'new-table': - openNewTableDesign(node); - return; - case 'sort-by-name': - handleTableGroupSortAction(node, 'name'); - return; - case 'sort-by-frequency': - handleTableGroupSortAction(node, 'frequency'); - return; - default: - return; - } - }; - - const closeDatabaseNode = (node: any) => { - const dbConnId = String(node.dataRef?.id || ''); - const dbName = String(node.dataRef?.dbName || node.title || '').trim(); - loadingNodesRef.current.delete(`tables-${dbConnId}-${dbName}`); - setConnectionStates(prev => { - const next = { ...prev }; - delete next[node.key]; - return next; - }); - setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`))); - setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`))); - replaceTreeNodeChildren(node.key, undefined); - if (dbConnId && dbName) { - closeTabsByDatabase(dbConnId, dbName); - } - message.success(t('sidebar.message.database_closed')); - }; - - const openDatabaseQuery = (node: any) => { - addTab({ - id: `query-${Date.now()}`, - title: t('sidebar.tab.new_query_database', { database: node.title }), - type: 'query', - connectionId: node.dataRef.id, - dbName: node.title, - query: '' - }); - }; - - const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => { - switch (action) { - case 'new-table': - openNewTableDesign(node); - return; - case 'new-schema': - openCreateSchemaModal(node); - return; - case 'new-materialized-view': - openCreateStarRocksMaterializedView(node); - return; - case 'new-external-catalog': - openCreateStarRocksExternalCatalog(node); - return; - case 'rename-db': - setRenameDbTarget(node); - renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' }); - setIsRenameDbModalOpen(true); - return; - case 'refresh': - loadTables(node); - return; - case 'export-db-schema': - void handleExportDatabaseSQL(node, false); - return; - case 'backup-db-sql': - void handleExportDatabaseSQL(node, true); - return; - case 'disconnect-db': - closeDatabaseNode(node); - return; - case 'new-query': - openDatabaseQuery(node); - return; - case 'run-sql': - handleRunSQLFile(node); - return; - case 'drop-db': - handleDeleteDatabase(node); - return; - default: - return; - } - }; - - const refreshConnectionNode = (node: any) => { - const connKey = String(node?.key || node?.dataRef?.id || ''); - if (!connKey) return; - setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); - Array.from(loadingNodesRef.current).forEach((loadingKey) => { - if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) { - loadingNodesRef.current.delete(loadingKey); - } - }); - loadDatabases(node); - }; - - const releaseConnectionResources = async (conn: SavedConnection | undefined) => { - if (!conn?.config) return; - const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any); - if (res && res.success === false) { - throw new Error(res.message || '释放连接失败'); - } - }; - - const disconnectConnectionNode = async (node: any) => { - const connKey = String(node?.key || node?.dataRef?.id || ''); - if (!connKey) return; - const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined; - Array.from(loadingNodesRef.current).forEach((loadingKey) => { - if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) { - loadingNodesRef.current.delete(loadingKey); - } - }); - setConnectionStates(prev => { - const next = { ...prev }; - Object.keys(next).forEach(k => { - if (k === connKey || k.startsWith(`${connKey}-`)) { - delete next[k]; - } - }); - return next; - }); - setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); - setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); - replaceTreeNodeChildren(connKey, undefined); - closeTabsByConnection(connKey); - try { - await releaseConnectionResources(conn); - } catch (error: any) { - message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败'); - } - message.success(t('connection.sidebar.disconnect.success')); - }; - - const deleteConnectionNode = (node: any) => { - Modal.confirm({ - title: t('connection.sidebar.delete.confirmTitle'), - content: t('connection.sidebar.delete.confirmContent', { name: node.title }), - onOk: async () => { - const connId = String(node.key); - const backendApp = (window as any).go?.app?.App; - if (typeof backendApp?.DeleteConnection !== 'function') { - message.error(t('connection.sidebar.delete.backendUnavailable')); - throw new Error('DeleteConnection unavailable'); - } - try { - await backendApp.DeleteConnection(connId); - closeTabsByConnection(connId); - removeConnection(connId); - message.success(t('connection.sidebar.delete.success')); - } catch (error: any) { - message.error(error?.message || t('connection.sidebar.delete.failureFallback')); - throw error; - } - } - }); - }; - - const createConnectionTreeNode = (conn: SavedConnection): TreeNode => ({ - title: conn.name, - key: conn.id, - icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 22), - type: 'connection', - dataRef: conn, - isLeaf: false, - }); - - const getConnectionNodeForAction = (conn: SavedConnection): TreeNode => { - return findTreeNodeByKeyRef.current(treeDataRef.current, conn.id) || createConnectionTreeNode(conn); - }; - - const handleV2ConnectionContextMenuAction = (node: any, action: V2ConnectionContextMenuActionKey) => { - const connId = String(node?.key || node?.dataRef?.id || ''); - if (!connId) return; - switch (action) { - case 'new-db': - setTargetConnection(node); - setIsCreateDbModalOpen(true); - return; - case 'refresh': - refreshConnectionNode(node); - return; - case 'new-query': - addTab({ - id: `query-${Date.now()}`, - title: buildConnectionRootQueryTabTitle(), - type: 'query', - connectionId: connId, - dbName: undefined, - query: '' - }); - return; - case 'open-sql-file': - handleRunSQLFile(node); - return; - case 'new-command': - addTab({ - id: `redis-cmd-${connId}-${Date.now()}`, - title: buildConnectionRootRedisCommandTabTitle(), - type: 'redis-command', - connectionId: connId, - redisDB: 0 - }); - return; - case 'open-monitor': - addTab({ - id: `redis-monitor-${connId}-${Date.now()}`, - title: buildConnectionRootRedisMonitorTabTitle(), - type: 'redis-monitor', - connectionId: connId, - redisDB: 0 - }); - return; - case 'edit': - if (onEditConnection) onEditConnection(node.dataRef); - return; - case 'copy-connection': - void handleDuplicateConnection(node.dataRef as SavedConnection); - return; - case 'disconnect': - void disconnectConnectionNode(node); - return; - case 'delete': - deleteConnectionNode(node); - return; - case 'move-to-ungrouped': - moveConnectionToTag(connId, null); - return; - default: - if (action.startsWith('move-to-tag:')) { - moveConnectionToTag(connId, action.slice('move-to-tag:'.length)); - } - } - }; - - const handleV2ConnectionGroupContextMenuAction = (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => { - const tag = connectionTags.find((item) => item.id === group.id); - if (!tag) return; - if (action === 'edit-group') { - createTagForm.setFieldsValue({ name: tag.name, connectionIds: tag.connectionIds }); - setRenameViewTarget({ - title: tag.name, - key: `tag-${tag.id}`, - type: 'tag', - dataRef: tag, - }); - setIsCreateTagModalOpen(true); - return; - } - if (action === 'delete-group') { - Modal.confirm({ - title: t('connection.sidebar.group.deleteConfirmTitle'), - content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name }), - onOk: () => { - removeConnectionTag(tag.id); - }, - }); - } - }; - - const onSearch = (e: React.ChangeEvent) => { - const { value } = e.target; - setSearchValue(value); - }; - - const toggleSearchScope = (scope: SearchScope) => { - setSearchScopes((prev) => { - if (scope === 'smart') { - return ['smart']; - } - const withoutSmart = prev.filter((item) => item !== 'smart'); - if (withoutSmart.includes(scope)) { - const next = withoutSmart.filter((item) => item !== scope); - return next.length > 0 ? next : ['smart']; - } - return [...withoutSmart, scope]; - }); - }; - - const setSearchScopeChecked = (scope: SearchScope, checked: boolean) => { - if (scope === 'smart') { - if (checked) { - setSearchScopes(['smart']); - } else if (searchScopes.length === 1 && searchScopes[0] === 'smart') { - setSearchScopes(['smart']); - } else { - setSearchScopes((prev) => { - const next = prev.filter((item) => item !== 'smart'); - return next.length > 0 ? next : ['smart']; - }); - } - return; - } - - if (checked) { - setSearchScopes((prev) => { - const withoutSmart = prev.filter((item) => item !== 'smart'); - if (withoutSmart.includes(scope)) { - return withoutSmart; - } - return [...withoutSmart, scope]; - }); - } else { - setSearchScopes((prev) => { - const next = prev.filter((item) => item !== scope && item !== 'smart'); - return next.length > 0 ? next : ['smart']; - }); - } - }; - - const currentLanguage = getCurrentLanguage(); - - const searchScopeSummary = useMemo(() => { - if (searchScopes.includes('smart')) { - return t('sidebar.command_search.scope.summary_smart'); - } - return searchScopes.map((scope) => t(SEARCH_SCOPE_LABEL_KEY_MAP[scope])).join(' + '); - }, [searchScopes, currentLanguage]); - - const searchScopePopoverContent = useMemo(() => { - const smartSelected = searchScopes.includes('smart'); - const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart'); - const borderColor = overlayTheme.sectionBorder.replace('1px solid ', ''); - const mutedTextColor = overlayTheme.mutedText; - const titleColor = overlayTheme.titleText; - const panelBg = overlayTheme.shellBg; - const smartBg = smartSelected - ? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)') - : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)'); - const smartBorder = smartSelected - ? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)') - : borderColor; - const getOptionCardStyle = (checked: boolean) => ({ - display: 'flex', - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - gap: 12, - padding: '10px 12px', - borderRadius: 12, - border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`, - background: checked - ? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)') - : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'), - transition: 'all 120ms ease', - }); - return ( -
-
-
-
{t('sidebar.command_search.scope.title')}
-
{t('sidebar.command_search.scope.description')}
-
-
- -
-
- - - -
- -
-
{t('sidebar.command_search.scope.manual_title')}
-
{t('sidebar.command_search.scope.multi_select')}
-
- -
- {scopedOptions.map((option) => { - const checked = searchScopes.includes(option.value); - return ( - - ); - })} -
- -
- {t('sidebar.command_search.scope.manual_help')} -
-
- ); - }, [darkMode, overlayTheme, searchScopes, currentLanguage]); - - const getConnectionHostSearchText = (node: TreeNode): string => { - if (node.type !== 'connection') return ''; - const config = node.dataRef?.config || {}; - return resolveConnectionHostTokens(config).join(' '); - }; - - const getConnectionNameSearchText = (node: TreeNode): string => { - if (node.type !== 'connection') return ''; - const name = node.dataRef?.name ?? node.title; - return String(name || '').toLowerCase(); - }; - - const matchByScopes = (node: TreeNode, keyword: string, scopes: SearchScope[]): boolean => { - const title = String(node.title || '').toLowerCase(); - if (scopes.includes('database') && node.type === 'database' && title.includes(keyword)) { - return true; - } - if (scopes.includes('tag') && node.type === 'tag' && title.includes(keyword)) { - return true; - } - if (scopes.includes('host') && node.type === 'connection' && getConnectionHostSearchText(node).includes(keyword)) { - return true; - } - if (scopes.includes('object') && (isV2SidebarObjectNode(node) || node.type === 'object-group') && title.includes(keyword)) { - return true; - } - if (node.type === 'external-sql-root' || node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') { - const pathText = String(node?.dataRef?.path || '').toLowerCase(); - return title.includes(keyword) || pathText.includes(keyword); - } - return false; - }; - - const loop = (data: TreeNode[], keyword: string): TreeNode[] => { - const isSmartMode = searchScopes.includes('smart'); - const result: TreeNode[] = []; - data.forEach((item) => { - const titleMatch = String(item.title || '').toLowerCase().includes(keyword); - const smartMatch = item.type === 'connection' - ? getConnectionNameSearchText(item).includes(keyword) || getConnectionHostSearchText(item).includes(keyword) - : titleMatch; - const scopedMatch = matchByScopes(item, keyword, searchScopes); - const selfMatch = isSmartMode ? smartMatch : scopedMatch; - const filteredChildren = item.children ? loop(item.children, keyword) : []; - - if (selfMatch) { - const shouldKeepFullSubtree = isSmartMode - || item.type === 'connection' - || item.type === 'database' - || item.type === 'tag' - || item.type === 'external-sql-root' - || item.type === 'external-sql-directory' - || item.type === 'external-sql-folder'; - if (item.children && shouldKeepFullSubtree) { - result.push(item); - } else if (item.children && filteredChildren.length > 0) { - result.push({ ...item, children: filteredChildren }); - } else { - result.push(item); - } - return; - } - - if (filteredChildren.length > 0) { - result.push({ ...item, children: filteredChildren }); - } - }); - return result; - }; - - const displayTreeData = useMemo(() => { - const keyword = deferredSearchValue.trim().toLowerCase(); - if (!keyword) return treeData; - return loop(treeData, keyword); - }, [deferredSearchValue, searchScopes, treeData]); - - const commandSearchTreeItems = useMemo(() => { - const result: V2CommandSearchItem[] = []; - const visit = (nodes: TreeNode[]) => { - nodes.forEach((node) => { - const dataRef = node.dataRef || {}; - if (node.type === 'connection') { - const conn = dataRef as SavedConnection; - result.push({ - key: `node-${node.key}`, - kind: 'node', - title: String(node.title || conn.name || t('connection.unnamed')), - meta: resolveConnectionHostSummary(conn.config) || conn.config?.type || t('connection.sidebar.menu.section'), - icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 16), - node, - }); - } else if (node.type === 'database') { - const conn = connections.find((item) => item.id === dataRef.id); - result.push({ - key: `node-${node.key}`, - kind: 'node', - title: String(node.title || dataRef.dbName || t('database.unnamed')), - meta: conn?.name || dataRef.id || t('database.label'), - icon: , - node, - }); - } else if ( - node.type === 'table' - || node.type === 'view' - || node.type === 'materialized-view' - || node.type === 'db-trigger' - || node.type === 'db-event' - || node.type === 'routine' - ) { - const conn = connections.find((item) => item.id === dataRef.id); - const objectName = String(dataRef.tableName || dataRef.viewName || dataRef.triggerName || dataRef.eventName || dataRef.routineName || node.title || '').trim(); - const displayName = String(node.title || extractObjectName(objectName) || objectName).trim(); - result.push({ - key: `node-${node.key}`, - kind: 'node', - title: displayName, - meta: [conn?.name || dataRef.id, dataRef.dbName].filter(Boolean).join(' · '), - icon: node.type === 'table' - ? - : (node.type === 'db-event' ? : (node.type === 'routine' ? : )), - node, - }); - } - if (node.children) visit(node.children); - }); - }; - - visit(treeData); - return result; - }, [connections, treeData]); - - const commandSearchRecentItems = useMemo(() => { - return sqlLogs.slice(0, 5).map((log) => ({ - key: `recent-${log.id}`, - kind: 'recent', - title: log.sql.replace(/\s+/g, ' ').trim() || 'SQL 记录', - meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`, - icon: , - sql: log.sql, - dbName: log.dbName, - })); - }, [sqlLogs]); - - const commandSearchActionItems = useMemo(() => [ - { - key: 'action-new-query', - kind: 'action', - title: t('query.new'), - meta: '打开一个新的 SQL 编辑页', - shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform), - icon: , - onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')), - }, - { - key: 'action-new-connection', - kind: 'action', - title: '新建数据源', - meta: '创建数据库、运行时或其他数据源连接', - shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform), - icon: , - onRun: () => onCreateConnection?.(), - }, - { - key: 'action-open-ai', - kind: 'action', - title: '打开 AI 数据洞察', - meta: '让 AI 分析当前数据库上下文', - shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform), - icon: , - onRun: () => onToggleAI?.(), - }, - { - key: 'action-open-sql-log', - kind: 'action', - title: '查看 SQL 执行日志', - meta: '打开最近执行记录面板', - shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform), - icon: , - onRun: () => onToggleLogPanel?.(), - }, - ], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]); - - const v2CommandSearchQuery = useMemo( - () => parseV2CommandSearchQuery(deferredV2CommandSearchValue), - [deferredV2CommandSearchValue], - ); - const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword; - const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object'; - const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai'; - const filteredCommandSearchTreeItems = useMemo(() => { - return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery); - }, [commandSearchTreeItems, v2CommandSearchQuery]); - - const filteredCommandSearchActionItems = useMemo(() => { - if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return []; - if (!normalizedV2CommandSearchValue) return commandSearchActionItems; - return commandSearchActionItems.filter((item) => { - const haystack = `${item.title} ${item.meta}`.toLowerCase(); - return haystack.includes(normalizedV2CommandSearchValue); - }); - }, [commandSearchActionItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]); - - const filteredCommandSearchRecentItems = useMemo(() => { - if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return []; - if (!normalizedV2CommandSearchValue) return commandSearchRecentItems; - return commandSearchRecentItems.filter((item) => { - const haystack = `${item.title} ${item.meta}`.toLowerCase(); - return haystack.includes(normalizedV2CommandSearchValue); - }); - }, [commandSearchRecentItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]); - - const commandSearchAiItem = useMemo(() => { - if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return []; - return [{ - key: 'action-ask-ai', - kind: 'action', - title: '让 AI 回答', - meta: v2CommandSearchQuery.aiPrompt, - shortcut: '↵', - icon: , - onRun: () => { - const wasClosed = !useStore.getState().aiPanelVisible; - if (wasClosed) setAIPanelVisible(true); - window.setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { - detail: { prompt: v2CommandSearchQuery.aiPrompt }, - })); - }, wasClosed ? 350 : 0); - }, - }]; - }, [setAIPanelVisible, v2CommandSearchAiMode, v2CommandSearchQuery.aiPrompt]); - - const commandSearchFlatItems = useMemo( - () => [ - ...commandSearchAiItem, - ...filteredCommandSearchTreeItems, - ...filteredCommandSearchActionItems, - ...filteredCommandSearchRecentItems, - ], - [commandSearchAiItem, filteredCommandSearchActionItems, filteredCommandSearchRecentItems, filteredCommandSearchTreeItems], - ); - - useEffect(() => { - setV2CommandActiveIndex(0); - }, [v2CommandSearchValue, commandSearchFlatItems.length]); - - const flattenConnectionNodes = useCallback((nodes: TreeNode[]): TreeNode[] => { - const result: TreeNode[] = []; - nodes.forEach((node) => { - if (node.type === 'connection') { - result.push(node); - } - if (node.children) { - result.push(...flattenConnectionNodes(node.children)); - } - }); - return result; - }, []); - - const activeConnectionId = resolveV2ActiveConnectionId({ - activeContextConnectionId: activeContext?.connectionId, - activeTabConnectionId: activeTab?.connectionId, - selectedKeys, + const { + handleCopyStructure, + handleCopyTableName, + handleExport, + openExportDialog, + handleCopyTableAsInsert, + openTableDdlInDesigner, + openTableInERView, + injectTablePromptToAI, + handleCreateDatabase, + openCreateSchemaModal, + handleCreateSchema, + openRenameSchemaModal, + handleRenameSchema, + handleDeleteSchema, + handleRenameDatabase, + handleDeleteDatabase, + handleRenameTable, + handleDeleteTable, + handleTableDataDangerAction, + openViewDefinition, + openEditView, + openCreateView, + openCreateStarRocksMaterializedView, + openCreateStarRocksExternalCatalog, + openCreateStarRocksRollup, + handleDropView, + handleRenameView, + openRenameSavedQueryModal, + handleRenameSavedQuery, + isSavedQueryUnmatched, + handleRebindSavedQuery, + openRoutineDefinition, + openEventDefinition, + openEditRoutine, + openCreateRoutine, + handleDropRoutine, + resolveMessagePublishTarget, + openMessagePublishModal, + handleMessagePublishSuccess, + } = useSidebarObjectActions({ + connections, connectionIds, - fallbackConnectionId: selectedNodesRef.current - .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) - .find(Boolean), + connectionIdSet, + tabs, + treeDataRef, + setTreeData, + setExpandedKeys, + setLoadedKeys, + addTab, + updateQueryTabDraft, + saveQuery, + addSqlLog, + closeTabsByDatabase, + createDbForm, + targetConnection, + setIsCreateDbModalOpen, + createSchemaForm, + createSchemaTarget, + setCreateSchemaTarget, + setIsCreateSchemaModalOpen, + renameSchemaForm, + renameSchemaTarget, + setRenameSchemaTarget, + setIsRenameSchemaModalOpen, + renameDbForm, + renameDbTarget, + setRenameDbTarget, + setIsRenameDbModalOpen, + renameTableForm, + renameTableTarget, + setRenameTableTarget, + setIsRenameTableModalOpen, + renameViewForm, + renameViewTarget, + setRenameViewTarget, + setIsRenameViewModalOpen, + renameSavedQueryForm, + renameSavedQueryTarget, + setRenameSavedQueryTarget, + setIsRenameSavedQueryModalOpen, + setMessagePublishTarget, + buildRuntimeConfig, + getConnectionNodeRef, + getDatabaseNodeRef, + extractObjectName, + isPostgresSchemaDialect, + loadDatabases, + loadTables, + openDesign, + onDoubleClick, + runExportWithProgress, + setAIPanelVisible, + addAIContext, }); - const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || null; - const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected'); - const activeDatabaseDisplayName = useMemo(() => { - if (activeContext && typeof activeContext === 'object' && 'dbName' in activeContext) { - return String(activeContext.dbName || '').trim(); - } - return String(activeTab?.dbName || '').trim(); - }, [activeContext, activeTab?.dbName]); - const activeConnectionTreeData = useMemo(() => { - const externalSQLNodes = displayTreeData.filter((node) => node.type === 'external-sql-root'); - if (!activeConnection) return displayTreeData; - const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id); - if (activeConnectionNode) { - return [ - ...(activeConnectionNode.children && activeConnectionNode.children.length > 0 ? activeConnectionNode.children : []), - ...externalSQLNodes, - ]; - } - const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => { - if (node.type === 'tag') { - return filterTree(node.children || []); - } - if (node.type === 'connection') { - if (node.key !== activeConnection.id) return []; - return node.children && node.children.length > 0 ? filterTree(node.children) : []; - } - return [{ ...node, children: node.children ? filterTree(node.children) : undefined }]; - }); - const filtered = filterTree(displayTreeData); - return [...filtered, ...externalSQLNodes]; - }, [activeConnection, displayTreeData]); - const v2VisibleTreeData = useMemo(() => { - if (v2ExplorerFilter === 'all') { - return displayTreeData; - } - return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter); - }, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]); - const v2TreeHorizontalScrollWidth = useMemo( - () => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth), - [treeViewportWidth, v2VisibleTreeData], - ); - const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth - ? Math.max(1, treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE) - : treeHeight; - const v2TreeMetrics = useMemo(() => { - const databaseTableCounts = new Map(); - const objectGroupCounts = new Map(); - let activeObjectCount = 0; - const visitAndCount = (node: TreeNode): number => { - const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0); - const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount; - if (node.type === 'database') { - const tableCount = (node.children || []).reduce((total, child) => { - if (child.type === 'object-group' && child?.dataRef?.groupKey === 'tables') { - return total + (Array.isArray(child.children) ? child.children.filter((item) => item.type === 'table').length : 0); - } - if (child?.dataRef?.groupKey === 'schema' && Array.isArray(child.children)) { - return total + child.children.reduce((schemaTotal, schemaChild) => { - if (schemaChild.type === 'object-group' && schemaChild?.dataRef?.groupKey === 'tables') { - return schemaTotal + (Array.isArray(schemaChild.children) ? schemaChild.children.filter((item) => item.type === 'table').length : 0); - } - return schemaTotal; - }, 0); - } - return total; - }, 0); - databaseTableCounts.set(node.key, tableCount); - } else if (node.type === 'object-group') { - objectGroupCounts.set(node.key, childCount); - } - return totalCount; - }; - activeObjectCount = v2VisibleTreeData.reduce((total, node) => total + visitAndCount(node), 0); + const refreshV2TableContextMenuStatsRef = useRef<(node: any) => void>(() => {}); - return { - activeObjectCount, - databaseTableCounts, - objectGroupCounts, - }; - }, [v2VisibleTreeData]); - const activeConnectionObjectCount = v2TreeMetrics.activeObjectCount; + const { + getConnectionNodeForAction, + toggleSidebarTablePinned, + handleV2TableContextMenuAction, + handleTableGroupSortAction, + handleV2TableGroupContextMenuAction, + handleV2DatabaseContextMenuAction, + disconnectConnectionNode, + deleteConnectionNode, + handleV2ConnectionContextMenuAction, + handleV2ConnectionGroupContextMenuAction, + } = useSidebarV2ActionHandlers({ + connections, + connectionTags, + pinnedSidebarTables, + loadingNodesRef, + treeDataRef, + findTreeNodeByKeyRef, + refreshV2TableContextMenuStatsRef, + setConnectionStates, + setExpandedKeys, + setLoadedKeys, + setTargetConnection, + setIsCreateDbModalOpen, + setRenameDbTarget, + setIsRenameDbModalOpen, + setRenameTableTarget, + setIsRenameTableModalOpen, + setRenameViewTarget, + setIsCreateTagModalOpen, + renameDbForm, + renameTableForm, + createTagForm, + addTab, + closeTabsByDatabase, + closeTabsByConnection, + removeConnection, + removeConnectionTag, + moveConnectionToTag, + setSidebarTablePinned, + setTableSortPreference, + replaceTreeNodeChildren, + loadDatabases, + loadTables, + getDatabaseNodeRef, + extractObjectName, + openDesign, + openNewTableDesign, + onDoubleClick, + openMessagePublishModal, + openTableDdlInDesigner, + openTableInERView, + handleCopyTableName, + handleCopyStructure, + handleCopyTableAsInsert, + openCreateStarRocksRollup, + handleExport, + openExportDialog, + injectTablePromptToAI, + handleTableDataDangerAction, + handleDeleteTable, + openCreateSchemaModal, + openCreateStarRocksMaterializedView, + openCreateStarRocksExternalCatalog, + handleExportDatabaseSQL, + handleRunSQLFile, + handleDeleteDatabase, + onEditConnection, + handleDuplicateConnection, + buildConnectionRootQueryTabTitle, + buildConnectionRootRedisCommandTabTitle, + buildConnectionRootRedisMonitorTabTitle, + }); + const { + onSearch, + searchScopeSummary, + searchScopePopoverContent, + displayTreeData, + v2CommandSearchObjectMode, + v2CommandSearchAiMode, + filteredCommandSearchTreeItems, + filteredCommandSearchActionItems, + filteredCommandSearchRecentItems, + commandSearchAiItem, + commandSearchFlatItems, + flattenConnectionNodes, + activeConnection, + activeConnectionDisplayName, + activeDatabaseDisplayName, + v2VisibleTreeData, + v2TreeHorizontalScrollWidth, + effectiveTreeHeight, + v2TreeMetrics, + activeConnectionObjectCount, + } = useSidebarSearchModel({ + searchScopes, + setSearchScopes, + setSearchValue, + deferredSearchValue, + deferredV2CommandSearchValue, + v2CommandSearchValue, + setV2CommandActiveIndex, + v2ExplorerFilter, + treeData, + treeViewportWidth, + treeHeight, + isV2Ui, + connections, + connectionIds, + selectedKeys, + selectedNodesRef, + activeContext, + activeTab, + sqlLogs, + shortcutOptions, + activeShortcutPlatform, + overlayTheme, + darkMode, + onCreateConnection, + onToggleAI, + onToggleLogPanel, + setAIPanelVisible, + extractObjectName, + }); const legacyToolbarButtonColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)'; const legacyToolbarStyle: React.CSSProperties = { padding: '6px 16px', @@ -3768,6 +2026,7 @@ const Sidebar: React.FC<{ handleV2ConnectionContextMenuAction, handleV2ConnectionGroupContextMenuAction, }); + refreshV2TableContextMenuStatsRef.current = refreshV2TableContextMenuStats; const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => renderSidebarV2TreeTitle({ node, @@ -3781,108 +2040,30 @@ const Sidebar: React.FC<{ setIsTreeDragging, }); - const selectConnectionFromRail = useCallback((conn: SavedConnection) => { - const key = conn.id; - const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key); - setSelectedKeys([key]); - selectedNodesRef.current = connectionNode ? [connectionNode] : []; - setActiveContext({ connectionId: key, dbName: '' }); - mergeExpandedTreeKeys([key]); - const targetNode = connectionNode || { - key, - dataRef: conn, - type: 'connection', - }; - void loadDatabases(targetNode); - }, [setActiveContext]); - - const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => { - if (!item) return; - closeV2CommandSearch(); - if (item.kind === 'action') { - item.onRun(); - return; - } - if (item.kind === 'recent') { - addTab({ - id: `query-${Date.now()}`, - title: '最近查询', - type: 'query', - connectionId: item.connectionId || activeContext?.connectionId || activeTab?.connectionId || '', - dbName: item.dbName || activeContext?.dbName || activeTab?.dbName || '', - query: item.sql, - }); - return; - } - - const node = item.node; - const dataRef = node.dataRef || {}; - if (node.type === 'connection') { - selectConnectionFromRail(dataRef as SavedConnection); - return; - } - if (node.type === 'database') { - setActiveContext({ connectionId: resolveSidebarNodeConnectionId(node, connectionIds) || dataRef.id, dbName: dataRef.dbName }); - mergeExpandedTreeKeys([dataRef.id, node.key]); - setSelectedKeys([node.key]); - selectedNodesRef.current = [node]; - scrollSidebarTreeToKey(node.key); - return; - } - if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { - void locateObjectInSidebar({ - tabId: String(node.key || ''), - connectionId: dataRef.id, - dbName: dataRef.dbName, - tableName: dataRef.tableName || dataRef.viewName, - schemaName: dataRef.schemaName, - objectGroup: node.type === 'table' ? 'tables' : (node.type === 'materialized-view' ? 'materializedViews' : 'views'), - }); - onDoubleClick(null, node); - return; - } - if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine') { - setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); - setSelectedKeys([node.key]); - selectedNodesRef.current = [node]; - scrollSidebarTreeToKey(node.key); - onDoubleClick(null, node); - } - }, [activeContext, activeTab, addTab, closeV2CommandSearch, selectConnectionFromRail, setActiveContext]); - - const handleV2CommandSearchKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'ArrowDown') { - event.preventDefault(); - setV2CommandActiveIndex((prev) => { - if (commandSearchFlatItems.length === 0) return 0; - return Math.min(prev + 1, commandSearchFlatItems.length - 1); - }); - return; - } - if (event.key === 'ArrowUp') { - event.preventDefault(); - setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0)); - return; - } - if (event.key === 'Enter') { - if (!shouldRunV2CommandSearchEnter({ - key: event.key, - isComposing: event.nativeEvent.isComposing, - keyCode: event.nativeEvent.keyCode, - activeItemCount: commandSearchFlatItems.length, - })) { - return; - } - event.preventDefault(); - runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]); - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - closeV2CommandSearch(); - } - }; - + const { + selectConnectionFromRail, + runCommandSearchItem, + handleV2CommandSearchKeyDown, + } = useSidebarCommandSearchRunner({ + activeContext, + activeTab, + addTab, + closeV2CommandSearch, + commandSearchFlatItems, + connectionIds, + findTreeNodeByKeyRef, + locateObjectInSidebar, + loadDatabases, + mergeExpandedTreeKeys, + onDoubleClick, + scrollSidebarTreeToKey, + selectedNodesRef, + setActiveContext, + setSelectedKeys, + setV2CommandActiveIndex, + treeDataRef, + v2CommandActiveIndex, + }); expandConnectionFromRailRef.current = (connectionId: string) => { const conn = connections.find((item) => item.id === connectionId); if (conn) { @@ -3972,129 +2153,16 @@ const Sidebar: React.FC<{ extractObjectName, }); - const titleRender = (node: any) => { - let status: 'success' | 'error' | 'default' = 'default'; - if (node.type === 'connection' || node.type === 'database') { - if (connectionStates[node.key] === 'success') status = 'success'; - else if (connectionStates[node.key] === 'error') status = 'error'; - } - - const statusBadge = node.type === 'connection' || node.type === 'database' ? ( - isV2Ui - ?
{isLogPanelOpen && ( - setIsLogPanelOpen(false)} + )} @@ -4910,7 +4104,7 @@ function App() { ) : null, lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( - + ) : null, , , @@ -5907,7 +5101,7 @@ function App() {
); - const renderStep2 = () => { - const baseInfoSection = ( -
-
- {t("connection.modal.config.basic.title")} -
-
- {t("connection.modal.config.basic.description")} -
- -
- {renderConfigSectionCard({ - sectionKey: "identity", - icon: , - badge: ( - - {getConnectionConfigLayoutKindLabel(connectionConfigLayout.kind)} - - ), - children: ( - - - - ), - })} - - {!isCustom && - !isJVM && - renderConfigSectionCard({ - sectionKey: "uri", - icon: , - children: ( - <> - - - - {supportsConnectionParams && ( - - - - )} - - - - - - {uriFeedback && ( - setUriFeedback(null)} - style={{ marginBottom: 16 }} - /> - )} - {renderStoredSecretControls({ - fieldName: "uri", - clearKey: "opaqueURI", - hasStoredSecret: initialValues?.hasOpaqueURI, - clearLabel: t("connection.modal.uri.stored.clear"), - description: t("connection.modal.uri.stored.description"), - })} - - ), - })} - - {isCustom ? ( - <> - {renderConfigSectionCard({ - sectionKey: "customDriver", - icon: , - children: ( - - - - ), - })} - {renderConfigSectionCard({ - sectionKey: "customDsn", - icon: , - children: ( - <> - - - - {renderStoredSecretControls({ - fieldName: "dsn", - clearKey: "opaqueDSN", - hasStoredSecret: initialValues?.hasOpaqueDSN, - clearLabel: t("connection.modal.field.dsn.clearSaved"), - description: t( - "connection.modal.field.dsn.savedDescription", - ), - })} - - ), - })} - - ) : isJVM ? ( - <> - {unsupportedJvmModeMessage && ( - - )} -
-
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.target.title"), - t("connection.modal.jvm.target.description"), - )} -
- - - - - - -
-
-
- - {t("connection.modal.jvm.environment.title")} - - {renderChoiceCards({ - fieldName: "jvmEnvironment", - value: String(jvmEnvironment), - minWidth: 120, - options: [ - { - value: "dev", - label: t( - "connection.modal.jvm.environment.dev.label", - ), - description: t( - "connection.modal.jvm.environment.dev.description", - ), - }, - { - value: "uat", - label: t( - "connection.modal.jvm.environment.staging.label", - ), - description: t( - "connection.modal.jvm.environment.staging.description", - ), - }, - { - value: "prod", - label: t( - "connection.modal.jvm.environment.prod.label", - ), - description: t( - "connection.modal.jvm.environment.prod.description", - ), - }, - ], - })} -
- - - - - {t("connection.modal.jvm.readonlyPreferred")} - -
-
- -
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.accessMode.title"), - t("connection.modal.jvm.accessMode.description"), - )} - -
- {JVM_EDITABLE_MODES.map((mode) => { - const meta = resolveJVMModeMeta(mode); - const enabled = normalizedJvmAllowedModes.includes(mode); - const preferred = jvmPreferredMode === mode; - return ( -
handleJvmModeCardSelect(mode)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleJvmModeCardSelect(mode); - } - }} - aria-pressed={enabled} - style={{ - textAlign: "left", - padding: 14, - borderRadius: 16, - border: enabled - ? darkMode - ? "1px solid rgba(255,214,102,0.36)" - : "1px solid rgba(22,119,255,0.34)" - : darkMode - ? "1px solid rgba(255,255,255,0.08)" - : "1px solid rgba(16,24,40,0.08)", - background: enabled - ? darkMode - ? "rgba(255,214,102,0.08)" - : "rgba(22,119,255,0.06)" - : darkMode - ? "rgba(255,255,255,0.03)" - : "rgba(16,24,40,0.03)", - boxShadow: preferred - ? darkMode - ? "0 0 0 2px rgba(255,214,102,0.12)" - : "0 0 0 2px rgba(22,119,255,0.10)" - : "none", - color: darkMode ? "#f5f7ff" : "#162033", - cursor: "pointer", - transition: "all 120ms ease", - }} - > - - - {meta.label} - - {preferred ? ( - - {t("connection.modal.jvm.tag.preferred")} - - ) : null} - {!enabled ? ( - {t("connection.modal.jvm.tag.notEnabled")} - ) : null} - -
- {mode === "jmx" - ? t("connection.modal.jvm.mode.jmx.description") - : mode === "endpoint" - ? t( - "connection.modal.jvm.mode.endpoint.description", - ) - : t("connection.modal.jvm.mode.agent.description")} -
- -
- ); - })} -
-
- {t("connection.modal.jvm.preferredSummary", { - mode: resolveJVMModeMeta(String(jvmPreferredMode || "jmx")) - .label, - })} -
-
- -
- {renderJvmSectionHeader( - , - "JMX", - t("connection.modal.jvm.jmx.description"), - - {normalizedJvmAllowedModes.includes("jmx") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} -
- - - - - - -
-
- - - - - - -
-
- -
- {renderJvmSectionHeader( - , - "Endpoint", - t("connection.modal.jvm.endpoint.description"), - - {normalizedJvmAllowedModes.includes("endpoint") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} - - - - - - -
- -
- {renderJvmSectionHeader( - , - "Agent", - t("connection.modal.jvm.agent.description"), - - {normalizedJvmAllowedModes.includes("agent") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} - - - - - - -
- -
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.diagnostic.title"), - t("connection.modal.jvm.diagnostic.description"), - - - , - )} - {jvmDiagnosticEnabled ? ( - <> -
-
- - {t("connection.modal.jvm.diagnostic.transport.label")} - - {renderChoiceCards({ - fieldName: "jvmDiagnosticTransport", - value: String(jvmDiagnosticTransport), - options: [ - { - value: "agent-bridge", - label: t( - "connection.modal.jvm.diagnostic.transport.agent_bridge", - ), - description: t( - "connection.modal.jvm.diagnostic.transport.agentBridge.description", - ), - }, - { - value: "arthas-tunnel", - label: t( - "connection.modal.jvm.diagnostic.transport.arthas_tunnel", - ), - description: t( - "connection.modal.jvm.diagnostic.transport.arthasTunnel.description", - ), - }, - ], - })} -
- - - -
-
- - - - - - -
- - - -
- {[ - { - name: "jvmDiagnosticAllowObserveCommands", - label: t( - "connection.modal.jvm.diagnostic.command.observe.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.observe.description", - ), - }, - { - name: "jvmDiagnosticAllowTraceCommands", - label: t( - "connection.modal.jvm.diagnostic.command.trace.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.trace.description", - ), - }, - { - name: "jvmDiagnosticAllowMutatingCommands", - label: t( - "connection.modal.jvm.diagnostic.command.mutating.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.mutating.description", - ), - }, - ].map((item) => ( -
- - {item.label} - -
- {item.description} -
-
- ))} -
- - ) : ( -
- {t("connection.modal.jvm.diagnostic.disabledHint")} -
- )} -
-
- - ) : ( - <> - {renderConfigSectionCard({ - sectionKey: isFileDb ? "fileTarget" : "target", - icon: isFileDb ? : , - children: ( -
- - - - {isFileDb ? ( - - - - ) : ( - Number(value) > 0, - ), - ]} - style={{ marginBottom: 0 }} - > - - - )} -
- ), - })} - - {dbType === "clickhouse" && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: ( - - { - form.setFieldsValue({ mysqlTopology: "single" }); - clearConnectionTestResultForChoice(); - }} - /> - - ), - })} - - {(dbType === "postgres" || - dbType === "kingbase" || - dbType === "highgo" || - dbType === "vastbase" || - dbType === "opengauss" || - dbType === "gaussdb") && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "kafka" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "rocketmq" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "mqtt" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "rabbitmq" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {(dbType === "oracle" || isOceanBaseOracle) && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {isMySQLLike && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mysqlTopology", - value: String(mysqlTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.mysql.single.description", - ), - }, - { - value: "replica", - label: t( - "connection.modal.topology.mysql.replica.label", - ), - description: t( - "connection.modal.topology.mysql.replica.description", - ), - }, - ], - }), - })} - - {isKafka && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "kafkaTopology", - value: String(kafkaTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.kafka.topology.single.label"), - description: t( - "connection.modal.messageQueue.kafka.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.kafka.topology.cluster.description", - ), - }, - ], - }), - })} - - {isRocketMQ && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "rocketmqTopology", - value: String(rocketmqTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.rocketmq.topology.single.label"), - description: t( - "connection.modal.messageQueue.rocketmq.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.rocketmq.topology.cluster.description", - ), - }, - ], - }), - })} - - {isMQTT && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mqttTopology", - value: String(mqttTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.mqtt.topology.single.label"), - description: t( - "connection.modal.messageQueue.mqtt.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.mqtt.topology.cluster.description", - ), - }, - ], - }), - })} - - {isKafka && - kafkaTopology === "cluster" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - - - - ), - })} - - {isMQTT && - mqttTopology === "cluster" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - - - -
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "mysqlReplicaPassword", - clearKey: "mysqlReplicaPassword", - hasStoredSecret: initialValues?.hasMySQLReplicaPassword, - clearLabel: t( - "connection.modal.field.mysqlReplicaPassword.clear", - ), - description: t( - "connection.modal.field.mysqlReplicaPassword.savedDescription", - ), - })} - - ), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mongoTopology", - value: String(mongoTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.mongodb.single.description", - ), - }, - { - value: "replica", - label: t( - "connection.modal.topology.mongodb.replica.label", - ), - description: t( - "connection.modal.topology.mongodb.replica.description", - ), - }, - ], - }), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "mongoDiscovery", - icon: , - children: ( - <> - -
- {[ - { - value: false, - label: t( - "connection.modal.mongo.discovery.standard.label", - ), - description: t( - "connection.modal.mongo.discovery.standard.description", - ), - }, - { - value: true, - label: t( - "connection.modal.mongo.discovery.srv.label", - ), - description: t( - "connection.modal.mongo.discovery.srv.description", - ), - }, - ].map((option) => { - const active = mongoSrv === option.value; - return ( - - ); - })} -
- {mongoSrv && useSSH && ( - - )} - - ), - })} - - {dbType === "mongodb" && - mongoTopology === "replica" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - <> - - - - - - -
- - - - {renderStoredSecretControls({ - fieldName: "mongoReplicaPassword", - clearKey: "mongoReplicaPassword", - hasStoredSecret: initialValues?.hasMongoReplicaPassword, - clearLabel: t( - "connection.modal.field.mongoReplicaPassword.clear", - ), - description: t( - "connection.modal.field.mongoReplicaPassword.savedDescription", - ), - })} - - - - {mongoMembers.length > 0 && ( - record.host} - pagination={false} - dataSource={mongoMembers} - style={{ marginBottom: 12 }} - columns={[ - { - title: t("connection.modal.field.host.label"), - dataIndex: "host", - width: "48%", - }, - { - title: t("connection.modal.mongo.member.role"), - dataIndex: "role", - width: "32%", - render: ( - value: string, - record: MongoMemberInfo, - ) => ( - - {value || - record.state || - t("common.unknown")} - - ), - }, - { - title: t("connection.modal.mongo.member.health"), - dataIndex: "healthy", - width: "20%", - render: (value: boolean) => ( - - {value - ? t("connection.modal.mongo.member.healthy") - : t( - "connection.modal.mongo.member.unhealthy", - )} - - ), - }, - ]} - /> - )} - - ), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "mongoPolicy", - icon: , - children: ( -
- - - -
- - {t("connection.modal.mongo.readPreference.label")} - - {renderChoiceCards({ - fieldName: "mongoReadPreference", - value: String(mongoReadPreference), - minWidth: 130, - options: [ - { - value: "primary", - label: "primary", - description: t( - "connection.modal.mongo.readPreference.primary.description", - ), - }, - { - value: "primaryPreferred", - label: "primaryPreferred", - description: t( - "connection.modal.mongo.readPreference.primaryPreferred.description", - ), - }, - { - value: "secondary", - label: "secondary", - description: t( - "connection.modal.mongo.readPreference.secondary.description", - ), - }, - { - value: "secondaryPreferred", - label: "secondaryPreferred", - description: t( - "connection.modal.mongo.readPreference.secondaryPreferred.description", - ), - }, - { - value: "nearest", - label: "nearest", - description: t( - "connection.modal.mongo.readPreference.nearest.description", - ), - }, - ], - })} -
-
- ), - })} - - {isRedis && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: ( - <> - {renderChoiceCards({ - fieldName: "redisTopology", - value: String(redisTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.redis.single.description", - ), - }, - { - value: "cluster", - label: t( - "connection.modal.topology.redis.cluster.label", - ), - description: t( - "connection.modal.topology.redis.cluster.description", - ), - }, - ], - })} - {redisTopology === "cluster" && ( - - - {redisDbList.map((db) => ( - - db{db} - - ))} - - - ), - })} - - {!isFileDb && - !isRedis && - renderConfigSectionCard({ - sectionKey: "credentials", - icon: , - children: ( - <> -
- - - - - - - {dbType === "mongodb" && ( -
- - {t( - "connection.modal.mongo.authMechanism.label", - )} - - {renderChoiceCards({ - fieldName: "mongoAuthMechanism", - value: String(mongoAuthMechanism), - minWidth: 150, - options: [ - { - value: "", - label: t( - "connection.modal.mongo.authMechanism.auto.label", - ), - description: t( - "connection.modal.mongo.authMechanism.auto.description", - ), - }, - { - value: "NONE", - label: t( - "connection.modal.mongo.authMechanism.none.label", - ), - description: t( - "connection.modal.mongo.authMechanism.none.description", - ), - }, - { - value: "SCRAM-SHA-1", - label: "SCRAM-SHA-1", - description: t( - "connection.modal.mongo.authMechanism.scramSha1.description", - ), - }, - { - value: "SCRAM-SHA-256", - label: "SCRAM-SHA-256", - description: t( - "connection.modal.mongo.authMechanism.scramSha256.description", - ), - }, - { - value: "MONGODB-AWS", - label: "MONGODB-AWS", - description: t( - "connection.modal.mongo.authMechanism.aws.description", - ), - }, - ], - })} -
- )} -
- {dbType === "mongodb" && ( - - - {t("connection.modal.field.savePassword")} - - - )} - - ), - })} - - {!isFileDb && - !isRedis && - !isKafka && - renderConfigSectionCard({ - sectionKey: "databaseScope", - icon: , - children: ( - - - - ), - })} - - )} - - - ); - - const networkSecuritySection = - !isFileDb && !isJVM - ? (() => { - const effectiveUseSSL = useSSL || !!form.getFieldValue("useSSL"); - const effectiveUseSSH = useSSH || !!form.getFieldValue("useSSH"); - const effectiveUseHttpTunnel = - useHttpTunnel || - !!form.getFieldValue("useHttpTunnel"); - const effectiveUseProxy = - !effectiveUseHttpTunnel && - (useProxy || !!form.getFieldValue("useProxy")); - const networkItems: Array<{ - key: "ssl" | "ssh" | "proxy" | "httpTunnel"; - title: string; - description: string; - enabled: boolean; - }> = [ - ...(isSSLType - ? [ - { - key: "ssl" as const, - title: t("connection.modal.network.ssl_tls"), - description: t( - "connection.modal.network.ssl.description", - ), - enabled: effectiveUseSSL, - }, - ] - : []), - { - key: "ssh", - title: t("connection.modal.network.ssh.title"), - description: t("connection.modal.network.ssh.description"), - enabled: effectiveUseSSH, - }, - { - key: "proxy", - title: t("connection.modal.network.proxy.title"), - description: t("connection.modal.network.proxy.description"), - enabled: effectiveUseProxy, - }, - { - key: "httpTunnel", - title: t("connection.modal.network.httpTunnel.title"), - description: t( - "connection.modal.network.httpTunnel.description", - ), - enabled: effectiveUseHttpTunnel, - }, - ]; - const resolvedNetworkConfig = - activeNetworkConfig === "ssl" && !effectiveUseSSL - ? networkItems.find((item) => item.enabled)?.key || - (networkItems.some((item) => item.key === activeNetworkConfig) - ? activeNetworkConfig - : networkItems[0]?.key || "ssh") - : networkItems.some((item) => item.key === activeNetworkConfig) - ? activeNetworkConfig - : networkItems[0]?.key || "ssh"; - const renderNetworkPanel = () => { - if (resolvedNetworkConfig === "ssl") { - return ( -
-
- {t("connection.modal.network.ssl_tls")} -
-
- {t("connection.modal.network.ssl.panelDescription")} -
- {!effectiveUseSSL ? ( -
-
{t("connection.modal.network.ssl.disabledHint")}
-
{sslHintText}
-
- ) : ( -
-
- - {t("connection.modal.network.ssl.mode")} - - {renderChoiceCards({ - fieldName: "sslMode", - value: String(sslMode), - options: [ - { - value: "preferred", - label: t( - "connection.modal.network.ssl_mode.preferred", - ), - description: t( - "connection.modal.network.ssl.preferred.description", - ), - }, - { - value: "required", - label: t( - "connection.modal.network.ssl_mode.required", - ), - description: t( - "connection.modal.network.ssl.required.description", - ), - }, - { - value: "skip-verify", - label: t( - "connection.modal.network.ssl_mode.skip_verify", - ), - description: t( - "connection.modal.network.ssl.skipVerify.description", - ), - }, - ], - })} -
- {(supportsSSLCAPath || supportsSSLClientCertificate) && ( -
- {supportsSSLCAPath && ( - - - - - - - - - )} - {supportsSSLClientCertificate && ( - <> - - - - - - - - - - - - - - - - - - )} -
- )} - - {sslHintText} - -
- )} -
- ); - } - if (resolvedNetworkConfig === "ssh") { - return ( -
-
- {t("connection.modal.network.ssh.title")} -
-
- {t("connection.modal.network.ssh.panelDescription")} -
- {!effectiveUseSSH ? ( -
- {t("connection.modal.network.ssh.disabledHint")} -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- - - - - - - - - {renderStoredSecretControls({ - fieldName: "sshPassword", - clearKey: "sshPassword", - hasStoredSecret: initialValues?.hasSSHPassword, - clearLabel: t( - "connection.modal.network.ssh.clearPassword", - ), - description: t( - "connection.modal.network.ssh.savedDescription", - ), - })} -
- )} -
- ); - } - if (resolvedNetworkConfig === "proxy") { - return ( -
-
- {t("connection.modal.network.proxy.title")} -
-
- {t("connection.modal.network.proxy.panelDescription")} -
- {!effectiveUseProxy ? ( -
- {t("connection.modal.network.proxy.disabledHint")} -
- ) : ( -
- - - -
-
- - {t("connection.modal.network.proxy.type")} - - {renderChoiceCards({ - fieldName: "proxyType", - value: String(proxyType), - minWidth: 150, - options: [ - { - value: "socks5", - label: "SOCKS5", - description: t( - "connection.modal.network.proxy.socks5.description", - ), - }, - { - value: "http", - label: "HTTP CONNECT", - description: t( - "connection.modal.network.proxy.http.description", - ), - }, - ], - })} -
- - - -
-
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "proxyPassword", - clearKey: "proxyPassword", - hasStoredSecret: initialValues?.hasProxyPassword, - clearLabel: t( - "connection.modal.network.proxy.clearPassword", - ), - description: t( - "connection.modal.network.proxy.savedDescription", - ), - })} -
- )} -
- ); - } - return ( -
-
- {t("connection.modal.network.httpTunnel.title")} -
-
- {t( - "connection.modal.network.httpTunnel.panelDescription", - )} -
- {!effectiveUseHttpTunnel ? ( -
- {t("connection.modal.network.httpTunnel.disabledHint")} -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "httpTunnelPassword", - clearKey: "httpTunnelPassword", - hasStoredSecret: initialValues?.hasHttpTunnelPassword, - clearLabel: t( - "connection.modal.network.httpTunnel.clearPassword", - ), - description: t( - "connection.modal.network.httpTunnel.savedDescription", - ), - })} - - {t( - "connection.modal.network.httpTunnel.exclusiveHint", - )} - -
- )} -
- ); - }; - - return ( -
-
- {t("connection.modal.network.title")} -
-
- {t("connection.modal.network.description")} -
-
- {networkItems.map((item) => { - const active = item.key === resolvedNetworkConfig; - const activeColor = darkMode ? "#ffd666" : "#1677ff"; - return ( -
setActiveNetworkConfig(item.key)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setActiveNetworkConfig(item.key); - } - }} - style={{ - ...getConnectionOptionCardStyle(item.enabled), - borderColor: active - ? darkMode - ? "rgba(255,214,102,0.46)" - : "rgba(24,144,255,0.36)" - : "transparent", - background: active - ? darkMode - ? "linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)" - : "linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)" - : getConnectionOptionCardStyle(item.enabled) - .background, - boxShadow: active - ? darkMode - ? "0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)" - : "0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)" - : "none", - cursor: "pointer", - outline: "none", - }} - > -
-
-
- - - -
-
- - {item.title} - -
- {active && ( - - {t( - "connection.modal.network.currentEditing", - )} - - )} - - {item.enabled - ? t("connection.modal.network.enabled") - : t( - "connection.modal.network.notEnabled", - )} - -
-
-
- {item.description} -
-
-
-
-
- ); - })} -
-
{renderNetworkPanel()}
-
-
- {t("connection.modal.network.advanced.title")} -
- - - -
-
- ); - })() - : null; - - return ( -
{ - if (testResult) { - setTestResult(null); - setTestErrorLogOpen(false); - } - if ( - changed.uri !== undefined || - changed.connectionParams !== undefined || - changed.type !== undefined || - changed.oceanBaseProtocol !== undefined - ) { - setUriFeedback(null); - } - if (changed.useSSL !== undefined) { - setUseSSL(changed.useSSL); - if (changed.useSSL) setActiveNetworkConfig("ssl"); - } - if (changed.useSSH !== undefined) { - setUseSSH(changed.useSSH); - if (changed.useSSH) setActiveNetworkConfig("ssh"); - } - if (changed.useProxy !== undefined) { - const enabledProxy = !!changed.useProxy; - setUseProxy(enabledProxy); - if (enabledProxy) setActiveNetworkConfig("proxy"); - if (enabledProxy && form.getFieldValue("useHttpTunnel")) { - form.setFieldValue("useHttpTunnel", false); - setUseHttpTunnel(false); - } - } - if (changed.proxyType !== undefined) { - const nextType = String( - changed.proxyType || "socks5", - ).toLowerCase(); - if (nextType === "http") { - const currentPort = Number(form.getFieldValue("proxyPort") || 0); - if (!currentPort || currentPort === 1080) { - form.setFieldValue("proxyPort", 8080); - } - } else { - const currentPort = Number(form.getFieldValue("proxyPort") || 0); - if (!currentPort || currentPort === 8080) { - form.setFieldValue("proxyPort", 1080); - } - } - } - if (changed.useHttpTunnel !== undefined) { - const enabledHttpTunnel = !!changed.useHttpTunnel; - setUseHttpTunnel(enabledHttpTunnel); - if (enabledHttpTunnel) setActiveNetworkConfig("httpTunnel"); - if (enabledHttpTunnel && form.getFieldValue("useProxy")) { - form.setFieldValue("useProxy", false); - setUseProxy(false); - } - if (enabledHttpTunnel) { - const currentPort = Number( - form.getFieldValue("httpTunnelPort") || 0, - ); - if (!currentPort || currentPort <= 0) { - form.setFieldValue("httpTunnelPort", 8080); - } - } - } - if (changed.type !== undefined) setDbType(changed.type); - if (changed.jvmAllowedModes !== undefined) { - const resolvedModes = normalizeEditableJVMModes( - changed.jvmAllowedModes, - ); - const currentPreferredMode = String( - form.getFieldValue("jvmPreferredMode") || "", - ) - .trim() - .toLowerCase(); - const resolvedPreferredMode = - resolvedModes.find((mode) => mode === currentPreferredMode) || - resolvedModes[0]; - form.setFieldValue("jvmAllowedModes", resolvedModes); - form.setFieldValue("jvmPreferredMode", resolvedPreferredMode); - form.setFieldValue( - "jvmEndpointEnabled", - resolvedModes.includes("endpoint"), - ); - form.setFieldValue( - "jvmAgentEnabled", - resolvedModes.includes("agent"), - ); - } - if (changed.redisTopology !== undefined) { - const nextRedisTopology = String( - changed.redisTopology || "single", - ).toLowerCase(); - const currentRedisPort = Number(form.getFieldValue("port") || 0); - if ( - nextRedisTopology === "sentinel" && - (!currentRedisPort || currentRedisPort === 6379) - ) { - form.setFieldValue("port", 26379); - } else if ( - nextRedisTopology !== "sentinel" && - currentRedisPort === 26379 - ) { - form.setFieldValue("port", 6379); - } - const supportedDbs = buildRedisDatabaseList( - form.getFieldValue("redisDB"), - form.getFieldValue("includeRedisDatabases"), - ); - setRedisDbList(supportedDbs); - form.setFieldValue( - "includeRedisDatabases", - normalizeRedisDatabaseSelection( - form.getFieldValue("includeRedisDatabases"), - supportedDbs, - ), - ); - } - if ( - changed.type !== undefined || - changed.host !== undefined || - changed.port !== undefined || - changed.mongoHosts !== undefined || - changed.mongoTopology !== undefined || - changed.mongoSrv !== undefined - ) { - setMongoMembers([]); - } - }} - > - - {currentDriverUnavailableReason && ( - - {currentDriverUnavailableReason} - - - } - /> - )} - {currentDriverUpdateReason && ( - - {currentDriverUpdateReason} - - - } - /> - )} - {(() => { - const sectionItems: Array<{ - key: "basic" | "network" | "appearance"; - title: string; - description: string; - icon: React.ReactNode; - }> = [ - { - key: "basic", - title: t("connection.modal.config.basic.title"), - description: isJVM - ? t("connection.modal.config.basic.jvmNavDescription") - : t("connection.modal.config.basic.navDescription"), - icon: , - }, - ...(!isCustom && !isFileDb && !isJVM - ? [ - { - key: "network" as const, - title: t("connection.modal.network.title"), - description: t( - "connection.modal.network.navDescription", - ), - icon: , - }, - ] - : []), - { - key: "appearance", - title: t("connection.modal.appearance.title"), - description: t("connection.modal.appearance.description"), - icon: , - }, - ]; - const resolvedSection = sectionItems.some( - (item) => item.key === activeConfigSection, - ) - ? activeConfigSection - : sectionItems[0]?.key || "basic"; - - const effectiveIconType = customIconType || dbType; - const effectiveIconColor = - customIconColor || getDbDefaultColor(effectiveIconType); - - const appearanceSection = ( -
-
-
- {t("connection.modal.appearance.icon")} -
-
- {DB_ICON_TYPES.map((iconKey) => { - const isActive = effectiveIconType === iconKey; - return ( - - ); - })} -
-
- {t("connection.modal.appearance.current", { - name: getDbIconLabel(effectiveIconType), - })} -
-
-
-
- {t("connection.modal.appearance.color")} -
-
- {PRESET_ICON_COLORS.map((presetColor) => { - const isActive = effectiveIconColor === presetColor; - return ( -
-
-
-
- {t("connection.modal.appearance.preview")} -
-
- {getDbIcon(effectiveIconType, effectiveIconColor, 24)} - - {form.getFieldValue("name") || - t("connection.modal.appearance.previewName")} - -
- {(customIconType || customIconColor) && ( - - )} -
-
- ); - - const currentSectionContent = - resolvedSection === "basic" - ? baseInfoSection - : resolvedSection === "appearance" - ? appearanceSection - : networkSecuritySection; - - if (sectionItems.length <= 1) { - return currentSectionContent; - } - - return ( -
-
-
- {t("connection.modal.config.sections")} -
-
- {sectionItems.map((item) => { - const active = item.key === resolvedSection; - return ( - - ); - })} -
-
-
{currentSectionContent}
-
- ); - })()} - - ); - }; + const renderStep2 = () => ( + + ); const getFooter = () => { if (step === 1) { diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 3902d89..2e79517 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; import DataGrid, { buildGridFieldSelectOptions, @@ -23,6 +24,17 @@ import { getCurrentLanguage, setCurrentLanguage, type LanguagePreference } from import { V2CellContextMenuView } from './V2TableContextMenu'; import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts'; +const readDataGridSource = () => [ + './useDataGridBatchActions.ts', + './DataGrid.tsx', + './useDataGridV2Actions.ts', + './useDataGridMetadata.ts', + './useDataGridColumnResize.ts', + './dataGridStyles.ts', + './DataGridCore.tsx', + './DataGridShell.tsx', +].map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n'); + const mockStoreState = vi.hoisted(() => ({ languagePreference: 'system' as LanguagePreference, uiVersion: 'v2', @@ -88,6 +100,21 @@ vi.mock('@monaco-editor/react', () => ({ ), })); +const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; +}); +const cancelAnimationFrameMock = vi.fn(); + +vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); +vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); +vi.stubGlobal('window', { + requestAnimationFrame: requestAnimationFrameMock, + cancelAnimationFrame: cancelAnimationFrameMock, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + const renderDataGridWithI18n = ( element: React.ReactElement, options: { preference?: LanguagePreference; systemLanguages?: readonly string[] } = {}, @@ -164,7 +191,7 @@ describe('DataGrid layout', () => { }); it('localizes DataGrid error boundary, column drag affordances, and legacy row context menu labels through i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const expectedKeys = [ 'data_grid.error_boundary.title', 'data_grid.error_boundary.description', @@ -230,7 +257,7 @@ describe('DataGrid layout', () => { }); it('localizes legacy cell context menu labels through translateDataGrid', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8'); const legacyMountStart = dataGridSource.indexOf(' { }); it('localizes row copy and paste feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const rowCopyPasteFeedbackKeys = [ 'data_grid.message.select_rows_to_copy', 'data_grid.message.copied_rows', @@ -345,7 +372,7 @@ describe('DataGrid layout', () => { }); it('localizes selected-column copy and paste feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const columnCopyPasteFeedbackKeys = [ 'data_grid.message.select_same_row_cells_to_copy', 'data_grid.message.no_copyable_cells', @@ -423,7 +450,7 @@ describe('DataGrid layout', () => { }); it('localizes commit, preview SQL, and basic copy feedback in the scoped DataGrid windows', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const previewCommitCopyStart = source.indexOf('const handlePreviewChanges = useCallback'); const clipboardRowsStart = source.indexOf('const getClipboardRows = useCallback', previewCommitCopyStart); expect(previewCommitCopyStart).toBeGreaterThan(-1); @@ -478,7 +505,7 @@ describe('DataGrid layout', () => { }); it('localizes Preview SQL Modal chrome while preserving raw SQL text and operation labels', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const previewModalStart = source.indexOf('{/* Preview SQL Modal */}'); const importPreviewStart = source.indexOf('{/* Import Preview Modal */}', previewModalStart); expect(previewModalStart).toBeGreaterThan(-1); @@ -514,7 +541,7 @@ describe('DataGrid layout', () => { }); it('localizes query-result, selected-cell, copy-SQL, and current-row copy feedback', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const copyFeedbackStart = source.indexOf('const getClipboardRows = useCallback'); const copyFeedbackEnd = source.indexOf('const buildConnConfig = useCallback', copyFeedbackStart); expect(copyFeedbackStart).toBeGreaterThan(-1); @@ -552,7 +579,7 @@ describe('DataGrid layout', () => { }); it('localizes batch fill feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const batchFillCellsKeys = [ 'data_grid.message.select_cells_to_fill', 'data_grid.message.selected_cells_no_update', @@ -631,7 +658,7 @@ describe('DataGrid layout', () => { }); it('localizes editor and JSON feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const sliceCallback = (startMarker: string, endMarker: string) => { const start = source.indexOf(startMarker); expect(start).toBeGreaterThan(-1); @@ -780,7 +807,7 @@ describe('DataGrid layout', () => { mockStoreState.uiVersion = previousUiVersion; } - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toMatch(/import\s+\{\s*getCurrentLanguage,\s*t\s*\}\s+from\s+['"]\.\.\/i18n['"]/); expect(source).toMatch(/import\s+\{\s*useOptionalI18n\s*\}\s+from\s+['"]\.\.\/i18n\/provider['"]/); @@ -942,7 +969,7 @@ describe('DataGrid layout', () => { }); it('hides current-page find in JSON and text record views', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain("const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null;"); expect(source).toContain('pageFindContent={visiblePageFindContent}'); @@ -952,7 +979,7 @@ describe('DataGrid layout', () => { const source = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8'); const pageFindSource = readFileSync(new URL('./DataGridPageFind.tsx', import.meta.url), 'utf8'); - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const paginationSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8'); expect(source).toContain('data-grid-legacy-secondary-actions="true"'); @@ -1028,7 +1055,7 @@ describe('DataGrid layout', () => { }); it('keeps detached DataGrid chrome text behind translateDataGrid', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const toolbarFrameSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); const pageFindSource = readFileSync(new URL('./DataGridPageFind.tsx', import.meta.url), 'utf8'); const resultViewSource = readFileSync(new URL('./DataGridResultViewSwitcher.tsx', import.meta.url), 'utf8'); @@ -1524,7 +1551,7 @@ describe('DataGrid layout', () => { }); it('localizes DataGrid filter option labels through the filter hook translator', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8'); const filterOpOptionsStart = filterHookSource.indexOf('const filterOpOptions = React.useMemo'); const filterLogicOptionsStart = filterHookSource.indexOf('const filterLogicOptions = React.useMemo'); @@ -1965,13 +1992,13 @@ describe('DataGrid layout', () => { }); it('clears modified cell markers when refreshing the grid', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); - expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[clearAutoCommitTimer, onReload\]\);/); + expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[[\s\S]*clearAutoCommitTimer[\s\S]*onReload[\s\S]*\]\);/); }); it('routes temporal inline editors through the current connection config', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig);'); expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, currentConnConfig);'); @@ -2174,7 +2201,7 @@ describe('DataGrid layout', () => { }); it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8'); expect(source).toContain("type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';"); @@ -2200,7 +2227,7 @@ describe('DataGrid layout', () => { }); it('keeps inline cell editors stretched to the full cell width', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain('const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: \'100%\', minWidth: 0 };'); expect(source).toContain('className="data-grid-inline-editor-form-item"'); @@ -2211,7 +2238,7 @@ describe('DataGrid layout', () => { }); it('disables browser autocapitalization for inline cell editors', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const editorInputCount = source.match(/\{\.\.\.noAutoCapInputProps\}[\s\S]{0,180}className="data-grid-inline-editor-input"/g)?.length || 0; @@ -2265,10 +2292,10 @@ describe('DataGrid layout', () => { }); it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + const css = readV2ThemeCss(); expect(filterHookSource).toContain('const handleQuickWherePaste = React.useCallback'); expect(filterHookSource).toContain("event.clipboardData.getData('text/plain')"); @@ -2286,12 +2313,12 @@ describe('DataGrid layout', () => { }); it('keeps DataGrid scroll synchronization throttled to animation frames', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8'); const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8'); const paginationBarSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + const css = readV2ThemeCss(); expect(source).toContain('virtualHorizontalElementsRef'); expect(source).toContain('const handleSubmitColumnQuickFind = useCallback((submittedValue?: string) => {'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index d038b7c..3b05716 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -131,6 +131,7 @@ import DataGridPaginationBar from './DataGridPaginationBar'; import DataGridResultViewSwitcher from './DataGridResultViewSwitcher'; import DataGridSecondaryActions from './DataGridSecondaryActions'; import DataGridToolbarFrame from './DataGridToolbarFrame'; +import DataGridShell from './DataGridShell'; import DataGridModals from './DataGridModals'; import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; import DataGridPreviewPanel from './DataGridPreviewPanel'; @@ -150,1458 +151,133 @@ import { useExportProgressDialog } from './ExportProgressModal'; import { useDataGridFilters } from './useDataGridFilters'; import { useDataGridDdlView } from './useDataGridDdlView'; import { useDataGridModalEditors } from './useDataGridModalEditors'; +import { useDataGridBatchActions } from './useDataGridBatchActions'; +import { useDataGridV2Actions } from './useDataGridV2Actions'; +import { useDataGridMetadata } from './useDataGridMetadata'; +import { useDataGridColumnResize } from './useDataGridColumnResize'; import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; import { buildTableExportTab } from '../utils/tableExportTab'; +import { buildDataGridCssText } from './dataGridStyles'; // --- Error Boundary --- -interface DataGridErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -interface DataGridErrorBoundaryProps { - children: React.ReactNode; - i18nLanguage?: string; -} - -class DataGridErrorBoundary extends React.Component< +import { + DataGridErrorBoundary, + GONAVI_ROW_KEY, + GONAVI_ROW_NUMBER_COLUMN_KEY, + CELL_KEY_SEP, + CELL_SELECTION_DRAG_THRESHOLD_PX, + DATE_TIME_CACHE_LIMIT, + TABLE_CELL_PREVIEW_MAX_CHARS, + ROW_NUMBER_COLUMN_WIDTH, + DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS, + DATA_GRID_DISPLAY_RENDER_VERSION, + DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, + DEFAULT_GRID_MONO_FONT_FAMILY, + normalizedDateTimeCache, + objectCellPreviewCache, + useDataGridI18nLanguage, + makeCellKey, + splitCellKey, + resolveContextMenuFieldName, + trimSimpleCache, + looksLikeDateTimeText, + normalizeDateTimeString, + normalizeBitHexDisplayText, + isDateOnlyColumnType, + isOceanBaseOracleDisplayConnection, + normalizeOceanBaseOracleDateDisplayText, + formatCellDisplayText, + formatClipboardCellText, + normalizeClipboardTsvCell, + buildClipboardTsv, + renderHighlightedCellText, + renderCellDisplayValue, + formatCellValue, + attachDataGridVirtualEditRenderVersion, + attachDataGridDisplayRenderVersion, + hasDataGridDisplayRenderVersionChanged, + hasDataGridVirtualEditRenderVersionChanged, + toEditableText, + toFormText, + isCellValueEqualForDiff, + isCellValueEqualForRender, + INLINE_EDIT_MAX_CHARS, + shouldOpenModalEditor, + getCellFieldName, + setCellFieldValue, + looksLikeJsonText, + isPlainObject, + normalizeValueForJsonView, + isJsonViewValueEqual, + coerceJsonEditorValueForStorage, + ResizableTitle, + sortableHeaderStaticStyles, + SortableHeaderCell, + EditableContext, + CellContextMenuContext, + DataContext, + setGlobalDeletedRowKeys, + resolveEditableCellRowKey, + isEditableCellDeleted, + isEditableCellModified, + areEditableCellPropsEqual, + EditableCell, + ContextMenuRow, + buildColumnMetaMap, + hasUsableColumnMeta, + EXACT_GRID_FILTER_OPERATOR, + CONTAINS_GRID_FILTER_OPERATOR, + FILTER_FIELD_SELECT_STYLE, + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_OPTION_STYLE, + STRING_LIKE_GRID_FILTER_TYPES, + normalizeGridFilterColumnType, + isStringLikeGridFilterColumnType, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, + buildGridFieldSelectOptions, + renderGridFieldSelectOption, + buildDataGridCommitChangeSet, + CELL_ELLIPSIS_STYLE, + VIRTUAL_CELL_TEXT_STYLE, + READONLY_CELL_WRAP_STYLE, + INLINE_EDIT_FORM_ITEM_STYLE, + VIRTUAL_EDITING_CELL_STYLE, +} from './DataGridCore'; +import type { + DataGridErrorBoundaryState, DataGridErrorBoundaryProps, - DataGridErrorBoundaryState -> { - constructor(props: DataGridErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('DataGrid render error:', error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ( -
-

{t('data_grid.error_boundary.title', undefined, this.props.i18nLanguage)}

-

{t('data_grid.error_boundary.description', undefined, this.props.i18nLanguage)}

-
-                        {this.state.error?.message}
-                    
- -
- ); - } - return this.props.children; - } -} - -// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 -export const GONAVI_ROW_KEY = '__gonavi_row_key__'; -export const GONAVI_ROW_NUMBER_COLUMN_KEY = '__gonavi_row_number__'; - -// Cell key helpers for batch selection/fill. -// Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). -const CELL_KEY_SEP = '\u0001'; -const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; -const DATE_TIME_CACHE_LIMIT = 2000; -const TABLE_CELL_PREVIEW_MAX_CHARS = 240; -const ROW_NUMBER_COLUMN_WIDTH = 58; -const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ - { value: 3000, seconds: 3 }, - { value: 5000, seconds: 5 }, - { value: 10000, seconds: 10 }, - { value: 30000, seconds: 30 }, -]; -const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); -const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); -const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; -const normalizedDateTimeCache = new Map(); -const objectCellPreviewCache = new WeakMap(); -const useDataGridI18nLanguage = () => { - const i18n = useOptionalI18n(); - return i18n?.language ?? getCurrentLanguage(); -}; -const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; -const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => { - const sepIndex = cellKey.indexOf(CELL_KEY_SEP); - if (sepIndex === -1) return null; - return { - rowKey: cellKey.slice(0, sepIndex), - colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length), - }; -}; -export const resolveContextMenuFieldName = (dataIndex: string, title?: string): string => { - const name = String(dataIndex || title || '').trim(); - return name; -}; - -const trimSimpleCache = (cache: Map, limit: number) => { - if (cache.size < limit) return; - const firstKey = cache.keys().next().value; - if (typeof firstKey === 'string') { - cache.delete(firstKey); - } -}; - -const looksLikeDateTimeText = (val: string): boolean => { - if (!val) return false; - const len = val.length; - if (len < 19 || len > 48) return false; - const charCode0 = val.charCodeAt(0); - if (charCode0 < 48 || charCode0 > 57) return false; - return ( - val[4] === '-' && - val[7] === '-' && - (val[10] === ' ' || val[10] === 'T') && - val[13] === ':' && - val[16] === ':' - ); -}; - -// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing. -// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. -// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. -const normalizeDateTimeString = (val: string) => { - if (!looksLikeDateTimeText(val)) { - return val; - } - - const cached = normalizedDateTimeCache.get(val); - if (cached !== undefined) { - return cached; - } - - // 检查是否为无效日期时间(0000-00-00 或类似格式) - if (/^0{4}-0{2}-0{2}/.test(val)) { - return val; // 保持原样显示,不尝试转换 - } - - const match = val.match( - /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ - ); - const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; - trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); - normalizedDateTimeCache.set(val, normalized); - return normalized; -}; - -// --- Helper: Format Value --- -const normalizeBitHexDisplayText = (val: any, columnType?: string): string | null => { - const typeText = String(columnType || '').trim().toLowerCase(); - if (!/^varbit(?:\s*\(\s*\d+\s*\))?$/.test(typeText) - && !/^bit(?:\s+varying)?(?:\s*\(\s*\d+\s*\))?$/.test(typeText)) { - return null; - } - if (typeof val !== 'string') return null; - const raw = val.trim(); - if (!/^0x[0-9a-f]+$/i.test(raw)) return null; - try { - return BigInt(raw).toString(10); - } catch { - return null; - } -}; - -type CellDisplayConnectionLike = TemporalConnectionLike; - -const isDateOnlyColumnType = (columnType?: string): boolean => { - const normalized = String(columnType || '').trim().toLowerCase(); - if (!normalized) return false; - const base = normalized.split(/[ (]/)[0]; - return base === 'date' || base === 'newdate'; -}; - -const isOceanBaseOracleDisplayConnection = (connectionConfig?: CellDisplayConnectionLike): boolean => { - if (!connectionConfig) return false; - const type = String(connectionConfig.type || '').trim().toLowerCase(); - const driver = String(connectionConfig.driver || '').trim().toLowerCase(); - return (type === 'oceanbase' || driver === 'oceanbase') - && normalizeOceanBaseProtocol(connectionConfig.oceanBaseProtocol) === 'oracle'; -}; - -const normalizeOceanBaseOracleDateDisplayText = ( - val: string, - columnType?: string, - connectionConfig?: CellDisplayConnectionLike, -): string | null => { - if (!isDateOnlyColumnType(columnType) || !isOceanBaseOracleDisplayConnection(connectionConfig)) { - return null; - } - const trimmed = String(val || '').trim(); - if (!trimmed) return trimmed; - const match = trimmed.match( - /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?)?$/ - ); - if (!match) return null; - const [, datePart, timePart, fractionPart] = match; - if (!timePart) return datePart; - if (timePart === '00:00:00' && (!fractionPart || /^\.0+$/.test(fractionPart))) { - return datePart; - } - return null; -}; - -export const formatCellDisplayText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { - try { - if (val === null) return 'NULL'; - const bitText = normalizeBitHexDisplayText(val, columnType); - if (bitText !== null) return bitText; - if (typeof val === 'object') { - if (!Array.isArray(val) && !isPlainObject(val)) { - return String(val); - } - const cached = objectCellPreviewCache.get(val); - if (cached !== undefined) { - return cached; - } - const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length; - if (topLevelSize > 80) { - const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`; - objectCellPreviewCache.set(val, summary); - return summary; - } - try { - const nextText = JSON.stringify(val); - const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; - objectCellPreviewCache.set(val, previewText); - return previewText; - } catch { - return '[Object]'; - } - } - if (typeof val === 'string') { - const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); - if (oceanBaseDateOnly !== null) { - return oceanBaseDateOnly.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${oceanBaseDateOnly.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : oceanBaseDateOnly; - } - const normalized = normalizeDateTimeString(val); - return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; - } - return String(val); - } catch (e) { - console.error('formatCellValue error:', e); - return '[Error]'; - } -}; - -const formatClipboardCellText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { - try { - if (val === null || val === undefined) return 'NULL'; - const bitText = normalizeBitHexDisplayText(val, columnType); - if (bitText !== null) return bitText; - if (typeof val === 'string') { - const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); - if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; - return normalizeDateTimeString(val); - } - if (typeof val === 'object') { - try { - return JSON.stringify(val); - } catch { - return String(val); - } - } - return String(val); - } catch (e) { - console.error('formatClipboardCellText error:', e); - return '[Error]'; - } -}; - -const normalizeClipboardTsvCell = (text: string): string => text.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); - -const buildClipboardTsv = ( - rows: Array>, - columnNames: string[], - getColumnType?: (columnName: string) => string | undefined, - connectionConfig?: CellDisplayConnectionLike, -): string => { - if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { - return ''; - } - const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); - const lines = rows.map((row) => ( - columnNames - .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName), connectionConfig))) - .join('\t') - )); - return [header, ...lines].join('\n'); -}; - -const renderHighlightedCellText = (text: string, query: string): React.ReactNode => { - const ranges = findDataGridTextRanges(text, query); - if (ranges.length === 0) return text; - - const nodes: React.ReactNode[] = []; - let cursor = 0; - ranges.forEach((range, index) => { - if (range.start > cursor) { - nodes.push(text.slice(cursor, range.start)); - } - nodes.push( - - {text.slice(range.start, range.end)} - , - ); - cursor = range.end; - }); - if (cursor < text.length) { - nodes.push(text.slice(cursor)); - } - return <>{nodes}; -}; - -const renderCellDisplayValue = (val: any, query: string, columnType?: string, connectionConfig?: CellDisplayConnectionLike): React.ReactNode => { - const text = formatCellDisplayText(val, columnType, connectionConfig); - const content = renderHighlightedCellText(text, query); - if (val === null) return {content}; - return content; -}; - -const formatCellValue = (val: any) => renderCellDisplayValue(val, ''); - -export const attachDataGridVirtualEditRenderVersion = ( - rows: T[], - editingCell: VirtualEditingCellState | null, -): T[] => { - if (!editingCell) return rows; - - return rows.map((row) => { - const rowKey = row?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null || String(rowKey) !== editingCell.rowKey) { - return row; - } - const nextRow = { ...(row as object) } as T; - Object.defineProperty(nextRow, DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, { - value: `${editingCell.rowKey}${CELL_KEY_SEP}${editingCell.dataIndex}`, - enumerable: true, - }); - return nextRow; - }); -}; - -export const attachDataGridDisplayRenderVersion = ( - rows: T[], - renderVersion: string, -): T[] => { - if (!renderVersion) return rows; - - return rows.map((row) => { - if (!row || typeof row !== 'object') return row; - const nextRow = { ...(row as object) } as T; - Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, { - value: renderVersion, - enumerable: true, - }); - return nextRow; - }); -}; - -export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { - const nextVersion = nextRecord && typeof nextRecord === 'object' - ? (nextRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] - : undefined; - const previousVersion = previousRecord && typeof previousRecord === 'object' - ? (previousRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] - : undefined; - return nextVersion !== previousVersion; -}; - -export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { - const nextVersion = nextRecord && typeof nextRecord === 'object' - ? (nextRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] - : undefined; - const previousVersion = previousRecord && typeof previousRecord === 'object' - ? (previousRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] - : undefined; - return nextVersion !== previousVersion; -}; - -const toEditableText = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'string') return val; - try { - return JSON.stringify(val, null, 2); - } catch { - return String(val); - } -}; - -const toFormText = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'string') return normalizeDateTimeString(val); - return toEditableText(val); -}; - -// 用于变更比较:NULL 与 undefined 视为同类空值;与空字符串严格区分。 -const isCellValueEqualForDiff = (left: any, right: any): boolean => { - if (left === right) return true; - const leftNullish = left === null || left === undefined; - const rightNullish = right === null || right === undefined; - if (leftNullish || rightNullish) return leftNullish && rightNullish; - return toFormText(left) === toFormText(right); -}; - -// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。 -const isCellValueEqualForRender = (left: any, right: any): boolean => { - if (left === right) return true; - const leftNullish = left === null || left === undefined; - const rightNullish = right === null || right === undefined; - if (leftNullish || rightNullish) return leftNullish && rightNullish; - - const leftType = typeof left; - const rightType = typeof right; - if (leftType === 'object' || rightType === 'object') { - // 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。 - return false; - } - - if (leftType === 'string' || rightType === 'string') { - return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right)); - } - return left === right; -}; - -const INLINE_EDIT_MAX_CHARS = 2000; - -const shouldOpenModalEditor = (val: any): boolean => { - if (val === null || val === undefined) return false; - if (typeof val === 'string') { - if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true; - const trimmed = val.trimStart(); - return trimmed.startsWith('{') || trimmed.startsWith('['); - } - return typeof val === 'object'; -}; - -const getCellFieldName = (record: Item, dataIndex: string) => { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) return dataIndex; - return [String(rowKey), dataIndex]; -}; - -const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => { - if (!form) return; - if (Array.isArray(fieldName)) { - const [rowKey, colKey] = fieldName; - form.setFieldsValue({ [rowKey]: { [colKey]: value } }); - return; - } - form.setFieldsValue({ [fieldName]: value }); -}; - -const looksLikeJsonText = (text: string): boolean => { - const raw = (text || '').trim(); - if (!raw) return false; - const first = raw[0]; - const last = raw[raw.length - 1]; - return (first === '{' && last === '}') || (first === '[' && last === ']'); -}; - -const isPlainObject = (value: any): value is Record => { - return Object.prototype.toString.call(value) === '[object Object]'; -}; - -const normalizeValueForJsonView = (value: any): any => { - if (value === null || value === undefined) return value; - - if (typeof value === 'string') { - const normalizedText = normalizeDateTimeString(value); - if (!looksLikeJsonText(normalizedText)) return normalizedText; - try { - return normalizeValueForJsonView(JSON.parse(normalizedText)); - } catch { - return normalizedText; - } - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeValueForJsonView(item)); - } - - if (isPlainObject(value)) { - const next: Record = {}; - Object.entries(value).forEach(([key, val]) => { - next[key] = normalizeValueForJsonView(val); - }); - return next; - } - - return value; -}; - -const isJsonViewValueEqual = (left: any, right: any): boolean => { - const leftNormalized = normalizeValueForJsonView(left); - const rightNormalized = normalizeValueForJsonView(right); - - if (leftNormalized === rightNormalized) return true; - if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized; - if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized; - - if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') { - return String(leftNormalized) === String(rightNormalized); - } - - try { - return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); - } catch { - return false; - } -}; - -const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => { - if (typeof currentValue === 'string') { - const raw = currentValue.trim(); - const parsedCurrent = looksLikeJsonText(raw); - if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) { - return JSON.stringify(editedValue); - } - } - return editedValue; -}; - -// --- Resizable Header (Native Implementation) --- -const ResizableTitle = React.forwardRef((props, ref) => { - const { onResizeStart, onResizeAutoFit, width, ...restProps } = props; - - const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; - if (width) { - nextStyle.width = width; - } - - // 注意:virtual table 模式下,rc-table 会依赖 header cell 的 width 样式来渲染选择列。 - // 若这里丢失 width,可能导致左上角“全选”checkbox 不显示。 - if (!width || typeof onResizeStart !== 'function') { - return
- ); -}); - -// --- Sortable Header Cell --- -interface SortableHeaderCellProps extends React.HTMLAttributes { - id?: string; -} - -// --- Sortable Header Cell --- -interface SortableHeaderCellProps extends React.HTMLAttributes { - id?: string; -} - -// 静态 CSS 移到组件外,强制去除 th 内边距并确保指针穿透 -const sortableHeaderStaticStyles = ` - .gonavi-sortable-header-cell { - padding: 0 !important; - overflow: hidden; - } - .gonavi-sortable-header-cell[data-cursor-grabbing="true"], - .gonavi-sortable-header-cell[data-cursor-grabbing="true"] *, - .gonavi-sortable-header-cell.is-dragging, - .gonavi-sortable-header-cell.is-dragging * { - cursor: grabbing !important; - } - .sortable-header-cell-drag-handle { - display: flex; - align-items: center; - width: 100%; - height: 100%; - min-height: var(--gonavi-header-min-height, 40px); - padding: 0 10px; - user-select: none; - cursor: inherit; - overflow: hidden; - } -`; - -const SortableHeaderCell: React.FC = React.memo((props) => { - const { id, children, style: propStyle, className: propClassName, ...restProps } = props; - const [isPressed, setIsPressed] = useState(false); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: id || '' }); - - const style: React.CSSProperties = { - ...propStyle, - transform: CSS.Transform.toString(transform), - transition, - ...(isDragging ? { - position: 'relative', - zIndex: 9999, - opacity: 0.6, - backgroundColor: 'rgba(24, 144, 255, 0.15)', - boxShadow: '0 4px 12px rgba(0,0,0,0.15)' - } : {}), - touchAction: 'none', - willChange: 'transform', - // 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制 - cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer', - }; - - useEffect(() => { - const handleGlobalMouseUp = () => setIsPressed(false); - window.addEventListener('mouseup', handleGlobalMouseUp); - return () => window.removeEventListener('mouseup', handleGlobalMouseUp); - }, []); - - if (!id || id === 'GONAVI_SELECTION_COLUMN') { - return {children}; - } - - return ( - { - setIsPressed(true); - if (listeners?.onPointerDown) listeners.onPointerDown(e); - }} - > - -
-
- {children} -
-
-
- ); -}); - -// --- Contexts --- -const EditableContext = React.createContext(null); -const CellContextMenuContext = React.createContext<{ - showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; - handleBatchFillToSelected: (record: Item, dataIndex: string) => void; -} | null>(null); -const DataContext = React.createContext<{ - selectedRowKeysRef: React.MutableRefObject; - displayDataRef: React.MutableRefObject; - handleCopyInsert: (r: any) => void; - handleCopyUpdate: (r: any) => void; - handleCopyDelete: (r: any) => void; - handleCopyJson: (r: any) => void; - handleCopyCsv: (r: any) => void; - handleExportSelected: (options: DataExportFileOptions, r: any) => Promise; - copyToClipboard: (t: string) => void; - tableName?: string; - enableRowContextMenu: boolean; - supportsCopyInsert: boolean; -} | null>(null); - -interface Item { - [key: string]: any; -} - -interface EditableCellProps { - title: React.ReactNode; - editable: boolean; - children: React.ReactNode; - dataIndex: string; - record: Item; - handleSave: (record: Item) => void; - focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; - columnType?: string; - dbType?: string; - connectionConfig?: CellDisplayConnectionLike; - inputCellPadding?: React.CSSProperties; - as?: any; - modifiedColumns?: Record>; - rowKeyStr?: (k: React.Key) => string; - deletedRowKeys?: Set; - darkMode?: boolean; - [key: string]: any; -} - -// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。 -// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存, -// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。 -let globalDeletedRowKeys: Set = new Set(); - -const resolveEditableCellRowKey = ( - record: Item | undefined, - rowKeyStr?: (k: React.Key) => string, -): string | null => { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null || typeof rowKeyStr !== 'function') { - return null; - } - return rowKeyStr(rowKey); -}; - -const isEditableCellDeleted = ( - record: Item | undefined, - deletedRowKeys?: Set, - rowKeyStr?: (k: React.Key) => string, -): boolean => { - const rowKey = resolveEditableCellRowKey(record, rowKeyStr); - return rowKey ? !!deletedRowKeys?.has(rowKey) : false; -}; - -const isEditableCellModified = ( - record: Item | undefined, - dataIndex: string, - modifiedColumns?: Record>, - rowKeyStr?: (k: React.Key) => string, -): boolean => { - const rowKey = resolveEditableCellRowKey(record, rowKeyStr); - return rowKey ? !!modifiedColumns?.[rowKey]?.has(dataIndex) : false; -}; - -const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: EditableCellProps): boolean => { - if (prevProps.editable !== nextProps.editable) return false; - if (prevProps.dataIndex !== nextProps.dataIndex) return false; - if (prevProps.title !== nextProps.title) return false; - if (prevProps.columnType !== nextProps.columnType) return false; - if (prevProps.dbType !== nextProps.dbType) return false; - if ((prevProps.connectionConfig?.type ?? null) !== (nextProps.connectionConfig?.type ?? null)) return false; - if ((prevProps.connectionConfig?.driver ?? null) !== (nextProps.connectionConfig?.driver ?? null)) return false; - if ((prevProps.connectionConfig?.oceanBaseProtocol ?? null) !== (nextProps.connectionConfig?.oceanBaseProtocol ?? null)) return false; - if (prevProps.darkMode !== nextProps.darkMode) return false; - if (prevProps.as !== nextProps.as) return false; - if (prevProps.handleSave !== nextProps.handleSave) return false; - if (prevProps.focusCell !== nextProps.focusCell) return false; - if ((prevProps.inputCellPadding?.padding ?? null) !== (nextProps.inputCellPadding?.padding ?? null)) return false; - if (prevProps.style !== nextProps.style) return false; - - const prevRecord = prevProps.record; - const nextRecord = nextProps.record; - if (resolveEditableCellRowKey(prevRecord, prevProps.rowKeyStr) !== resolveEditableCellRowKey(nextRecord, nextProps.rowKeyStr)) { - return false; - } - if (hasDataGridFindRenderVersionChanged(nextRecord, prevRecord)) { - return false; - } - if (!isCellValueEqualForRender(prevRecord?.[prevProps.dataIndex], nextRecord?.[nextProps.dataIndex])) { - return false; - } - if (isEditableCellDeleted(prevRecord, prevProps.deletedRowKeys, prevProps.rowKeyStr) !== isEditableCellDeleted(nextRecord, nextProps.deletedRowKeys, nextProps.rowKeyStr)) { - return false; - } - if (isEditableCellModified(prevRecord, prevProps.dataIndex, prevProps.modifiedColumns, prevProps.rowKeyStr) !== isEditableCellModified(nextRecord, nextProps.dataIndex, nextProps.modifiedColumns, nextProps.rowKeyStr)) { - return false; - } - - return true; -}; - -const EditableCell: React.FC = React.memo(({ - title, - editable, - children, - dataIndex, - record, - handleSave, - focusCell, - columnType, - dbType, - connectionConfig, - inputCellPadding, - as: Component = 'td', - modifiedColumns, - rowKeyStr, - deletedRowKeys, - darkMode, - ...restProps -}) => { - const [editing, setEditing] = useState(false); - const inputRef = useRef(null); - const cellRef = useRef(null); - const pickerOpenRef = useRef(false); - const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); - const form = useContext(EditableContext); - const cellContextMenuContext = useContext(CellContextMenuContext); - const i18nLanguage = useDataGridI18nLanguage(); - const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage); - - /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ - const lockTableScroll = useCallback((lock: boolean) => { - if (lock) { - // 查找虚拟滚动容器或常规滚动容器 - const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; - if (tableWrapper) { - const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); }; - tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); - scrollLockRef.current = { el: tableWrapper, handler }; - } - } else if (scrollLockRef.current) { - const { el, handler } = scrollLockRef.current; - el.removeEventListener('wheel', handler, { capture: true } as any); - scrollLockRef.current = null; - } - }, []); - - useEffect(() => { - if (editing) { - // 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值) - const raw = record[dataIndex]; - const fieldName = getCellFieldName(record, dataIndex); - if (isDateTimeField) { - const dayjsVal = parseToDayjs(raw, pickerType); - setCellFieldValue(form, fieldName, dayjsVal); - } else { - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; - setCellFieldValue(form, fieldName, initialValue); - } - inputRef.current?.focus(); - } - }, [editing]); - - const toggleEdit = () => { - setEditing(!editing); - }; - - const save = async (pickerValue?: dayjs.Dayjs | null) => { - try { - if (!form || !editing) return; - const fieldName = getCellFieldName(record, dataIndex); - await form.validateFields([fieldName]); - let nextValue = form.getFieldValue(fieldName); - if (isDateTimeField) { - nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); - } - toggleEdit(); - // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 - if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { - handleSave({ ...record, [dataIndex]: nextValue }); - } - // 保存后移除焦点 - if (inputRef.current) { - inputRef.current.blur(); - } - } catch (errInfo) { - console.log('Save failed:', errInfo); - // 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态 - if (isDateTimeField && editing) setEditing(false); - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - if (!cellContextMenuContext) return; - e.preventDefault(); - e.stopPropagation(); // 阻止冒泡到行级菜单 - cellContextMenuContext.showMenu(e, record, dataIndex, title); - }; - - let childNode = children; - - const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig); - const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); - - const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined - ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) - : false; - - const isModified = !editing && modifiedColumns && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined - ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex) - : false; - - const modifiedStyle: React.CSSProperties | undefined = isModified - ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } - : undefined; - - if (editable) { - childNode = editing ? ( - - {isDateTimeField ? ( - pickerType === 'time' ? ( - setTimeout(() => { void save(value); }, 0)} - onOpenChange={lockTableScroll} - onBlur={() => setTimeout(() => { void save(); }, 0)} - needConfirm={false} - /> - ) : pickerType === 'datetime' ? ( - ( - { - // 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。 - // 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。 - const fieldName = getCellFieldName(record, dataIndex); - setCellFieldValue(form, fieldName, dayjs()); - }} - >{dateTimePickerNowLabel} - )} - onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} - onOpenChange={(open) => { - pickerOpenRef.current = open; - lockTableScroll(open); - // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 - if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); - }} - onBlur={() => { - // 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。 - // 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。 - setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150); - }} - needConfirm - /> - ) : ( - setTimeout(() => { void save(value); }, 0)} - onOpenChange={lockTableScroll} - onBlur={() => setTimeout(() => { void save(); }, 0)} - needConfirm={false} - /> - ) - ) : ( - { void save(); }} - onBlur={() => { void save(); }} - onFocus={(e) => { - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - onDoubleClick={(e) => { - e.stopPropagation(); - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - /> - )} - - ) : ( -
- {children} -
- ); - } else if (cellContextMenuContext) { - // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 - childNode = ( -
- {children} -
- ); - } else if (isModified) { - childNode = ( -
- {children} -
- ); - } - - const handleDoubleClick = () => { - if (!editable) return; - if (isRowDeleted) return; - // 模块级检查:绕过 React 渲染链条,确保即使组件因 memo 缓存未重渲染也能拿到最新状态 - if (record?.[GONAVI_ROW_KEY] !== undefined - && rowKeyStr - && globalDeletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))) return; - // 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。 - if (editing) return; - const raw = record?.[dataIndex]; - if (focusCell && shouldOpenModalEditor(raw)) { - focusCell(record, dataIndex, title); - return; - } - toggleEdit(); - }; - - return ( - - {childNode} - - ); -}, areEditableCellPropsEqual); - -const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { - const context = useContext(DataContext); - - if (!record || !context) return
{children}; - - const { - selectedRowKeysRef, - displayDataRef, - handleCopyInsert, - handleCopyUpdate, - handleCopyDelete, - handleCopyJson, - handleCopyCsv, - handleExportSelected, - copyToClipboard, - enableRowContextMenu, - supportsCopyInsert, - } = context; - - if (!enableRowContextMenu) { - return {children}; - } - - const getTargets = () => { - const keys = selectedRowKeysRef.current; - const recordKey = record?.[GONAVI_ROW_KEY]; - if (recordKey !== undefined && keys.includes(recordKey)) { - return displayDataRef.current.filter(d => keys.includes(d?.[GONAVI_ROW_KEY])); - } - return [record]; - }; - - const menuItems: MenuProps['items'] = [ - ...(supportsCopyInsert ? [{ - key: 'insert', - label: t('data_grid.context_menu.copy_as_insert'), - icon: , - onClick: () => handleCopyInsert(record), - }, { - key: 'update', - label: t('data_grid.context_menu.copy_as_update'), - icon: , - onClick: () => handleCopyUpdate(record), - }, { - key: 'delete', - label: t('data_grid.context_menu.copy_as_delete'), - icon: , - onClick: () => handleCopyDelete(record), - }] : []), - { key: 'json', label: t('data_grid.context_menu.copy_as_json'), icon: , onClick: () => handleCopyJson(record) }, - { key: 'csv', label: t('data_grid.context_menu.copy_as_csv'), icon: , onClick: () => handleCopyCsv(record) }, - { key: 'copy', label: t('data_grid.context_menu.copy_as_markdown'), icon: , onClick: () => { - const records = getTargets(); - const orderedCols = displayDataRef.current.length > 0 - ? Object.keys(displayDataRef.current[0]).filter(c => c !== GONAVI_ROW_KEY) - : []; - const header = `| ${orderedCols.join(' | ')} |`; - const separator = `| ${orderedCols.map(() => '---').join(' | ')} |`; - const rows = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - return String(v).replace(/\|/g, '\\|').replace(/\n/g, ' '); - }); - return `| ${values.join(' | ')} |`; - }); - copyToClipboard([header, separator, ...rows].join('\n')); - } }, - { type: 'divider' }, - { - key: 'export-selected', - label: t('data_grid.context_menu.export_selected'), - icon: , - children: [ - { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) }, - { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) }, - { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) }, - { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) }, - { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) }, - ] - } - ]; - - return ( - document.body} autoAdjustOverflow> - {children} - - ); -}); - -interface DataGridProps { - data: any[]; - columnNames: string[]; - loading: boolean; - tableName?: string; - objectType?: 'table' | 'view' | 'materialized-view'; - exportScope?: 'table' | 'queryResult'; - resultSql?: string; - resultExportAllSql?: string; - dbName?: string; - connectionId?: string; - pkColumns?: string[]; - editLocator?: EditRowLocator; - readOnly?: boolean; - showRowNumberColumn?: boolean; - onReload?: () => void; - onSort?: (field: string, order: string) => void; - onPageChange?: (page: number, size: number) => void; - pagination?: { - current: number, - pageSize: number, - total: number, - totalKnown?: boolean, - totalApprox?: boolean, - approximateTotal?: number, - totalCountLoading?: boolean, - totalCountCancelled?: boolean, - }; - onRequestTotalCount?: () => void; - onCancelTotalCount?: () => void; - sortInfoExternal?: Array<{ columnKey: string, order: string, enabled?: boolean }>; - // Filtering - showFilter?: boolean; - onToggleFilter?: () => void; - exportSqlWithFilter?: string; - onApplyFilter?: (conditions: GridFilterCondition[]) => void; - appliedFilterConditions?: FilterCondition[]; - quickWhereCondition?: string; - onApplyQuickWhereCondition?: (condition: string) => void; - scrollSnapshot?: { top: number; left: number }; - onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; - toolbarExtraActions?: React.ReactNode; -} - -type GridFilterCondition = FilterCondition & { - id: number; - column: string; - op: string; - value: string; - value2?: string; -}; - -type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; -type DdlViewLayoutMode = 'bottom' | 'side'; -type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; -type VirtualEditingCellState = { - rowKey: string; - dataIndex: string; - title: React.ReactNode; - columnType?: string; -}; - -type ColumnMeta = { - type: string; - comment: string; -}; - -const buildColumnMetaMap = (columns: ColumnDefinition[]): Record => { - const nextMap: Record = {}; - (columns || []).forEach((column: any) => { - const name = getColumnDefinitionName(column); - if (!name) return; - nextMap[name] = { - type: getColumnDefinitionType(column), - comment: getColumnDefinitionComment(column), - }; - }); - return nextMap; -}; - -const hasUsableColumnMeta = (metaMap: Record): boolean => ( - Object.values(metaMap || {}).some((meta) => { - const type = String(meta?.type || '').trim(); - const comment = String(meta?.comment || '').trim(); - return type.length > 0 || comment.length > 0; - }) -); - -type ForeignKeyTarget = { - columnName: string; - refTableName: string; - refColumnName: string; - constraintName: string; -}; - -type VirtualTableScrollReference = TableReference & { - scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void; -}; - -const EXACT_GRID_FILTER_OPERATOR = '='; -const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS'; -const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = { - width: 320, - flex: '0 1 320px', - minWidth: 260, - maxWidth: 'min(460px, 100%)', -}; -const FILTER_FIELD_POPUP_WIDTH = 520; -const FILTER_FIELD_OPTION_STYLE: React.CSSProperties = { - display: 'block', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; -const STRING_LIKE_GRID_FILTER_TYPES = new Set([ - 'bpchar', - 'char', - 'character', - 'character varying', - 'citext', - 'clob', - 'fixedstring', - 'long nvarchar', - 'long varchar', - 'longtext', - 'mediumtext', - 'nchar', - 'nclob', - 'ntext', - 'nvarchar', - 'nvarchar2', - 'string', - 'text', - 'tinytext', - 'varchar', - 'varchar2', -]); - -const normalizeGridFilterColumnType = (columnType: unknown): string => { - let normalized = String(columnType ?? '').trim().toLowerCase().replace(/\s+/g, ' '); - for (let i = 0; i < 4; i += 1) { - const wrapped = normalized.match(/^(?:nullable|lowcardinality)\((.+)\)$/); - if (!wrapped) break; - normalized = wrapped[1].trim().replace(/\s+/g, ' '); - } - return normalized; -}; - -export const isStringLikeGridFilterColumnType = (columnType: unknown): boolean => { - const normalized = normalizeGridFilterColumnType(columnType); - if (!normalized) return false; - const baseType = normalized.replace(/\(.*/, '').trim(); - return STRING_LIKE_GRID_FILTER_TYPES.has(baseType); -}; - -export const resolveDefaultGridFilterOperator = (columnType: unknown): string => ( - isStringLikeGridFilterColumnType(columnType) ? CONTAINS_GRID_FILTER_OPERATOR : EXACT_GRID_FILTER_OPERATOR -); - -export const resolveNextGridFilterOperatorForColumnChange = ({ - currentOperator, - previousColumnType, - nextColumnType, -}: { - currentOperator: unknown; - previousColumnType: unknown; - nextColumnType: unknown; -}): string => { - const current = String(currentOperator || '').trim(); - if (!current) return resolveDefaultGridFilterOperator(nextColumnType); - const previousDefault = resolveDefaultGridFilterOperator(previousColumnType); - return current === previousDefault ? resolveDefaultGridFilterOperator(nextColumnType) : current; -}; - -export const buildGridFieldSelectOptions = (columnNames: string[]) => ( - (columnNames || []).map((columnName) => { - const text = String(columnName || ''); - return { - value: text, - label: text, - title: text, - }; - }) -); - -const renderGridFieldSelectOption = (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => { - const text = String(option?.title ?? option?.label ?? option?.value ?? ''); - return ( - - {text} - - ); -}; - -type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any; - -type DataGridCommitChangeSet = { - inserts: any[]; - updates: any[]; - deletes: any[]; -}; - -export const buildDataGridCommitChangeSet = ({ - addedRows, - modifiedRows, - deletedRowKeys, - data, - editLocator, - visibleColumnNames, - rowKeyToString, - normalizeCommitCellValue, - shouldCommitColumn, - rowLocatorMessages, -}: { - addedRows: any[]; - modifiedRows: Record; - deletedRowKeys: Set; - data: any[]; - editLocator?: EditRowLocator; - visibleColumnNames: string[]; - rowKeyToString: (key: any) => string; - normalizeCommitCellValue: NormalizeCommitCellValue; - shouldCommitColumn: (columnName: string) => boolean; - rowLocatorMessages?: RowLocatorMessages; -}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => { - if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') { - return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' }; - } - - const normalizeValues = (values: Record, mode: 'insert' | 'update') => { - const normalizedValues: Record = {}; - Object.entries(values).forEach(([col, val]) => { - if (!shouldCommitColumn(col)) return; - const commitColumnName = resolveWritableColumnName(col, editLocator); - if (!commitColumnName) return; - const normalizedVal = normalizeCommitCellValue(col, val, mode); - if (normalizedVal !== undefined) { - normalizedValues[commitColumnName] = normalizedVal; - } - }); - return normalizedValues; - }; - - const originalRowsByKey = new Map(); - data.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - originalRowsByKey.set(rowKeyToString(key), row); - }); - - const inserts: any[] = []; - const updates: any[] = []; - const deletes: any[] = []; - - addedRows.forEach(row => { - const key = row?.[GONAVI_ROW_KEY]; - if (key !== undefined && key !== null && deletedRowKeys.has(rowKeyToString(key))) return; - inserts.push(normalizeValues(row, 'insert')); - }); - - for (const keyStr of deletedRowKeys) { - const originalRow = originalRowsByKey.get(keyStr); - if (!originalRow) continue; - const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); - if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; - deletes.push(locatorValues.values); - } - - for (const [keyStr, newRow] of Object.entries(modifiedRows)) { - if (deletedRowKeys.has(keyStr)) continue; - const originalRow = originalRowsByKey.get(keyStr); - if (!originalRow) continue; - - const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); - if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; - - const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); - let values: Record = {}; - if (!hasRowKey) { - values = { ...(newRow as any) }; - } else { - visibleColumnNames.forEach((col) => { - const nextVal = (newRow as any)?.[col]; - const prevVal = (originalRow as any)?.[col]; - if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; - }); - } - - const normalizedValues = normalizeValues(values, 'update'); - if (Object.keys(normalizedValues).length === 0) continue; - updates.push({ keys: locatorValues.values, values: normalizedValues }); - } - - return { ok: true, changes: { inserts, updates, deletes } }; -}; - -// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象 -const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' }; -const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - minWidth: 0, - width: '100%', -}; -const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }; -const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 }; -const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { - margin: 0, - padding: 0, - display: 'flex', - flex: '1 1 auto', - alignItems: 'center', - width: '100%', - minWidth: 0, - minHeight: 'calc(28px * var(--gn-ui-scale, 1))', - height: 'calc(28px * var(--gn-ui-scale, 1))', - overflow: 'visible', - whiteSpace: 'nowrap', - boxSizing: 'border-box', -}; - + CellDisplayConnectionLike, + SortableHeaderCellProps, + Item, + EditableCellProps, + DataGridProps, + GridFilterCondition, + GridViewMode, + DdlViewLayoutMode, + DataGridExportScope, + VirtualEditingCellState, + ColumnMeta, + ForeignKeyTarget, + VirtualTableScrollReference, + NormalizeCommitCellValue, + DataGridCommitChangeSet, +} from './DataGridCore'; +export { + GONAVI_ROW_KEY, + GONAVI_ROW_NUMBER_COLUMN_KEY, + resolveContextMenuFieldName, + formatCellDisplayText, + attachDataGridVirtualEditRenderVersion, + attachDataGridDisplayRenderVersion, + hasDataGridDisplayRenderVersionChanged, + hasDataGridVirtualEditRenderVersionChanged, + isStringLikeGridFilterColumnType, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, + buildGridFieldSelectOptions, + buildDataGridCommitChangeSet, +} from './DataGridCore'; const DataGrid: React.FC = ({ data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, resultSql, @@ -2229,20 +905,10 @@ const DataGrid: React.FC = ({ const [sortInfo, setSortInfo] = useState>([]); const [columnWidths, setColumnWidths] = useState>({}); - const [columnMetaMap, setColumnMetaMap] = useState>({}); - const [foreignKeyMap, setForeignKeyMap] = useState>({}); - const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]); - const [metadataReloadVersion, setMetadataReloadVersion] = useState(0); const mergedDisplayDataRef = useRef([]); const closeCellEditModeRef = useRef<() => void>(() => {}); const formRef = useRef(form); formRef.current = form; - const columnMetaCacheRef = useRef>>({}); - const columnMetaSeqRef = useRef(0); - const foreignKeyCacheRef = useRef>>({}); - const foreignKeySeqRef = useRef(0); - const uniqueKeyGroupsCacheRef = useRef>({}); - const uniqueKeyGroupsSeqRef = useRef(0); useEffect(() => { const ext = sortInfoExternal || []; @@ -2252,191 +918,28 @@ const DataGrid: React.FC = ({ setSortInfo(ext); }, [sortInfoExternal, sortInfo]); - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) { - setColumnMetaMap({}); - setForeignKeyMap({}); - setUniqueKeyGroups([]); - return; - } - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {}); - foreignKeySeqRef.current += 1; - setForeignKeyMap(exportScope === 'table' ? (foreignKeyCacheRef.current[cacheKey] || {}) : {}); - setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []); - }, [connectionId, dbName, tableName, exportScope]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (columnMetaCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setColumnMetaMap({}); - 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 seq = ++columnMetaSeqRef.current; - const loadColumnMeta = async () => { - let nextMap: Record | null = null; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName); - if (seq !== columnMetaSeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - continue; - } - const candidateMap = buildColumnMetaMap(res.data as ColumnDefinition[]); - if (!hasUsableColumnMeta(candidateMap)) { - continue; - } - nextMap = candidateMap; - break; - } catch { - if (seq !== columnMetaSeqRef.current) return; - } - } - - if (seq !== columnMetaSeqRef.current) return; - if (nextMap) { - columnMetaCacheRef.current[cacheKey] = nextMap; - setColumnMetaMap(nextMap); - return; - } - setColumnMetaMap({}); - }; - - void loadColumnMeta(); - }, [connections, connectionId, dbName, tableName, metadataReloadVersion]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName || exportScope !== 'table') return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (foreignKeyCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setForeignKeyMap({}); - 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 seq = ++foreignKeySeqRef.current; - DBGetForeignKeys(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName) - .then((res) => { - if (seq !== foreignKeySeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - setForeignKeyMap({}); - return; - } - const nextMap: Record = {}; - (res.data as ForeignKeyDefinition[]).forEach((fk: any) => { - const columnName = String(fk?.columnName ?? fk?.ColumnName ?? '').trim(); - const refTableName = String(fk?.refTableName ?? fk?.RefTableName ?? '').trim(); - if (!columnName || !refTableName || refTableName === '-') return; - const target: ForeignKeyTarget = { - columnName, - refTableName, - refColumnName: String(fk?.refColumnName ?? fk?.RefColumnName ?? '').trim(), - constraintName: String(fk?.constraintName ?? fk?.ConstraintName ?? fk?.name ?? fk?.Name ?? '').trim(), - }; - nextMap[columnName] = target; - }); - foreignKeyCacheRef.current[cacheKey] = nextMap; - setForeignKeyMap(nextMap); - }) - .catch(() => { - if (seq !== foreignKeySeqRef.current) return; - setForeignKeyMap({}); - }); - }, [connections, connectionId, dbName, tableName, exportScope, metadataReloadVersion]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (uniqueKeyGroupsCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setUniqueKeyGroups([]); - 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 seq = ++uniqueKeyGroupsSeqRef.current; - DBGetIndexes(config as any, normalizedDbName, normalizedTableName) - .then((res) => { - if (seq !== uniqueKeyGroupsSeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - setUniqueKeyGroups([]); - return; - } - const nextGroups = resolveUniqueKeyGroupsFromIndexes(res.data as IndexDefinition[]); - uniqueKeyGroupsCacheRef.current[cacheKey] = nextGroups; - setUniqueKeyGroups(nextGroups); - }) - .catch(() => { - if (seq !== uniqueKeyGroupsSeqRef.current) return; - setUniqueKeyGroups([]); - }); - }, [connections, connectionId, dbName, tableName, metadataReloadVersion]); - - const columnMetaMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(columnMetaMap).forEach(([name, meta]) => { - const lowerName = String(name || '').toLowerCase(); - if (!lowerName || next[lowerName]) return; - next[lowerName] = meta; - }); - return next; - }, [columnMetaMap]); - - const columnTypeMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => { - const type = String(meta?.type || '').trim(); - if (!name || !type) return; - next[name] = type; - }); - return next; - }, [columnMetaMapByLowerName]); + const { + allTableColumnNames, + columnMetaCacheRef, + columnMetaMap, + columnMetaMapByLowerName, + columnTypeMapByLowerName, + foreignKeyCacheRef, + foreignKeyMap, + foreignKeyMapByLowerName, + getColumnFilterType, + metadataReloadVersion, + setMetadataReloadVersion, + uniqueKeyGroups, + uniqueKeyGroupsCacheRef, + } = useDataGridMetadata({ + connections, + connectionId, + dbName, + tableName, + exportScope, + visibleColumnNames, + }); const displayColumnTypeMap = useMemo(() => { const next: Record = {}; @@ -2448,33 +951,6 @@ const DataGrid: React.FC = ({ return next; }, [displayColumnNames, columnMetaMap, columnTypeMapByLowerName]); - const foreignKeyMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(foreignKeyMap).forEach(([name, target]) => { - const lowerName = String(name || '').toLowerCase(); - if (!lowerName || next[lowerName]) return; - next[lowerName] = target; - }); - return next; - }, [foreignKeyMap]); - - const getColumnFilterType = useCallback((columnName: string): string => { - const normalizedName = String(columnName || '').trim(); - if (!normalizedName) return ''; - return (columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()])?.type || ''; - }, [columnMetaMap, columnMetaMapByLowerName]); - - const allTableColumnNames = useMemo(() => { - const metaColumns = Object.keys(columnMetaMap); - if (metaColumns.length > 0) { - return metaColumns; - } - if (exportScope === 'table') { - return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY); - } - return []; - }, [columnMetaMap, exportScope, visibleColumnNames]); - const normalizeCommitCellValue = useCallback( (columnName: string, value: any, mode: 'insert' | 'update') => { if (value === undefined) return undefined; @@ -2581,649 +1057,56 @@ const DataGrid: React.FC = ({ const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0); // P0 性能优化:CSS 模板字符串 memoize,仅在主题/布局变量变化时重算 - const gridCssText = useMemo(() => ` - .${gridId} .data-grid-toolbar-scroll > * { - flex-shrink: 0; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar { - height: 7px; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb { - background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'}; - border: 0; - background-clip: border-box; - border-radius: 999px; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb:hover { - background: ${darkMode ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.32)'}; - border: 0; - background-clip: border-box; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track { - background: transparent; - } - .${gridId} .ant-table, - .${gridId} .ant-table-wrapper, - .${gridId} .ant-table-container { - background: transparent !important; - border-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-wrapper, - .${gridId} .ant-table-container { - border: none !important; - overflow: hidden !important; - } - .${gridId} .ant-table-tbody > tr > td, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !important; } - .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; } - .${gridId} .ant-table-tbody > tr > td:last-child, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child, - .${gridId} .ant-table-thead > tr > th:last-child { - border-inline-end-color: transparent !important; - } - /* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */ - .${gridId} .ant-table-header th:first-child, - .${gridId} .ant-table-thead > tr > th:first-child { - text-align: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-selection-column { - vertical-align: middle !important; - text-align: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - } - .${gridId} .ant-table-selection-column .ant-checkbox-wrapper { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - margin-right: 0 !important; - } - /* 窄表场景下 rc-table 会按视口等比放大选择列宽度,不能再额外锁死 header 宽度; - 这里只统一 header/body 的内边距与对齐方式,避免第一列把后续数据列整体顶偏。 */ - .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column { - text-align: center !important; - vertical-align: middle !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column .ant-checkbox-wrapper, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column .ant-checkbox-wrapper { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - margin-right: 0 !important; - } - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column { - display: flex !important; - align-items: center !important; - justify-content: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-thead > tr:first-child > th:first-child, - .${gridId} .ant-table-header table > thead > tr:first-child > th:first-child { - border-top-left-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-thead > tr:first-child > th:last-child, - .${gridId} .ant-table-header table > thead > tr:first-child > th:last-child { - border-top-right-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-body { - border-bottom-left-radius: ${panelRadius}px !important; - border-bottom-right-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-thead > tr > th::before { display: none !important; } - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; } - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } - .${gridId} .ant-table-tbody > tr:hover > td, - .${gridId} .ant-table-tbody .ant-table-row:hover > .ant-table-cell { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td, - .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 0.12)`} !important; } - .${gridId} .row-added td, - .${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } - .${gridId} .row-modified td, - .${gridId} .row-modified > .ant-table-cell { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } - .${gridId} .row-deleted td, - .${gridId} .row-deleted > .ant-table-cell { background-color: ${darkMode ? '#1f1f1f' : '#f0f0f0'} !important; color: ${darkMode ? '#595959' : '#bfbfbf'} !important; text-decoration: line-through; } - .${gridId} .ant-table-tbody > tr.row-added:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; } - .${gridId} .ant-table-tbody > tr.row-modified:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; } - .${gridId} .ant-table-tbody > tr.row-deleted:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-deleted:hover > .ant-table-cell { background-color: ${darkMode ? '#2a2a2a' : '#e8e8e8'} !important; } - .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name], - .${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } - .${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"], - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"], - .${gridId} [data-cell-selected="true"] { - box-shadow: inset 0 0 0 2px ${selectionAccentHex} !important; - background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}) !important; - } - .${gridId} .ant-table-content, - .${gridId} .ant-table-body { - scrollbar-gutter: stable; - } - .${gridId} .ant-table-body { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - scroll-padding-bottom: ${tableBodyBottomPadding}px; - contain: layout paint style; - } - .${gridId} .ant-table-tbody-virtual-holder, - .${gridId} .rc-virtual-list-holder { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - scroll-padding-bottom: ${tableBodyBottomPadding}px; - contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'}; - content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'}; - } - .${gridId} .ant-table-tbody-virtual-holder-inner { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'}; - } - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'}; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody > tr > td, - .${gridId}.gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell, - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - vertical-align: middle !important; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-cell-row-hover, - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.data-grid-virtual-inline-editing { - overflow: visible; - text-overflow: clip; - white-space: normal; - } - .${gridId} .data-grid-table-wrap { - width: 100%; - max-width: 100%; - overflow: hidden; - } - .${gridId} .ant-table-sticky-scroll { - display: none !important; - } - .${gridId} .data-grid-find-highlight { - padding: 0 1px; - border-radius: 3px; - background: ${darkMode ? 'rgba(246, 196, 83, 0.42)' : 'rgba(255, 193, 7, 0.42)'}; - color: inherit; - } - .${gridId} .editable-cell-value-wrap { - display: block; - width: 100%; - min-width: 0; - min-height: 20px; - padding-right: 0; - position: relative; - contain: ${useVirtualEditablePaintContain ? 'layout paint style' : 'layout style'}; - } - .${gridId} .editable-cell-value-wrap > * { - min-width: 0; - } - .${gridId} .data-grid-inline-editor-form-item, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-row, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content { - width: 100%; - min-width: 0; - } - .${gridId} .data-grid-inline-editor-input, - .${gridId} .data-grid-inline-editor-form-item .ant-picker { - width: 100% !important; - min-width: 0; - } - .${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap { - content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'}; - contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'}; - } - /* 虚拟表列对齐:阻止 header
; - } - - return ( - - {restProps.children} - { - e.stopPropagation(); - // Pass the header element reference implicitly via event target - onResizeStart(e); - }} - onDoubleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (typeof onResizeAutoFit === 'function') { - onResizeAutoFit(e); - } - }} - onPointerDown={(e) => { - // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, - // 避免调整列宽时意外触发列拖拽排序 - e.stopPropagation(); - }} - onClick={(e) => e.stopPropagation()} - title={t('data_grid.column.resize_tooltip')} - style={{ - position: 'absolute', - right: 0, // Align to right edge - bottom: 0, - top: 0, - width: 10, - cursor: 'col-resize', - zIndex: 10, - touchAction: 'none' - }} - /> -
通过 min-width:100% 拉伸到视口, - 使 header 列宽与虚拟 body 单元格宽度精确一致 */ - .${gridId} .ant-table-header > table { - min-width: 0 !important; - } - .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { - display: none !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content { - overflow-x: hidden !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-body { - overflow-x: hidden !important; - overflow-y: auto !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-tbody-virtual-holder, - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .rc-virtual-list-holder { - overflow-x: hidden !important; - } - .${gridId} .ant-table-body { - scrollbar-width: thin; - scrollbar-color: ${floatingScrollbarThumbBg} transparent; - } - .${gridId} .ant-table-body::-webkit-scrollbar { - width: ${floatingScrollbarHeight}px; - height: 0; - } - .${gridId} .ant-table-body::-webkit-scrollbar-track { - background: ${verticalScrollbarTrackBg}; - margin: 8px 0; - border-radius: 999px; - } - .${gridId} .ant-table-body::-webkit-scrollbar-thumb { - background: ${floatingScrollbarThumbBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .ant-table-body::-webkit-scrollbar-thumb:hover { - background: ${floatingScrollbarThumbHoverBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .rc-virtual-list-holder { - scrollbar-width: thin; - scrollbar-color: ${floatingScrollbarThumbBg} transparent; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar { - width: ${floatingScrollbarHeight}px; - height: 0; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track { - background: ${verticalScrollbarTrackBg}; - margin: 8px 0; - border-radius: 999px; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { - background: ${floatingScrollbarThumbBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb:hover { - background: ${floatingScrollbarThumbHoverBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll { - position: absolute; - left: ${floatingScrollbarInset}px; - right: ${floatingScrollbarInset}px; - bottom: ${floatingScrollbarBottomOffset}px; - height: ${floatingScrollbarHeight + 4}px; - overflow-x: auto; - overflow-y: hidden; - background: transparent; - z-index: 24; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar { - height: ${floatingScrollbarHeight}px; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-track { - background: ${horizontalScrollbarTrackBg}; - border: 1px solid ${horizontalScrollbarTrackBorderColor}; - border-radius: 999px; - box-shadow: ${horizontalScrollbarTrackShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb { - background: ${horizontalScrollbarThumbBg}; - border: 1px solid ${horizontalScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${horizontalScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb:hover { - background: ${horizontalScrollbarThumbHoverBg}; - border: 1px solid ${horizontalScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${horizontalScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll-inner { - height: 1px; - } - .${gridId} .data-grid-pagination-shell { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-wrap: wrap; - max-width: 100%; - padding: 8px 10px; - border-radius: 16px; - border: 1px solid ${paginationShellBorderColor}; - background: ${paginationShellBg}; - box-shadow: ${paginationShellShadow}; - backdrop-filter: ${dataGridBackdropFilter}; - -webkit-backdrop-filter: ${dataGridBackdropFilter}; - } - .${gridId} .data-grid-pagination-summary, - .${gridId} .data-grid-pagination-page-chip { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 34px; - padding: 0 12px; - border-radius: 999px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - color: ${paginationPrimaryTextColor}; - font-size: 12px; - line-height: 1; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - .${gridId} .data-grid-pagination-kicker { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 8px; - border-radius: 999px; - background: ${paginationAccentBg}; - border: 1px solid ${paginationAccentBorderColor}; - color: ${paginationActiveItemTextColor}; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - } - .${gridId} .data-grid-pagination-summary-value { - color: ${paginationPrimaryTextColor}; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-page-chip { - color: ${paginationSecondaryTextColor}; - font-weight: 600; - } - .${gridId} .ant-pagination { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0; - color: ${paginationPrimaryTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-item, - .${gridId} .ant-pagination .ant-pagination-prev, - .${gridId} .ant-pagination .ant-pagination-next, - .${gridId} .ant-pagination .ant-pagination-jump-prev, - .${gridId} .ant-pagination .ant-pagination-jump-next { - min-width: 34px; - height: 34px; - margin-inline-end: 0; - border-radius: 12px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - box-shadow: none; - display: inline-flex; - align-items: center; - justify-content: center; - overflow: hidden; - transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; - } - .${gridId} .ant-pagination .ant-pagination-item a, - .${gridId} .ant-pagination .ant-pagination-prev .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-next .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-prev > *, - .${gridId} .ant-pagination .ant-pagination-next > * { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - border: none; - background: transparent; - border-radius: inherit; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-item:hover, - .${gridId} .ant-pagination .ant-pagination-prev:hover, - .${gridId} .ant-pagination .ant-pagination-next:hover { - background: ${paginationHoverBg}; - border-color: ${paginationActiveItemBorderColor}; - transform: translateY(-1px); - } - .${gridId} .ant-pagination .ant-pagination-item-active { - border-color: ${paginationActiveItemBorderColor}; - background: ${paginationActiveItemBg}; - box-shadow: inset 0 0 0 1px ${paginationAccentBorderColor}; - } - .${gridId} .ant-pagination .ant-pagination-item-active a { - color: ${paginationActiveItemTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-disabled, - .${gridId} .ant-pagination .ant-pagination-disabled:hover { - background: transparent; - border-color: ${paginationChipBorderColor}; - transform: none; - opacity: 0.42; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev, - .${gridId} .ant-pagination .ant-pagination-jump-next { - padding: 0; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - padding: 0; - margin: 0; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-container, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-container { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - position: relative; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon { - position: absolute !important; - top: 0 !important; - right: 0 !important; - bottom: 0 !important; - left: 0 !important; - inset: 0 !important; - width: fit-content !important; - height: fit-content !important; - min-width: 0 !important; - min-height: 0 !important; - margin: auto !important; - padding: 0 !important; - transform: none !important; - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - line-height: 1 !important; - color: ${paginationSecondaryTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis { - letter-spacing: 0.18em; - text-indent: 0.18em; - text-align: center; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon .anticon, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon .anticon, - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon svg, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon svg { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - width: 1em; - height: 1em; - line-height: 1; - } - .${gridId} .data-grid-pagination-nav-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - font-size: 12px; - line-height: 1; - } - .${gridId} .data-grid-pagination-nav-icon .anticon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - } - .${gridId} .data-grid-pagination-jump { - display: inline-flex; - align-items: center; - gap: 6px; - height: 34px; - color: ${paginationSecondaryTextColor}; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - } - .${gridId} .data-grid-pagination-jump-label { - color: ${paginationSecondaryTextColor}; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-jump-input, - .${gridId} .data-grid-pagination-jump-input.ant-input-number { - width: 64px; - min-width: 64px; - height: 34px; - display: inline-flex; - align-items: stretch; - } - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input-wrap, - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input { - height: 100%; - } - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input { - padding: 0 10px; - text-align: center; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - font-variant-numeric: tabular-nums; - line-height: 34px; - } - .${gridId} .data-grid-pagination-jump-input.ant-input-number { - border-radius: 12px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - box-shadow: none; - } - .${gridId} .data-grid-pagination-jump-button.ant-btn { - height: 34px; - min-width: 34px; - padding: 0 10px; - border-radius: 12px; - border-color: ${paginationChipBorderColor}; - background: ${paginationChipBg}; - color: ${paginationPrimaryTextColor}; - font-weight: 700; - box-shadow: none; - } - .${gridId} .data-grid-pagination-size-select { - width: 72px; - min-width: 72px; - max-width: 72px; - height: 34px; - display: inline-flex; - align-items: stretch; - } - .${gridId} .data-grid-pagination-size-select.ant-select-single, - .${gridId} .data-grid-pagination-size-select.ant-select-single.ant-select-sm { - width: 72px; - min-width: 72px; - max-width: 72px; - height: 34px; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selector { - height: 34px !important; - border-radius: 12px !important; - border: 1px solid ${paginationChipBorderColor} !important; - background: ${paginationChipBg} !important; - box-shadow: none !important; - padding: 0 24px 0 10px !important; - display: flex !important; - align-items: center !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-wrap { - display: flex !important; - align-items: center !important; - height: 100%; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search, - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search-input { - height: 100% !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-item, - .${gridId} .data-grid-pagination-size-select .ant-select-selection-placeholder { - display: flex; - align-items: center; - height: 100%; - line-height: 34px !important; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - justify-content: flex-start; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search { - inset-inline-start: 10px !important; - inset-inline-end: 24px !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-arrow { - color: ${paginationSecondaryTextColor}; - inset-inline-end: 10px; - top: 50%; - transform: translateY(-50%); - margin-top: 0; - display: inline-flex; - align-items: center; - justify-content: center; - height: 16px; - line-height: 1; - } - .${gridId} .data-grid-pagination-size-select .ant-select-arrow .anticon { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - } - `, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor, densityParams]); + const gridCssText = useMemo( + () => buildDataGridCssText({ + darkMode, + dataGridBackdropFilter, + dataTableVerticalBorderRule, + densityParams, + floatingScrollbarBottomOffset, + floatingScrollbarHeight, + floatingScrollbarInset, + floatingScrollbarThumbBg, + floatingScrollbarThumbBorderColor, + floatingScrollbarThumbHoverBg, + floatingScrollbarThumbShadow, + gridId, + horizontalScrollbarThumbBg, + horizontalScrollbarThumbBorderColor, + horizontalScrollbarThumbHoverBg, + horizontalScrollbarThumbShadow, + horizontalScrollbarTrackBg, + horizontalScrollbarTrackBorderColor, + horizontalScrollbarTrackShadow, + paginationAccentBg, + paginationAccentBorderColor, + paginationActiveItemBg, + paginationActiveItemBorderColor, + paginationActiveItemTextColor, + paginationChipBg, + paginationChipBorderColor, + paginationHoverBg, + paginationPrimaryTextColor, + paginationSecondaryTextColor, + paginationShellBg, + paginationShellBorderColor, + paginationShellShadow, + panelRadius, + rowAddedBg, + rowAddedHover, + rowModBg, + rowModHover, + selectionAccentHex, + selectionAccentRgb, + tableBodyBottomPadding, + useVirtualEditablePaintContain, + useVirtualEditableVisibilityHints, + useVirtualHolderPaintHints, + useVirtualRowCellContain, + verticalScrollbarTrackBg, + }), + [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor, densityParams], + ); const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => { const target = targetElement || containerRef.current; @@ -3301,7 +1184,7 @@ const DataGrid: React.FC = ({ const [modifiedRows, setModifiedRows] = useState>({}); const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set()); // 同步到模块级变量,确保 EditableCell 事件处理器始终读取最新删除状态 - globalDeletedRowKeys = deletedRowKeys; + setGlobalDeletedRowKeys(deletedRowKeys); const [modifiedColumns, setModifiedColumns] = useState>>({}); const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewSqlData, setPreviewSqlData] = useState<{ @@ -3520,625 +1403,56 @@ const DataGrid: React.FC = ({ }, [closeCellEditMode]); // 批量填充选中的单元格 - const handleBatchFillCells = useCallback(() => { - const cellsToFill = currentSelectionRef.current; - if (cellsToFill.size === 0) { - void message.info(translateDataGrid('data_grid.message.select_cells_to_fill')); - return; - } - - const fillValue = batchEditSetNull ? null : batchEditValue; - - const addedRowMap = new Map(); - addedRows.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - addedRowMap.set(rowKeyStr(k), r); - }); - - const baseRowMap = new Map(); - displayDataRef.current.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - baseRowMap.set(rowKeyStr(k), r); - }); - - const patchesByRow = new Map>(); - let updatedCount = 0; - - cellsToFill.forEach((cellKey) => { - const parts = splitCellKey(cellKey); - if (!parts) return; - const { rowKey, colName } = parts; - - const existing = modifiedRows[rowKey]; - const baseRow = baseRowMap.get(rowKey); - let currentVal: any; - - const addedRow = addedRowMap.get(rowKey); - if (addedRow) { - currentVal = addedRow?.[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) { - currentVal = (existing as any)?.[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) { - currentVal = (existing as any)?.[colName]; - } else { - currentVal = baseRow?.[colName]; - } - - const isSame = isCellValueEqualForDiff(currentVal, fillValue); - if (isSame) return; - - const patch = patchesByRow.get(rowKey) || {}; - patch[colName] = fillValue; - patchesByRow.set(rowKey, patch); - updatedCount++; - }); - - if (updatedCount === 0) { - void message.info(translateDataGrid('data_grid.message.selected_cells_no_update')); - return; - } - - // 仅做一次状态提交,避免大量 setState 循环 - setAddedRows(prev => prev.map(r => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return r; - const patch = patchesByRow.get(rowKeyStr(k)); - if (!patch) return r; - return { ...r, ...patch }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - patchesByRow.forEach((patch, keyStr) => { - if (addedRowMap.has(keyStr)) return; - - const existing = prev[keyStr]; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.filled_cells', { count: updatedCount })); - closeBatchEditModal(); - - // 清除选中状态 - setSelectedCells(new Set()); - currentSelectionRef.current = new Set(); - selectionStartRef.current = null; - isDraggingRef.current = false; - cellSelectionPointerRef.current = null; - if (cellSelectionAutoScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); - cellSelectionAutoScrollRafRef.current = null; - } - updateCellSelection(new Set()); - }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, translateDataGrid]); - - // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 - useEffect(() => { - const container = containerRef.current; - if (!canModifyData || !isTableSurfaceActive) return; - if (!container) return; - const EDGE_THRESHOLD_PX = 28; - const MIN_SCROLL_STEP = 8; - const MAX_SCROLL_STEP = 24; - - const isInteractiveTarget = (target: HTMLElement | null): boolean => { - if (!target) return false; - return !!target.closest('input, textarea, button, select, [contenteditable="true"], .ant-checkbox, .ant-picker, .ant-select, .ant-dropdown, .ant-modal'); - }; - - const getCellElement = (target: HTMLElement | null): HTMLElement | null => { - if (!target) return null; - const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement; - if (!cell || !container.contains(cell)) return null; - const colName = cell.getAttribute('data-col-name'); - if (!colName || !isWritableResultColumn(colName, effectiveEditLocator)) return null; - return cell; - }; - - const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { - const cell = getCellElement(target); - if (!cell) return null; - const rowKey = cell.getAttribute('data-row-key'); - const colName = cell.getAttribute('data-col-name'); - if (!rowKey || !colName) return null; - return { rowKey, colName }; - }; - - const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => { - const target = document.elementFromPoint(x, y) as HTMLElement | null; - return getCellInfo(target); - }; - - const applySelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { - const start = selectionStartRef.current; - if (!start) return; - - const currentData = displayDataRef.current; - const rowIndexMap = rowIndexMapRef.current; - const startRowIndex = start.rowIndex; - const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1; - if (startRowIndex === -1 || endRowIndex === -1) return; - - const startColIndex = start.colIndex; - const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - if (startColIndex === -1 || endColIndex === -1) return; - - const minRowIndex = Math.min(startRowIndex, endRowIndex); - const maxRowIndex = Math.max(startRowIndex, endRowIndex); - const minColIndex = Math.min(startColIndex, endColIndex); - const maxColIndex = Math.max(startColIndex, endColIndex); - - const newSelectedCells = new Set(); - for (let i = minRowIndex; i <= maxRowIndex; i++) { - const row = currentData[i]; - const rKey = String(row?.[GONAVI_ROW_KEY]); - for (let j = minColIndex; j <= maxColIndex; j++) { - newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); - } - } - - currentSelectionRef.current = newSelectedCells; - updateCellSelection(newSelectedCells); - }; - - const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - } - - cellSelectionRafRef.current = requestAnimationFrame(() => { - cellSelectionRafRef.current = null; - applySelectionUpdate(cellInfo); - }); - }; - - const stopAutoScroll = () => { - if (cellSelectionAutoScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); - cellSelectionAutoScrollRafRef.current = null; - } - }; - - const getScrollStep = (distanceToEdge: number): number => { - const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX)); - return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio); - }; - - const autoScrollTick = () => { - if (!isDraggingRef.current || !selectionStartRef.current) { - stopAutoScroll(); - return; - } - - const pointer = cellSelectionPointerRef.current; - const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null; - if (!pointer || !tableBody) { - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - return; - } - - const rect = tableBody.getBoundingClientRect(); - const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight); - const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth); - let deltaY = 0; - let deltaX = 0; - - if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) { - const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y; - deltaY = -getScrollStep(distance); - } else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) { - const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX); - deltaY = getScrollStep(distance); - } - - if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) { - const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x; - deltaX = -getScrollStep(distance); - } else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) { - const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX); - deltaX = getScrollStep(distance); - } - - let didScroll = false; - if (deltaY !== 0) { - const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY)); - if (nextTop !== tableBody.scrollTop) { - tableBody.scrollTop = nextTop; - didScroll = true; - } - } - - if (deltaX !== 0) { - const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX)); - if (nextLeft !== tableBody.scrollLeft) { - tableBody.scrollLeft = nextLeft; - didScroll = true; - } - } - - if (didScroll) { - const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y); - if (cellInfo) scheduleSelectionUpdate(cellInfo); - } - - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - }; - - const ensureAutoScroll = () => { - if (cellSelectionAutoScrollRafRef.current !== null) return; - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - }; - - const beginCellSelection = (cellInfo: { rowKey: string; colName: string }, x: number, y: number) => { - if (!cellEditModeRef.current) { - cellEditModeRef.current = true; - setCellEditMode(true); - } - suppressCellSelectionClickRef.current = true; - pendingCellSelectionStartRef.current = null; - isDraggingRef.current = true; - cellSelectionPointerRef.current = { x, y }; - - const currentData = displayDataRef.current; - const nextRowIndexMap = new Map(); - currentData.forEach((r, idx) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - nextRowIndexMap.set(String(k), idx); - }); - rowIndexMapRef.current = nextRowIndexMap; - - const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; - const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; - currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); - updateCellSelection(currentSelectionRef.current); - ensureAutoScroll(); - }; - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return; - const target = e.target instanceof HTMLElement ? e.target : null; - if (isInteractiveTarget(target)) return; - const cellInfo = getCellInfo(target); - if (!cellInfo) return; - - if (cellEditModeRef.current) { - e.preventDefault(); - beginCellSelection(cellInfo, e.clientX, e.clientY); - return; - } - - pendingCellSelectionStartRef.current = { ...cellInfo, x: e.clientX, y: e.clientY }; - }; - - const onMouseMove = (e: MouseEvent) => { - const pendingStart = pendingCellSelectionStartRef.current; - if (!isDraggingRef.current && pendingStart) { - const dx = e.clientX - pendingStart.x; - const dy = e.clientY - pendingStart.y; - if (Math.hypot(dx, dy) < CELL_SELECTION_DRAG_THRESHOLD_PX) return; - - e.preventDefault(); - beginCellSelection( - { rowKey: pendingStart.rowKey, colName: pendingStart.colName }, - e.clientX, - e.clientY, - ); - } - - if (!isDraggingRef.current || !selectionStartRef.current) return; - e.preventDefault(); - cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; - ensureAutoScroll(); - - const target = e.target instanceof HTMLElement ? e.target : null; - const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); - if (!cellInfo) return; - scheduleSelectionUpdate(cellInfo); - }; - - const onMouseUp = (e: MouseEvent) => { - pendingCellSelectionStartRef.current = null; - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - cellSelectionPointerRef.current = null; - stopAutoScroll(); - - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - cellSelectionRafRef.current = null; - } - - const target = e.target instanceof HTMLElement ? e.target : null; - const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); - if (cellInfo) applySelectionUpdate(cellInfo); - - if (currentSelectionRef.current.size > 0) { - setSelectedCells(new Set(currentSelectionRef.current)); - } - }; - - const onClickCapture = (e: MouseEvent) => { - if (!suppressCellSelectionClickRef.current) return; - suppressCellSelectionClickRef.current = false; - e.preventDefault(); - e.stopPropagation(); - }; - - const onScroll = () => { - if (currentSelectionRef.current.size === 0) return; - if (cellSelectionScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionScrollRafRef.current); - } - cellSelectionScrollRafRef.current = requestAnimationFrame(() => { - cellSelectionScrollRafRef.current = null; - updateCellSelection(currentSelectionRef.current); - }); - }; - - container.addEventListener('mousedown', onMouseDown); - container.addEventListener('mousemove', onMouseMove); - container.addEventListener('click', onClickCapture, true); - container.addEventListener('scroll', onScroll, true); - document.addEventListener('mouseup', onMouseUp); - - return () => { - container.removeEventListener('mousedown', onMouseDown); - container.removeEventListener('mousemove', onMouseMove); - container.removeEventListener('click', onClickCapture, true); - container.removeEventListener('scroll', onScroll, true); - document.removeEventListener('mouseup', onMouseUp); - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - cellSelectionRafRef.current = null; - } - if (cellSelectionScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionScrollRafRef.current); - cellSelectionScrollRafRef.current = null; - } - stopAutoScroll(); - pendingCellSelectionStartRef.current = null; - cellSelectionPointerRef.current = null; - isDraggingRef.current = false; - }; - }, [canModifyData, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, updateCellSelection]); - - const handleCopySelectedColumnsFromRow = useCallback(() => { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) { - void message.info(translateDataGrid('data_grid.message.select_same_row_cells_to_copy')); - return; - } - - const parsed = Array.from(activeSelection) - .map((cellKey) => splitCellKey(cellKey)) - .filter((item): item is { rowKey: string; colName: string } => !!item); - if (parsed.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_cells')); - return; - } - - const sourceRowKeySet = new Set(parsed.map((item) => item.rowKey)); - if (sourceRowKeySet.size !== 1) { - void message.info(translateDataGrid('data_grid.message.copy_columns_same_row_only')); - return; - } - - const sourceRowKey = parsed[0].rowKey; - const selectedColumnNames = Array.from(new Set(parsed.map((item) => item.colName))); - if (selectedColumnNames.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_columns')); - return; - } - - const sourceBaseRow = displayDataRef.current.find((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey; - }); - const sourceAddedRow = addedRows.find((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey; - }); - const sourceModified = modifiedRows[sourceRowKey]; - - const values: Record = {}; - selectedColumnNames.forEach((colName) => { - if (sourceAddedRow) { - values[colName] = sourceAddedRow[colName]; - return; - } - - if (sourceModified && Object.prototype.hasOwnProperty.call(sourceModified as any, colName)) { - values[colName] = (sourceModified as any)[colName]; - return; - } - - values[colName] = sourceBaseRow?.[colName]; - }); - - setCopiedCellPatch({ sourceRowKey, values }); - void message.success(translateDataGrid('data_grid.message.copied_columns', { count: selectedColumnNames.length })); - }, [selectedCells, rowKeyStr, addedRows, modifiedRows, translateDataGrid]); - - const handlePasteCopiedColumnsToSelectedRows = useCallback((fallbackRowKey?: React.Key) => { - if (!copiedCellPatch || Object.keys(copiedCellPatch.values).length === 0) { - void message.info(translateDataGrid('data_grid.message.copy_columns_first')); - return; - } - - const writablePatchValues = Object.fromEntries( - Object.entries(copiedCellPatch.values) - .filter(([colName]) => isWritableResultColumn(colName, effectiveEditLocator)) - ); - if (Object.keys(writablePatchValues).length === 0) { - void message.info(translateDataGrid('data_grid.message.no_pasteable_editable_fields')); - return; - } - - const targetKeySet = new Set(); - const selectedKeys = selectedRowKeysRef.current; - if (selectedKeys.length > 0) { - selectedKeys.forEach((key) => targetKeySet.add(rowKeyStr(key))); - } else if (fallbackRowKey !== undefined && fallbackRowKey !== null) { - targetKeySet.add(rowKeyStr(fallbackRowKey)); - } else { - void message.info(translateDataGrid('data_grid.message.select_target_rows')); - return; - } - - targetKeySet.delete(copiedCellPatch.sourceRowKey); - if (targetKeySet.size === 0) { - void message.info(translateDataGrid('data_grid.message.target_rows_cannot_only_source')); - return; - } - - const addedRowMap = new Map(); - addedRows.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - addedRowMap.set(rowKeyStr(key), row); - }); - - const baseRowMap = new Map(); - displayDataRef.current.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - baseRowMap.set(rowKeyStr(key), row); - }); - - const patchesByRow = new Map>(); - let updatedCellCount = 0; - - targetKeySet.forEach((targetRowKey) => { - const patch: Record = {}; - const existing = modifiedRows[targetRowKey]; - const addedRow = addedRowMap.get(targetRowKey); - const baseRow = baseRowMap.get(targetRowKey); - - Object.entries(writablePatchValues).forEach(([colName, nextValue]) => { - let currentValue: any; - - if (addedRow) { - currentValue = addedRow[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) { - currentValue = (existing as any)[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) { - currentValue = (existing as any)[colName]; - } else { - currentValue = baseRow?.[colName]; - } - - if (isCellValueEqualForDiff(currentValue, nextValue)) return; - patch[colName] = nextValue; - updatedCellCount++; - }); - - if (Object.keys(patch).length > 0) { - patchesByRow.set(targetRowKey, patch); - } - }); - - if (patchesByRow.size === 0 || updatedCellCount === 0) { - void message.info(translateDataGrid('data_grid.message.target_rows_no_update')); - return; - } - - setAddedRows(prev => prev.map((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return row; - const patch = patchesByRow.get(rowKeyStr(key)); - if (!patch) return row; - return { ...row, ...patch }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - patchesByRow.forEach((patch, keyStr) => { - if (addedRowMap.has(keyStr)) return; - const existing = prev[keyStr]; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.pasted_columns_to_rows', { rows: patchesByRow.size, cells: updatedCellCount })); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr, effectiveEditLocator, translateDataGrid]); - - // 批量填充到选中行 - const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { - if (!isWritableResultColumn(dataIndex, effectiveEditLocator)) { - void message.info(translateDataGrid('data_grid.message.current_field_not_editable')); - return; - } - const sourceValue = sourceRecord[dataIndex]; - const selKeys = selectedRowKeysRef.current; - - if (selKeys.length === 0) { - void message.info(translateDataGrid('data_grid.message.select_rows_to_fill')); - return; - } - - const sourceKey = sourceRecord?.[GONAVI_ROW_KEY]; - // 过滤掉源行本身 - const targetKeys = selKeys.filter(k => k !== sourceKey); - - if (targetKeys.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_other_rows_to_fill')); - return; - } - - // 批量更新 - const addedKeySet = new Set(); - addedRows.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - addedKeySet.add(rowKeyStr(k)); - }); - - const targetKeyStrList = targetKeys.map(rowKeyStr); - const targetKeyStrSet = new Set(targetKeyStrList); - const updatedCount = targetKeyStrSet.size; - - setAddedRows(prev => prev.map(r => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return r; - const keyStr = rowKeyStr(k); - if (!targetKeyStrSet.has(keyStr)) return r; - return { ...r, [dataIndex]: sourceValue }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - targetKeyStrSet.forEach((keyStr) => { - if (addedKeySet.has(keyStr)) return; - const existing = prev[keyStr]; - const patch = { [dataIndex]: sourceValue }; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.filled_rows', { count: updatedCount })); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [addedRows, rowKeyStr, effectiveEditLocator, translateDataGrid]); + const { + handleBatchFillCells, + handleCopySelectedColumnsFromRow, + handlePasteCopiedColumnsToSelectedRows, + handleBatchFillToSelected, + } = useDataGridBatchActions({ + CELL_SELECTION_DRAG_THRESHOLD_PX, + GONAVI_ROW_KEY, + addedRows, + batchEditSetNull, + batchEditValue, + canModifyData, + cancelAnimationFrame, + cellEditModeRef, + cellSelectionAutoScrollRafRef, + cellSelectionPointerRef, + cellSelectionRafRef, + cellSelectionScrollRafRef, + closeBatchEditModal, + columnIndexMap, + containerRef, + copiedCellPatch, + currentSelectionRef, + displayColumnNames, + displayDataRef, + effectiveEditLocator, + isCellValueEqualForDiff, + isDraggingRef, + isTableSurfaceActive, + isWritableResultColumn, + makeCellKey, + modifiedRows, + pendingCellSelectionStartRef, + requestAnimationFrame, + rowIndexMapRef, + rowKeyStr, + selectedCells, + selectedRowKeysRef, + selectionStartRef, + setAddedRows, + setCellContextMenu, + setCellEditMode, + setCopiedCellPatch, + setModifiedRows, + setSelectedCells, + splitCellKey, + suppressCellSelectionClickRef, + translateDataGrid, + updateCellSelection, + }); const displayData = useMemo(() => { return [...data, ...addedRows]; @@ -4222,213 +1536,26 @@ const DataGrid: React.FC = ({ applySortInfo(next); }, [applySortInfo, sortInfo]); - // Native Drag State - const draggingRef = useRef<{ - startX: number, - startWidth: number, - key: string, - containerLeft: number - } | null>(null); - const ghostRef = useRef(null); - const resizeRafRef = useRef(null); - const latestClientXRef = useRef(null); - const isResizingRef = useRef(false); // Lock for sorting - const autoFitCanvasRef = useRef(null); - - const flushGhostPosition = useCallback(() => { - resizeRafRef.current = null; - if (!draggingRef.current || !ghostRef.current) return; - if (latestClientXRef.current === null) return; - const relativeLeft = latestClientXRef.current - draggingRef.current.containerLeft; - ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; - }, []); - - // 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 = resolveDataTableColumnWidth({ - manualWidth: columnWidths[key], - density: dataTableDensity, - }); - - const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0; - - draggingRef.current = { startX, startWidth: currentWidth, key, containerLeft }; - latestClientXRef.current = startX; - - - - // Show Ghost Line at initial position - - if (ghostRef.current && containerRef.current) { - const relativeLeft = startX - containerLeft; - ghostRef.current.style.transform = `translateX(${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, dataTableDensity]); - - const measureTextWidth = useCallback((text: string, font: string) => { - if (typeof document === 'undefined') { - return text.length * 8; - } - if (!autoFitCanvasRef.current) { - autoFitCanvasRef.current = document.createElement('canvas'); - } - const context = autoFitCanvasRef.current.getContext('2d'); - if (!context) { - return text.length * 8; - } - context.font = font; - return context.measureText(text).width; - }, []); - - const buildAutoFitMeasurer = useCallback((element: HTMLElement | null, fallbackFont: string) => { - let font = fallbackFont; - if (typeof window !== 'undefined' && element) { - const computed = window.getComputedStyle(element); - const weight = computed.fontWeight || '400'; - const size = computed.fontSize || '13px'; - const family = computed.fontFamily || DEFAULT_GRID_MONO_FONT_FAMILY; - font = `${weight} ${size} ${family}`; - } - return (text: string) => measureTextWidth(text, font); - }, [measureTextWidth]); - - const autoFitDoneRef = useRef(''); - useEffect(() => { - if (displayColumnNames.length === 0 || displayData.length === 0) return; - const sig = displayColumnNames.join(','); - if (autoFitDoneRef.current === sig) return; - const font = `${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`; - const newWidths: Record = {}; - displayColumnNames.forEach((key) => { - const autoWidth = calculateAutoFitColumnWidth({ - headerTexts: [key], - valueTexts: displayData.slice(0, 200).map((row) => row?.[key]), - measureHeaderText: (t) => measureTextWidth(t, `600 ${font}`), - measureCellText: (t) => measureTextWidth(t, `400 ${font}`), - minWidth: 40, - maxWidth: 600, - defaultWidth: densityParams.defaultColumnWidth, - }); - newWidths[key] = autoWidth; - }); - autoFitDoneRef.current = sig; - setColumnWidths((prev) => ({ ...newWidths, ...prev })); - }, [displayColumnNames, displayData, densityParams, measureTextWidth]); - - const autoFitColumnWidth = useCallback((key: string, headerEl?: HTMLElement | null) => { - const normalizedKey = String(key || '').trim(); - if (!normalizedKey) return; - const sampleCell = Array.from( - containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || [] - ).find((node) => (node as HTMLElement).getAttribute('data-col-name') === normalizedKey) as HTMLElement | undefined; - - const meta = columnMetaMap[normalizedKey] || columnMetaMapByLowerName[normalizedKey.toLowerCase()]; - const headerTexts = [normalizedKey]; - if (showColumnType && meta?.type) headerTexts.push(meta.type); - if (showColumnComment && meta?.comment) headerTexts.push(meta.comment); - - const defaultWidth = resolveDataTableColumnWidth({ - manualWidth: columnWidths[normalizedKey], - density: dataTableDensity, - }); - const containerWidth = containerRef.current?.clientWidth ?? 0; - const nextWidth = calculateAutoFitColumnWidth({ - headerTexts, - valueTexts: displayDataRef.current.slice(0, 200).map((row) => row?.[normalizedKey]), - measureHeaderText: buildAutoFitMeasurer(headerEl ?? null, `600 ${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`), - measureCellText: buildAutoFitMeasurer(sampleCell ?? null, `400 ${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`), - defaultWidth, - minWidth: 80, - maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)), - }); - - setColumnWidths((prev) => ({ ...prev, [normalizedKey]: nextWidth })); - }, [ - buildAutoFitMeasurer, + const { + autoFitColumnWidth, + ghostRef, + handleResizeAutoFit, + handleResizeStart, + isResizingRef, + } = useDataGridColumnResize({ columnMetaMap, columnMetaMapByLowerName, columnWidths, + containerRef, dataTableDensity, - densityParams.dataFontSize, + densityParams, + displayColumnNames, + displayData, + displayDataRef, + setColumnWidths, showColumnComment, showColumnType, - ]); - - const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const handleEl = e.currentTarget as HTMLElement | null; - const headerEl = handleEl?.closest('th') as HTMLElement | null; - autoFitColumnWidth(key, headerEl); - }, [autoFitColumnWidth]); - - // 2. Drag Move (Global) - const handleResizeMove = useCallback((e: MouseEvent) => { - if (!draggingRef.current) return; - latestClientXRef.current = e.clientX; - if (resizeRafRef.current !== null) return; - resizeRafRef.current = requestAnimationFrame(flushGhostPosition); - }, [flushGhostPosition]); - - // 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 (resizeRafRef.current !== null) { - cancelAnimationFrame(resizeRafRef.current); - resizeRafRef.current = null; - } - latestClientXRef.current = null; - 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) => { const rowKey = row?.[GONAVI_ROW_KEY]; @@ -5755,802 +2882,115 @@ const DataGrid: React.FC = ({ copyToClipboard(text); }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, mergedDisplayData, translateDataGrid]); - const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeaderContextMenuActionKey) => { - const columnName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); - if (!columnName) { - void message.info(translateDataGrid('data_grid.message.no_field_name')); - setCellContextMenu(prev => ({ ...prev, visible: false })); - return; - } - - switch (action) { - case 'copy-field-name': - copyToClipboard(columnName); - break; - case 'copy-column-data': - handleCopyColumnData(columnName); - break; - case 'sort-asc': - applyColumnSort(columnName, 'ascend'); - break; - case 'sort-desc': - applyColumnSort(columnName, 'descend'); - break; - case 'clear-sort': - applyColumnSort(columnName, null); - break; - case 'auto-fit-column': - autoFitColumnWidth(columnName); - break; - case 'hide-column': - if (displayColumnNames.length <= 1) { - void message.info(translateDataGrid('data_grid.message.keep_one_visible_column')); - break; - } - toggleColumnVisibility(columnName, false); - break; - case 'show-column-type': - setQueryOptions({ showColumnType: true }); - break; - case 'hide-column-type': - setQueryOptions({ showColumnType: false }); - break; - case 'show-column-comment': - setQueryOptions({ showColumnComment: true }); - break; - case 'hide-column-comment': - setQueryOptions({ showColumnComment: false }); - break; - default: - break; - } - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [ - applyColumnSort, - autoFitColumnWidth, - cellContextMenu.dataIndex, - cellContextMenu.title, - copyToClipboard, - displayColumnNames.length, - handleCopyColumnData, - setQueryOptions, - translateDataGrid, - toggleColumnVisibility, - ]); - - const getClipboardRows = useCallback(() => ( - pickRowsForClipboard({ - rows: mergedDisplayData as Array>, - selectedRowKeys, - columnNames: displayOutputColumnNames, - rowKeyField: GONAVI_ROW_KEY, - rowKeyToString: rowKeyStr, - }) - ), [mergedDisplayData, selectedRowKeys, displayOutputColumnNames, rowKeyStr]); - - const getClipboardColumnNames = useCallback((rows: Array>) => { - if (rows.length === 0) return []; - return displayOutputColumnNames; - }, [displayOutputColumnNames]); - - const handleCopyQueryResultCsv = useCallback(() => { - const rows = getClipboardRows(); - const columns = getClipboardColumnNames(rows); - const text = buildClipboardCsv(rows, columns); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardColumnNames, getClipboardRows, translateDataGrid]); - - const handleCopyQueryResultJson = useCallback(() => { - const rows = getClipboardRows(); - const text = buildClipboardJson(rows); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardRows, translateDataGrid]); - - const handleCopyQueryResultMarkdown = useCallback(() => { - const rows = getClipboardRows(); - const columns = getClipboardColumnNames(rows); - const text = buildClipboardMarkdown(rows, columns); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardColumnNames, getClipboardRows, translateDataGrid]); - - const handleCopyDdl = useCallback(() => { - if (!ddlText.trim()) { - void message.info(translateDataGrid('data_grid.message.no_ddl_to_copy')); - return; - } - navigator.clipboard.writeText(ddlText) - .then(() => message.success(translateDataGrid('data_grid.message.ddl_copied'))) - .catch(() => message.error(translateDataGrid('data_grid.message.ddl_copy_failed'))); - }, [ddlText, translateDataGrid]); - - const handleCopySelectedCellsToClipboard = useCallback(() => { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) { - void message.info(translateDataGrid('data_grid.message.drag_select_cells_to_copy')); - return; - } - - const parsed = Array.from(activeSelection) - .map((cellKey) => splitCellKey(cellKey)) - .filter((item): item is { rowKey: string; colName: string } => !!item); - if (parsed.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_cells')); - return; - } - - const text = buildSelectedCellClipboardText({ - selectedCells: parsed, - rows: mergedDisplayData as Array>, - columnOrder: displayColumnNames, - rowKeyField: GONAVI_ROW_KEY, - }); - if (!text) { - void message.info(translateDataGrid('data_grid.message.selection_no_copyable_content')); - return; - } - - copyToClipboard(text); - }, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard, translateDataGrid]); - - useEffect(() => { - if (!cellEditMode) return; - - const onKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement | null; - const tagName = String(activeElement?.tagName || '').toLowerCase(); - if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) { - return; - } - - if (event.key === 'Escape') { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - event.preventDefault(); - if (activeSelection.size === 0) { - closeCellEditMode(); - return; - } - resetCellSelection(); - return; - } - - const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c'; - if (!isCopy) return; - - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) return; - - event.preventDefault(); - handleCopySelectedCellsToClipboard(); - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]); - - useEffect(() => { - if (!cellEditMode) return; - - const onPointerDown = (event: MouseEvent) => { - const root = rootRef.current; - const target = event.target instanceof Node ? event.target : null; - if (!root || !target || root.contains(target)) return; - if (target instanceof HTMLElement - && target.closest('.ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover')) { - return; - } - closeCellEditMode(); - }; - - document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); - }, [cellEditMode, closeCellEditMode]); - - const getTargets = useCallback((clickedRecord: any) => { - const selKeys = selectedRowKeysRef.current; - const currentData = displayDataRef.current; - const clickedKey = clickedRecord?.[GONAVI_ROW_KEY]; - if (clickedKey !== undefined && selKeys.includes(clickedKey)) { - return currentData.filter(d => selKeys.includes(d?.[GONAVI_ROW_KEY])); - } - return [clickedRecord]; - }, []); - - const getContextMenuTargetRows = useCallback((clickedRecord: any) => { - if (!clickedRecord) return []; - const selKeys = selectedRowKeysRef.current; - const clickedKey = clickedRecord?.[GONAVI_ROW_KEY]; - const clickedKeyStr = clickedKey === undefined || clickedKey === null ? '' : rowKeyStr(clickedKey); - const selectedKeyStrSet = new Set(selKeys.map(rowKeyStr)); - if (clickedKeyStr && selectedKeyStrSet.has(clickedKeyStr)) { - return mergedDisplayData.filter((row) => { - const rowKey = row?.[GONAVI_ROW_KEY]; - return rowKey !== undefined && rowKey !== null && selectedKeyStrSet.has(rowKeyStr(rowKey)); - }); - } - return [clickedRecord]; - }, [mergedDisplayData, rowKeyStr]); - - const translateCopySqlError = useCallback((error: CopySqlError): string => { - if (typeof error === 'string') { - return error; - } - switch (error.key) { - case 'data_grid.copy_sql.error.missing_table_name': - return translateDataGrid('data_grid.copy_sql.error.missing_table_name', error.params); - case 'data_grid.copy_sql.error.no_copyable_fields': - return translateDataGrid('data_grid.copy_sql.error.no_copyable_fields'); - case 'data_grid.copy_sql.error.missing_safe_where': - default: - return translateDataGrid('data_grid.copy_sql.error.missing_safe_where'); - } - }, [translateDataGrid]); - - const buildCopySqlBatchText = useCallback((mode: 'insert' | 'update' | 'delete', record: any): string | null => { - if (!supportsCopyInsert) { - void message.warning(translateDataGrid('data_grid.message.copy_sql_not_supported')); - return null; - } - const records = getTargets(record); - const orderedCols = displayOutputColumnNames; - if (mode === 'insert') { - return records.map((row: any) => buildCopyInsertSQL({ - dbType, - tableName, - orderedCols, - record: row, - columnTypesByLowerName: columnTypeMapByLowerName, - })).join('\n\n'); - } - - const sqlResults = records.map((row: any) => ( - mode === 'update' - ? buildCopyUpdateSQL({ - dbType, - tableName, - orderedCols, - record: row, - pkColumns, - uniqueKeyGroups, - allTableColumns: allTableColumnNames, - columnTypesByLowerName: columnTypeMapByLowerName, - }) - : buildCopyDeleteSQL({ - dbType, - tableName, - orderedCols, - record: row, - pkColumns, - uniqueKeyGroups, - allTableColumns: allTableColumnNames, - columnTypesByLowerName: columnTypeMapByLowerName, - }) - )); - const failedResult = sqlResults.find((result) => result.ok === false); - if (failedResult && failedResult.ok === false) { - void message.warning(translateCopySqlError(failedResult.error)); - return null; - } - const sqlTexts: string[] = []; - sqlResults.forEach((result) => { - if (result.ok) { - sqlTexts.push(result.sql); - } - }); - return sqlTexts.join('\n\n'); - }, [ - supportsCopyInsert, - getTargets, - displayOutputColumnNames, - dbType, - tableName, - columnTypeMapByLowerName, - pkColumns, - uniqueKeyGroups, - allTableColumnNames, - translateCopySqlError, - translateDataGrid, - ]); - - const handleCopyInsert = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('insert', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyUpdate = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('update', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyDelete = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('delete', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyJson = useCallback((record: any) => { - const records = getTargets(record); - const cleanRecords = pickDataGridOutputRows(records, displayOutputColumnNames); - copyToClipboard(JSON.stringify(cleanRecords, null, 2)); - }, [getTargets, displayOutputColumnNames, copyToClipboard]); - - const handleCopyCsv = useCallback((record: any) => { - const records = getTargets(record); - const orderedCols = displayOutputColumnNames; - const header = orderedCols.map(c => `"${c}"`).join(','); - const lines = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - // CSV 标准:值中的双引号转义为两个双引号 - const escaped = String(v).replace(/"/g, '""'); - return `"${escaped}"`; - }); - return values.join(','); - }); - copyToClipboard([header, ...lines].join('\n')); - }, [getTargets, displayOutputColumnNames, copyToClipboard]); - - const handleCopyRowData = useCallback((record: any) => { - const rows = getContextMenuTargetRows(record); - const columns = displayOutputColumnNames; - const text = buildClipboardTsv( - rows, - columns, - (columnName) => (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, - currentConnConfig, - ); - if (!text) { - void message.info(translateDataGrid('data_grid.message.current_row_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, getContextMenuTargetRows, translateDataGrid]); - - const buildConnConfig = useCallback(() => { - if (!connectionId) return null; - const conn = connections.find(c => c.id === connectionId); - if (!conn) return null; - return { - ...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: "" } - }; - }, [connections, connectionId]); - - const exportByQuery = useCallback(async (sql: string, defaultName: string, options: DataExportFileOptions, totalRows?: number) => { - const config = buildConnConfig(); - if (!config) return; - const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0; - await runExportWithProgress({ - title: `导出 ${defaultName || '查询结果'}`, - targetName: defaultName || 'export', - format: options.format, - totalRows: totalRowsKnown ? Number(totalRows) : undefined, - run: (jobId) => ExportQueryWithOptions( - buildRpcConnectionConfig(config) as any, - dbName || '', - sql, - defaultName || 'export', - { - ...options, - jobId, - totalRowsHint: totalRowsKnown ? Number(totalRows) : 0, - totalRowsKnown, - } as any, - ), - }); - }, [buildConnConfig, dbName, runExportWithProgress]); - - const buildPkWhereSql = useCallback((rows: any[], dbType: string) => { - if (!tableName || pkColumns.length === 0) return ''; - const targets = (rows || []).filter(Boolean); - if (targets.length === 0) return ''; - - const clauses: string[] = []; - for (const r of targets) { - const andParts: string[] = []; - for (const pk of pkColumns) { - const col = quoteIdentPart(dbType, pk); - const v = r?.[pk]; - if (v === null || v === undefined) return ''; - andParts.push(`${col} = '${escapeLiteral(String(v))}'`); - } - if (andParts.length === pkColumns.length) { - clauses.push(`(${andParts.join(' AND ')})`); - } - } - if (clauses.length === 0) return ''; - return clauses.join(' OR '); - }, [pkColumns, tableName]); - - const buildCurrentPageSql = useCallback((dbType: string) => { - if (!tableName || !pagination) return ''; - const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); - const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); - const baseSql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: whereSQL, - }); - const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); - const normalizedType = String(dbType || '').trim().toLowerCase(); - const hasSortForBuffer = hasExplicitSort(sortInfo); - const offset = (pagination.current - 1) * pagination.pageSize; - let sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, pagination.pageSize, offset); - if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { - sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); - } - return sql; - }, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); - - const buildAllRowsSql = useCallback((dbType: string) => { - if (!tableName) return ''; - return buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - }); - }, [tableName, displayOutputColumnNames]); - - const buildFilteredAllSql = useCallback((dbType: string) => { - if (!tableName) return ''; - const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); - const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); - if (!whereSQL) return ''; - let sql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: whereSQL, - }); - sql += buildOrderBySQL(dbType, sortInfo, pkColumns); - const normalizedType = String(dbType || '').trim().toLowerCase(); - const hasSortForBuffer = hasExplicitSort(sortInfo); - if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { - sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); - } - return sql; - }, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); - - const queryResultCurrentPageRows = useMemo(() => { - if (isQueryResultExport) { - return mergedDisplayData; - } - if (!pagination) { - return mergedDisplayData; - } - const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize); - return mergedDisplayData.slice(offset, offset + pagination.pageSize); - }, [isQueryResultExport, mergedDisplayData, pagination]); - - const exportQueryResultRows = useCallback(async (options: DataExportFileOptions, scope: Exclude) => { - if (scope === 'selected') { - const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key))); - const rows = mergedDisplayData.filter((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && selectedKeySet.has(rowKeyStr(key)); - }); - if (rows.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_rows_selected')); - return; - } - await exportData(rows, options); - return; - } - if (scope === 'page') { - await exportData(queryResultCurrentPageRows, options); - return; - } - const exportAllSql = String(resultExportAllSql || '').trim(); - const fallbackAllSql = String(resultSql || '').trim(); - const backendExportSql = exportAllSql || fallbackAllSql; - if (backendExportSql && connectionId) { - const totalRows = pagination && pagination.totalKnown !== false ? Number(pagination.total) : undefined; - await exportByQuery(backendExportSql, tableName || 'query_result', options, totalRows); - return; - } - await exportData(mergedDisplayData, options); - }, [connectionId, exportByQuery, exportData, mergedDisplayData, pagination, queryResultCurrentPageRows, resultExportAllSql, resultSql, rowKeyStr, selectedRowKeys, tableName]); - - // Context Menu Export - const handleExportSelected = useCallback(async (options: DataExportFileOptions, record: any) => { - if (isQueryResultExport) { - await exportData(getContextMenuTargetRows(record), options); - return; - } - const records = getTargets(record); - if (!connectionId || !tableName) { - await exportData(records, options); - return; - } - - // 有未提交修改时,优先按界面数据导出,避免与数据库不一致。 - if (hasChanges) { - await exportData(records, options); - void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes')); - return; - } - - const config = buildConnConfig(); - if (!config) { - await exportData(records, options); - return; - } - - const dbType = resolveDataSourceType(config); - const pkWhere = buildPkWhereSql(records, dbType); - if (!pkWhere) { - await exportData(records, options); - return; - } - - const sql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: `WHERE ${pkWhere}`, - }); - await exportByQuery(sql, tableName || 'export', options, records.length); - }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames, translateDataGrid]); - - const handleV2CellContextMenuAction = useCallback((action: V2CellContextMenuActionKey) => { - const record = cellContextMenu.record; - const closeMenu = () => setCellContextMenu(prev => ({ ...prev, visible: false })); - - switch (action) { - case 'copy-field-name': - handleCopyContextMenuFieldName(); - return; - case 'copy-row-data': - if (record) handleCopyRowData(record); - closeMenu(); - return; - case 'copy-row-for-paste': - if (record) { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) { - void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); - } else { - setSelectedRowKeys([rowKey]); - copyRowsForPaste([rowKey]); - } - } - closeMenu(); - return; - case 'paste-row-as-new': - handlePasteCopiedRowsAsNew(); - closeMenu(); - return; - case 'copy-column-data': - handleCopyColumnData(cellContextMenu.dataIndex); - closeMenu(); - return; - case 'undo-cell-change': - handleUndoContextMenuCellChange(); - return; - case 'set-null': - handleCellSetNull(); - return; - case 'edit-row': - handleOpenContextMenuRowEditor(); - return; - case 'fill-selected': - if (selectedRowKeys.length > 0 && record) { - handleBatchFillToSelected(record, cellContextMenu.dataIndex); - } - closeMenu(); - return; - case 'paste-copied-columns': - if (copiedCellPatch) { - handlePasteCopiedColumnsToSelectedRows(record?.[GONAVI_ROW_KEY]); - } - closeMenu(); - return; - case 'copy-insert': - if (record) handleCopyInsert(record); - closeMenu(); - return; - case 'copy-update': - if (record) handleCopyUpdate(record); - closeMenu(); - return; - case 'copy-delete': - if (record) handleCopyDelete(record); - closeMenu(); - return; - case 'copy-json': - if (record) handleCopyJson(record); - closeMenu(); - return; - case 'copy-csv': - if (record) handleCopyCsv(record); - closeMenu(); - return; - case 'copy-markdown': - if (record) { - const records = getTargets(record); - const columns = getClipboardColumnNames(records); - copyToClipboard(buildClipboardMarkdown(records, columns)); - } - closeMenu(); - return; - case 'export-csv': - case 'export-xlsx': - case 'export-json': - case 'export-html': - if (record) { - const format = action.replace('export-', '') as DataExportDialogValues['format']; - handleExportSelected({ format }, record).catch(console.error); - } - closeMenu(); - return; - default: - closeMenu(); - } - }, [ - cellContextMenu.record, - cellContextMenu.dataIndex, - copiedCellPatch, - copyRowsForPaste, - copyToClipboard, - getClipboardColumnNames, - getTargets, - handleBatchFillToSelected, - handleCellSetNull, - handleUndoContextMenuCellChange, - handleCopyContextMenuFieldName, - handleCopyCsv, - handleCopyDelete, - handleCopyInsert, - handleCopyJson, - handleCopyColumnData, - handleCopyRowData, - handleCopyUpdate, - handleExportSelected, - handleOpenContextMenuRowEditor, - handlePasteCopiedColumnsToSelectedRows, - handlePasteCopiedRowsAsNew, - selectedRowKeys.length, - translateDataGrid, - ]); - - // Export - const handleOpenExportDialog = useCallback(async () => { - const selectedCount = selectedRowKeys.length; - const allRowsLabel = (resultExportAllSql || resultSql) - ? '全部结果(重新查询)' - : `全部结果(当前缓存 ${mergedDisplayData.length} 条)`; - const commonInitialValues: Partial = { - format: DEFAULT_DATA_EXPORT_FORMAT, - xlsxMaxRowsPerSheet: DEFAULT_XLSX_ROWS_PER_SHEET, - }; - - if (isQueryResultExport) { - const scopeOptions: DataExportScopeOption[] = [ - { - value: 'selected', - label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行', - description: '仅导出当前结果集中已勾选的行。', - disabled: selectedCount <= 0, - }, - { - value: 'page', - label: `当前页 (${queryResultCurrentPageRows.length} 条)`, - description: '直接按当前结果页缓存导出。', - }, - { - value: 'all', - label: allRowsLabel, - description: (resultExportAllSql || resultSql) - ? '后台会重新执行 SQL,避免只导出当前页或当前缓存。' - : '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。', - }, - ]; - const values = await showDataExportDialog(modal, { - title: '导出查询结果', - scopeOptions, - initialValues: { - ...commonInitialValues, - scope: (resultExportAllSql || resultSql) ? 'all' : (selectedCount > 0 ? 'selected' : 'page'), - }, - }); - if (!values) return; - await exportQueryResultRows(values, values.scope as Exclude); - return; - } - - if (!connectionId) return; - const config = buildConnConfig(); - const dbType = config ? resolveDataSourceType(config) : ''; - const currentPageSql = config && !hasChanges ? buildCurrentPageSql(dbType) : ''; - const filteredAllSql = config && supportsSqlQueryExport ? buildFilteredAllSql(dbType) : ''; - const allRowsSql = config && objectType !== 'table' ? buildAllRowsSql(dbType) : ''; - const hasKnownFilteredTotal = hasFilteredExportSql && pagination && pagination.totalKnown !== false; - const hasKnownAllTotal = !hasFilteredExportSql && pagination && pagination.totalKnown !== false; - - addTab(buildTableExportTab({ - connectionId, - dbName, - tableName: tableName || 'export', - title: `导出 ${tableName || '数据'}`, - objectType, - scopeOptions: [ - { - value: 'page', - label: `当前页 (${displayData.length} 条)`, - description: currentPageSql - ? '后台按当前分页条件重新查询后导出当前页。' - : '当前页依赖前端临时状态,建议直接使用快捷导出。', - disabled: !currentPageSql, - }, - ...(hasFilteredExportSql ? [{ - value: 'filteredAll' as const, - label: '筛选结果(全部)', - description: filteredAllSql - ? '按当前筛选条件重新查询数据库并导出全部筛选结果。' - : '当前数据源或当前状态暂不支持在工作台重放筛选导出。', - disabled: !filteredAllSql, - }] : []), - { - value: 'all', - label: '全表数据', - description: '后台重新查询整张表并导出全部数据。', - }, - ], - initialScope: hasFilteredExportSql && filteredAllSql ? 'filteredAll' : 'all', - queryByScope: { - ...(currentPageSql ? { page: currentPageSql } : {}), - ...(filteredAllSql ? { filteredAll: filteredAllSql } : {}), - ...(allRowsSql ? { all: allRowsSql } : {}), - }, - rowCountByScope: { - page: displayData.length, - ...(hasKnownFilteredTotal ? { filteredAll: Number(pagination?.total) } : {}), - ...(hasKnownAllTotal ? { all: Number(pagination?.total) } : {}), - }, - })); - }, [ - addTab, - buildAllRowsSql, - buildConnConfig, - buildCurrentPageSql, - buildFilteredAllSql, - connectionId, - dbName, - displayData.length, - exportQueryResultRows, - hasFilteredExportSql, - objectType, - isQueryResultExport, - mergedDisplayData.length, - modal, - pagination, - queryResultCurrentPageRows.length, - resultExportAllSql, - resultSql, - selectedRowKeys.length, - supportsSqlQueryExport, - tableName, - hasChanges, - ]); + const { + handleV2ColumnHeaderContextMenuAction, + buildConnConfig, + buildCopySqlBatchText, + getTargets, + handleCopyCsv, + handleCopyDdl, + handleCopyDelete, + handleCopyInsert, + handleCopyJson, + handleCopyQueryResultCsv, + handleCopyQueryResultJson, + handleCopyQueryResultMarkdown, + handleCopyRowData, + handleCopySelectedCellsToClipboard, + handleCopyUpdate, + handleExportSelected, + handleV2CellContextMenuAction, + handleOpenExportDialog, + } = useDataGridV2Actions({ + GONAVI_ROW_KEY, + addTab, + allTableColumnNames, + applyColumnSort, + autoFitColumnWidth, + buildClipboardCsv, + buildClipboardJson, + buildClipboardMarkdown, + buildClipboardTsv, + buildCopyDeleteSQL, + buildCopyInsertSQL, + buildCopyUpdateSQL, + buildDataGridSelectBaseSql, + buildEffectiveFilterConditions, + buildOrderBySQL, + buildPaginatedSelectSQL, + buildRpcConnectionConfig, + buildSelectedCellClipboardText, + buildTableExportTab, + buildWhereSQL, + cellContextMenu, + cellEditMode, + closeCellEditMode, + columnMetaMap, + columnMetaMapByLowerName, + columnTypeMapByLowerName, + connectionId, + connections, + copiedCellPatch, + copyRowsForPaste, + copyToClipboard, + currentConnConfig, + currentSelectionRef, + dbName, + dbType, + ddlText, + displayColumnNames, + displayData, + displayDataRef, + displayOutputColumnNames, + escapeLiteral, + exportData, + filterConditions, + handleBatchFillToSelected, + handleCellSetNull, + handleCopyColumnData, + handleCopyContextMenuFieldName, + handleOpenContextMenuRowEditor, + handlePasteCopiedColumnsToSelectedRows, + handlePasteCopiedRowsAsNew, + handleUndoContextMenuCellChange, + hasChanges, + hasExplicitSort, + hasFilteredExportSql, + isQueryResultExport, + mergedDisplayData, + modal, + navigator, + objectType, + pagination, + pickDataGridOutputRows, + pickRowsForClipboard, + pkColumns, + quickWhereCondition, + quoteIdentPart, + resetCellSelection, + resolveContextMenuFieldName, + resolveDataSourceType, + resultExportAllSql, + resultSql, + rootRef, + rowKeyStr, + runExportWithProgress, + selectedCells, + selectedRowKeys, + selectedRowKeysRef, + setCellContextMenu, + setQueryOptions, + setSelectedRowKeys, + sortInfo, + splitCellKey, + supportsCopyInsert, + supportsSqlQueryExport, + tableName, + toggleColumnVisibility, + translateDataGrid, + uniqueKeyGroups, + withSortBufferTuningSQL, + }); const handleImport = async () => { if (!connectionId || !tableName) return; @@ -7613,799 +4053,308 @@ const DataGrid: React.FC = ({ fontWeight: 500, boxShadow: darkMode ? '0 2px 8px rgba(16,185,129,0.1)' : '0 2px 6px rgba(16,185,129,0.05)', }; - const renderDataTableView = () => ( -
-
- - - - - -
- - - - - - -
-
-
-
- ); - const pageFindContent = ( - } - pageFindText={pageFindText} - normalizedPageFindText={normalizedPageFindText} - hasMatches={pageFindMatches.length > 0} - activePageFindPosition={activePageFindPosition} - matchCount={pageFindMatches.length} - occurrenceCount={pageFindSummary.occurrenceCount} - matchedCellCount={pageFindSummary.matchedCellCount} - onPageFindTextChange={setPageFindText} - onCancel={() => setPageFindText('')} - onNavigatePrevious={() => handleNavigatePageFind('previous')} - onNavigateNext={() => handleNavigatePageFind('next')} - translate={translateDataGrid} - /> - ); - const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null; - const columnQuickFindContent = isTableSurfaceActive ? ( - } - value={columnQuickFindText} - options={columnQuickFindOptions} - hasTarget={!!resolveColumnQuickFindTarget(columnQuickFindText)} - translate={translateDataGrid} - onChange={setColumnQuickFindText} - onSubmit={handleSubmitColumnQuickFind} - /> - ) : null; - const resultViewSwitcher = ( - - ); - const paginationContent = ( - - ); - - const rowEditorFields = useMemo(() => ( - displayColumnNames.map((col) => { - const sample = rowEditorDisplayRef.current?.[col] ?? ''; - const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; - const isJson = looksLikeJsonText(sample); - const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; - const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const pickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); - const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); - const isWritable = isWritableResultColumn(col, effectiveEditLocator); - return { - columnName: col, - sample, - placeholder, - isJson, - useTextArea, - pickerType, - isTemporalValue, - isWritable, - }; - }) - ), [columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, displayColumnNames, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); - - const handleRefreshGrid = useCallback(() => { - clearAutoCommitTimer(); - autoCommitFailedTokenRef.current = -1; - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - setSelectedRowKeys([]); - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (connectionId && normalizedTableName) { - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - delete columnMetaCacheRef.current[cacheKey]; - delete foreignKeyCacheRef.current[cacheKey]; - delete uniqueKeyGroupsCacheRef.current[cacheKey]; - setMetadataReloadVersion((value) => value + 1); - } - if (onReload) onReload(); - }, [clearAutoCommitTimer, connectionId, dbName, onReload, tableName]); - - const handleResetPendingChanges = useCallback(() => { - clearAutoCommitTimer(); - autoCommitFailedTokenRef.current = -1; - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - }, [clearAutoCommitTimer]); - - const handleToggleFilterWithDefault = useCallback(() => { - if (!onToggleFilter) return; - onToggleFilter(); - if (filterConditions.length === 0 && !showFilter) addFilter(); - }, [onToggleFilter, filterConditions.length, showFilter]); - - const handleToggleCellEditMode = useCallback(() => { - const next = !cellEditMode; - if (!next) { - closeCellEditMode(); - } else { - cellEditModeRef.current = true; - setCellEditMode(true); - resetCellSelection(); - } - void message.info(next - ? translateDataGrid('data_grid.message.cell_edit_mode_entered') - : translateDataGrid('data_grid.message.cell_edit_mode_exited')).then(); - }, [cellEditMode, closeCellEditMode, resetCellSelection, translateDataGrid]); - - const handleRequestAiInsight = useCallback(() => { - const sampleData = mergedDisplayData.slice(0, 10); - const prompt = translateDataGrid('data_grid.ai_insight.prompt', { - count: sampleData.length, - json: JSON.stringify(sampleData, null, 2), - }); - const store = useStore.getState(); - const wasClosed = !store.aiPanelVisible; - if (wasClosed) store.setAIPanelVisible(true); - setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); - }, wasClosed ? 350 : 0); - }, [mergedDisplayData, translateDataGrid]); - - const handleToggleTotalCount = useCallback(() => { - if (!onRequestTotalCount) return; - if (pagination?.totalCountLoading) { - if (onCancelTotalCount) onCancelTotalCount(); - return; - } - onRequestTotalCount(); - }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); - - return ( -
- } - filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} - filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} - queryResultCopyMenu={queryResultCopyMenu} - dbType={dbType} - onResetPendingChanges={handleResetPendingChanges} - onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} - onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} - onRefresh={handleRefreshGrid} - onToggleFilterClick={handleToggleFilterWithDefault} - onAddRow={handleAddRow} - onUndoDeleteSelected={handleUndoDeleteSelected} - onDeleteSelected={handleDeleteSelected} - onToggleCellEditMode={handleToggleCellEditMode} - onCopySelectedCellsToClipboard={handleCopySelectedCellsToClipboard} - onCopySelectedColumnsFromRow={handleCopySelectedColumnsFromRow} - onOpenBatchEditModal={openBatchEditModal} - onPasteCopiedColumnsToSelectedRows={() => handlePasteCopiedColumnsToSelectedRows()} - onCommit={handleCommit} - onPreviewChanges={handlePreviewChanges} - onImport={handleImport} - onOpenExportModal={handleOpenExportDialog} - onCopyQueryResultCsv={handleCopyQueryResultCsv} - onRequestAiInsight={handleRequestAiInsight} - onToggleTotalCount={handleToggleTotalCount} - onQuickWhereDraftChange={setQuickWhereDraft} - onQuickWhereSuggestionsOpenChange={setQuickWhereSuggestionsOpen} - onQuickWhereKeyDown={(event) => { - const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); - if (isClipboardShortcut) { - event.stopPropagation(); - return; - } - if (!shouldApplyQuickWhereOnEnter({ - key: event.key, - shiftKey: event.shiftKey, - isComposing: Boolean((event.nativeEvent as any)?.isComposing), - suggestionsOpen: quickWhereSuggestionsOpen, - suggestionCount: quickWhereSuggestionOptions.length, - activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), - })) { - return; - } - event.preventDefault(); - applyQuickWhereCondition(); - }} - onQuickWhereSelect={(value, option) => { - setQuickWhereDraft(resolveWhereConditionSelectedValue({ - selectedValue: value, - currentInput: quickWhereDraft, - insertText: (option as any)?.insertText, - })); - }} - onQuickWhereCopy={stopQuickWhereClipboardPropagation} - onQuickWhereCut={stopQuickWhereClipboardPropagation} - onQuickWherePaste={handleQuickWherePaste} - onApplyQuickWhere={() => applyQuickWhereCondition()} - onClearQuickWhere={clearQuickWhereCondition} - updateFilter={updateFilter} - removeFilter={removeFilter} - addFilter={addFilter} - isListOp={isListOp} - isBetweenOp={isBetweenOp} - isNoValueOp={isNoValueOp} - enableSortControls={!!onSort} - onApplySortInfo={applySortInfo} - onApplyFilters={applyFilters} - onEnableAllFilters={applyAllFiltersEnabled} - onDisableAllFilters={applyAllFiltersDisabled} - onClearFiltersAndSorts={clearAllFiltersAndSorts} - /> - -
- {contextHolder} - {exportProgressModal} - setDdlModalOpen(false)} - onCopyDdl={handleCopyDdl} - /> - - {viewMode === 'table' ? ( - renderDataTableView() - ) : isV2Ui && viewMode === 'fields' ? ( - canOpenObjectDesigner ? ( - - ) : ( - - ) - ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( - { - void handleOpenTableDdl({ asView: true }); - }} - onCopy={handleCopyDdl} - ddlSidebarWidth={ddlSidebarWidth} - ddlSidebarResizePreviewX={ddlSidebarResizePreviewX} - onResizeStart={handleDdlSidebarResizeStart} - /> - ) : isV2Ui && viewMode === 'ddl' ? ( - { - void handleOpenTableDdl({ asView: true }); - }} - onCopy={handleCopyDdl} - /> - ) : isV2Ui && viewMode === 'er' ? ( - - ) : viewMode === 'json' ? ( - - ) : ( - setTextRecordIndex(i => Math.max(0, i - 1))} - onNext={() => setTextRecordIndex(i => Math.min(textViewRows.length - 1, i + 1))} - onEditCurrent={openCurrentViewRowEditor} - formatTextViewValue={formatTextViewValue} - /> - )} - - { - handleDataPanelFormatJson((errorMessage) => { - void message.error(translateDataGrid('data_grid.json_editor.invalid_format', { error: errorMessage })); - }); - }} - onSave={handleDataPanelSave} - onValueChange={setDataPanelValue} - onDirtyChange={(dirty) => { - dataPanelDirtyRef.current = dirty; - }} - isDirtyComparedToOriginal={(value) => value !== dataPanelOriginalRef.current} - /> - - {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal( -
e.stopPropagation()} - > - {cellContextMenu.kind === 'column' ? (() => { - const fieldName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); - const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()]; - const activeSort = sortInfo.find((item) => item.columnKey === fieldName && item.enabled !== false); - return ( - - ); - })() : ( - - )} -
, - document.body - )} - - setCellContextMenu(prev => ({ ...prev, visible: false }))} - onCopyFieldName={handleCopyContextMenuFieldName} - onCopyRowData={() => { - if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record); - }} - onCopyRowForPaste={() => { - const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) { - void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); - return; - } - setSelectedRowKeys([rowKey]); - copyRowsForPaste([rowKey]); - }} - onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} - onUndoCellChange={handleUndoContextMenuCellChange} - onSetNull={handleCellSetNull} - onEditRow={handleOpenContextMenuRowEditor} - onFillToSelected={() => { - if (selectedRowKeys.length > 0 && cellContextMenu.record) { - handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); - } - }} - onPasteCopiedColumns={() => { - const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; - handlePasteCopiedColumnsToSelectedRows(fallbackKey); - }} - onCopyInsert={() => { - if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); - }} - onCopyUpdate={() => { - if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); - }} - onCopyDelete={() => { - if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); - }} - onCopyJson={() => { - if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); - }} - onCopyCsv={() => { - if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); - }} - onCopyMarkdown={() => { - if (cellContextMenu.record) { - const records = getTargets(cellContextMenu.record); - const lines = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - return `| ${Object.values(vals).join(' | ')} |`; - }); - copyToClipboard(lines.join('\n')); - } - }} - onExportCsv={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error); - }} - onExportXlsx={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error); - }} - onExportJson={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error); - }} - onExportHtml={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error); - }} - /> -
- - { - void handleOpenTableDdl(); - }} - translate={translateDataGrid} - /> - - - - {/* Ghost Resize Line for Columns */} -
- - {/* Preview SQL Modal */} - setPreviewModalOpen(false)} - width={800} - footer={null} - > -
- {previewSqlData.deletes.length > 0 && ( -
-
- DELETE ({previewSqlData.deletes.length}) -
- {previewSqlData.deletes.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.updates.length > 0 && ( -
-
- UPDATE ({previewSqlData.updates.length}) -
- {previewSqlData.updates.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.inserts.length > 0 && ( -
-
- INSERT ({previewSqlData.inserts.length}) -
- {previewSqlData.inserts.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && ( -
- {translateDataGrid('data_grid.preview_sql.no_changes')} -
- )} -
-
- {translateDataGrid('data_grid.preview_sql.summary', { - deletes: previewSqlData.deletes.length, - updates: previewSqlData.updates.length, - inserts: previewSqlData.inserts.length - })} -
-
- - {/* Import Preview Modal */} - { - setImportPreviewVisible(false); - setImportFilePath(''); - }} - onSuccess={handleImportSuccess} - /> -
+ return ( + ); }; diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx new file mode 100644 index 0000000..047a3fa --- /dev/null +++ b/frontend/src/components/DataGridCore.tsx @@ -0,0 +1,1700 @@ +import Modal from './common/ResizableDraggableModal'; +// cspell:ignore anticon sqls uuidv uuidv4 hscroll +import React, { useState, useEffect, useRef, useContext, useMemo, useCallback, useDeferredValue } from 'react'; +import { createPortal } from 'react-dom'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import dayjs from 'dayjs'; +import type { SortOrder, ColumnType } from 'antd/es/table/interface'; +import type { Reference as TableReference } from 'rc-table'; +import { CloseOutlined, ConsoleSqlOutlined, CopyOutlined, EditOutlined, ExportOutlined, FileTextOutlined, LeftOutlined, RightOutlined, SearchOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; +import { + DndContext, + DragEndEvent, + PointerSensor, + useSensor, + useSensors, + closestCenter +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + horizontalListSortingStrategy, + arrayMove +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; +import ImportPreviewModal from './ImportPreviewModal'; +import { useStore } from '../store'; +import { getCurrentLanguage, t } from '../i18n'; +import { useOptionalI18n } from '../i18n/provider'; +import type { ColumnDefinition, ForeignKeyDefinition, IndexDefinition } from '../types'; +import { v4 as generateUuid } from 'uuid'; +import 'react-resizable/css/styles.css'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; +import { + getDensityParams, + resolveDataTableColumnWidth, + resolveDataTableVerticalBorderColor, +} from '../utils/dataGridDisplay'; +import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; +import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort'; +import { + calculateExternalHorizontalScrollInnerWidth, + calculateTableBodyBottomPadding, + calculateVirtualTableScrollX, + resolveDataGridColumnQuickFindScrollLeft, + resolveDataGridHorizontalWheelDelta, +} from './dataGridLayout'; +import { + buildCopyDeleteSQL, + buildCopyInsertSQL, + buildCopyUpdateSQL, + normalizeTemporalLiteralText, + resolveUniqueKeyGroupsFromIndexes, + type CopySqlError, +} from './dataGridCopyInsert'; +import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; +import { buildSelectedCellClipboardText } from './dataGridSelectionCopy'; +import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard'; +import { + buildDataGridSelectBaseSql, + pickDataGridOutputRows, + resolveDataGridOutputColumnNames, +} from './dataGridOutput'; +import { + buildClipboardCsv, + buildClipboardJson, + buildClipboardMarkdown, + pickRowsForClipboard, +} from './dataGridClipboardExport'; +import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; +import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; +import { + TEMPORAL_FORMATS, + formatFromDayjs, + getTemporalPickerFormat, + getTemporalPickerType, + isTemporalColumnType, + parseToDayjs, + resolveTemporalEditorSaveValue, + type TemporalConnectionLike, + type TemporalPickerType, +} from './dataGridTemporal'; +import { + buildEffectiveFilterConditions, + normalizeQuickWhereCondition, + resolveWhereConditionSelectedValue, + resolveWhereConditionSuggestions, + shouldApplyQuickWhereOnEnter, + validateQuickWhereCondition, +} from '../utils/dataGridWhereFilter'; +import { + attachDataGridFindRenderVersion, + collectDataGridFindMatches, + findDataGridTextRanges, + hasDataGridFindRenderVersionChanged, + normalizeDataGridFindQuery, + resolveDataGridColumnQuickFindTarget, + resolveDataGridFindNavigationIndex, + summarizeDataGridFindMatches, + type DataGridFindMatch, + type DataGridFindNavigationDirection, +} from '../utils/dataGridFind'; +import { + filterHiddenLocatorColumns, + isWritableResultColumn, + resolveWritableColumnName, + resolveRowLocatorValues, + type EditRowLocator, + type RowLocatorMessages, +} from '../utils/rowLocator'; +import { + getColumnDefinitionComment, + getColumnDefinitionName, + getColumnDefinitionType, +} from '../utils/columnDefinition'; +import { + V2CellContextMenuView, + V2ColumnHeaderContextMenuView, + type V2CellContextMenuActionKey, + type V2ColumnHeaderContextMenuActionKey, +} from './V2TableContextMenu'; +import DataGridColumnTitle from './DataGridColumnTitle'; +import DataGridColumnInfoPopoverContent from './DataGridColumnInfoPopoverContent'; +import DataGridColumnQuickFind from './DataGridColumnQuickFind'; +import DataGridPageFind from './DataGridPageFind'; +import DataGridPaginationBar from './DataGridPaginationBar'; +import DataGridResultViewSwitcher from './DataGridResultViewSwitcher'; +import DataGridSecondaryActions from './DataGridSecondaryActions'; +import DataGridToolbarFrame from './DataGridToolbarFrame'; +import DataGridModals from './DataGridModals'; +import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; +import DataGridPreviewPanel from './DataGridPreviewPanel'; +import { + DEFAULT_DATA_EXPORT_FORMAT, + DEFAULT_XLSX_ROWS_PER_SHEET, + showDataExportDialog, + type DataExportDialogValues, + type DataExportFileOptions, + type DataExportScopeOption, +} from './DataExportDialog'; +import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; +import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; +import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; +import TableDesigner from './TableDesigner'; +import { useExportProgressDialog } from './ExportProgressModal'; +import { useDataGridFilters } from './useDataGridFilters'; +import { useDataGridDdlView } from './useDataGridDdlView'; +import { useDataGridModalEditors } from './useDataGridModalEditors'; +import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; +import { buildTableExportTab } from '../utils/tableExportTab'; +import { buildDataGridCssText } from './dataGridStyles'; + +// --- Error Boundary --- +interface DataGridErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +interface DataGridErrorBoundaryProps { + children: React.ReactNode; + i18nLanguage?: string; +} + +class DataGridErrorBoundary extends React.Component< + DataGridErrorBoundaryProps, + DataGridErrorBoundaryState +> { + constructor(props: DataGridErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('DataGrid render error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

{t('data_grid.error_boundary.title', undefined, this.props.i18nLanguage)}

+

{t('data_grid.error_boundary.description', undefined, this.props.i18nLanguage)}

+
+                        {this.state.error?.message}
+                    
+ +
+ ); + } + return this.props.children; + } +} + +// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 +export const GONAVI_ROW_KEY = '__gonavi_row_key__'; +export const GONAVI_ROW_NUMBER_COLUMN_KEY = '__gonavi_row_number__'; + +// Cell key helpers for batch selection/fill. +// Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). +const CELL_KEY_SEP = '\u0001'; +const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; +const DATE_TIME_CACHE_LIMIT = 2000; +const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const ROW_NUMBER_COLUMN_WIDTH = 58; +const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ + { value: 3000, seconds: 3 }, + { value: 5000, seconds: 5 }, + { value: 10000, seconds: 10 }, + { value: 30000, seconds: 30 }, +]; +const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); +const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); +const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; +const normalizedDateTimeCache = new Map(); +const objectCellPreviewCache = new WeakMap(); +const useDataGridI18nLanguage = () => { + const i18n = useOptionalI18n(); + return i18n?.language ?? getCurrentLanguage(); +}; +const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; +const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => { + const sepIndex = cellKey.indexOf(CELL_KEY_SEP); + if (sepIndex === -1) return null; + return { + rowKey: cellKey.slice(0, sepIndex), + colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length), + }; +}; +export const resolveContextMenuFieldName = (dataIndex: string, title?: string): string => { + const name = String(dataIndex || title || '').trim(); + return name; +}; + +const trimSimpleCache = (cache: Map, limit: number) => { + if (cache.size < limit) return; + const firstKey = cache.keys().next().value; + if (typeof firstKey === 'string') { + cache.delete(firstKey); + } +}; + +const looksLikeDateTimeText = (val: string): boolean => { + if (!val) return false; + const len = val.length; + if (len < 19 || len > 48) return false; + const charCode0 = val.charCodeAt(0); + if (charCode0 < 48 || charCode0 > 57) return false; + return ( + val[4] === '-' && + val[7] === '-' && + (val[10] === ' ' || val[10] === 'T') && + val[13] === ':' && + val[16] === ':' + ); +}; + +// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing. +// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. +// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. +const normalizeDateTimeString = (val: string) => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + const cached = normalizedDateTimeCache.get(val); + if (cached !== undefined) { + return cached; + } + + // 检查是否为无效日期时间(0000-00-00 或类似格式) + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; // 保持原样显示,不尝试转换 + } + + const match = val.match( + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + ); + const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; + trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); + normalizedDateTimeCache.set(val, normalized); + return normalized; +}; + +// --- Helper: Format Value --- +const normalizeBitHexDisplayText = (val: any, columnType?: string): string | null => { + const typeText = String(columnType || '').trim().toLowerCase(); + if (!/^varbit(?:\s*\(\s*\d+\s*\))?$/.test(typeText) + && !/^bit(?:\s+varying)?(?:\s*\(\s*\d+\s*\))?$/.test(typeText)) { + return null; + } + if (typeof val !== 'string') return null; + const raw = val.trim(); + if (!/^0x[0-9a-f]+$/i.test(raw)) return null; + try { + return BigInt(raw).toString(10); + } catch { + return null; + } +}; + +type CellDisplayConnectionLike = TemporalConnectionLike; + +const isDateOnlyColumnType = (columnType?: string): boolean => { + const normalized = String(columnType || '').trim().toLowerCase(); + if (!normalized) return false; + const base = normalized.split(/[ (]/)[0]; + return base === 'date' || base === 'newdate'; +}; + +const isOceanBaseOracleDisplayConnection = (connectionConfig?: CellDisplayConnectionLike): boolean => { + if (!connectionConfig) return false; + const type = String(connectionConfig.type || '').trim().toLowerCase(); + const driver = String(connectionConfig.driver || '').trim().toLowerCase(); + return (type === 'oceanbase' || driver === 'oceanbase') + && normalizeOceanBaseProtocol(connectionConfig.oceanBaseProtocol) === 'oracle'; +}; + +const normalizeOceanBaseOracleDateDisplayText = ( + val: string, + columnType?: string, + connectionConfig?: CellDisplayConnectionLike, +): string | null => { + if (!isDateOnlyColumnType(columnType) || !isOceanBaseOracleDisplayConnection(connectionConfig)) { + return null; + } + const trimmed = String(val || '').trim(); + if (!trimmed) return trimmed; + const match = trimmed.match( + /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?)?$/ + ); + if (!match) return null; + const [, datePart, timePart, fractionPart] = match; + if (!timePart) return datePart; + if (timePart === '00:00:00' && (!fractionPart || /^\.0+$/.test(fractionPart))) { + return datePart; + } + return null; +}; + +export const formatCellDisplayText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { + try { + if (val === null) return 'NULL'; + const bitText = normalizeBitHexDisplayText(val, columnType); + if (bitText !== null) return bitText; + if (typeof val === 'object') { + if (!Array.isArray(val) && !isPlainObject(val)) { + return String(val); + } + const cached = objectCellPreviewCache.get(val); + if (cached !== undefined) { + return cached; + } + const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length; + if (topLevelSize > 80) { + const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`; + objectCellPreviewCache.set(val, summary); + return summary; + } + try { + const nextText = JSON.stringify(val); + const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; + objectCellPreviewCache.set(val, previewText); + return previewText; + } catch { + return '[Object]'; + } + } + if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) { + return oceanBaseDateOnly.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${oceanBaseDateOnly.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : oceanBaseDateOnly; + } + const normalized = normalizeDateTimeString(val); + return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; + } + return String(val); + } catch (e) { + console.error('formatCellValue error:', e); + return '[Error]'; + } +}; + +const formatClipboardCellText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { + try { + if (val === null || val === undefined) return 'NULL'; + const bitText = normalizeBitHexDisplayText(val, columnType); + if (bitText !== null) return bitText; + if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; + return normalizeDateTimeString(val); + } + if (typeof val === 'object') { + try { + return JSON.stringify(val); + } catch { + return String(val); + } + } + return String(val); + } catch (e) { + console.error('formatClipboardCellText error:', e); + return '[Error]'; + } +}; + +const normalizeClipboardTsvCell = (text: string): string => text.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); + +const buildClipboardTsv = ( + rows: Array>, + columnNames: string[], + getColumnType?: (columnName: string) => string | undefined, + connectionConfig?: CellDisplayConnectionLike, +): string => { + if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { + return ''; + } + const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); + const lines = rows.map((row) => ( + columnNames + .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName), connectionConfig))) + .join('\t') + )); + return [header, ...lines].join('\n'); +}; + +const renderHighlightedCellText = (text: string, query: string): React.ReactNode => { + const ranges = findDataGridTextRanges(text, query); + if (ranges.length === 0) return text; + + const nodes: React.ReactNode[] = []; + let cursor = 0; + ranges.forEach((range, index) => { + if (range.start > cursor) { + nodes.push(text.slice(cursor, range.start)); + } + nodes.push( + + {text.slice(range.start, range.end)} + , + ); + cursor = range.end; + }); + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + return <>{nodes}; +}; + +const renderCellDisplayValue = (val: any, query: string, columnType?: string, connectionConfig?: CellDisplayConnectionLike): React.ReactNode => { + const text = formatCellDisplayText(val, columnType, connectionConfig); + const content = renderHighlightedCellText(text, query); + if (val === null) return {content}; + return content; +}; + +const formatCellValue = (val: any) => renderCellDisplayValue(val, ''); + +export const attachDataGridVirtualEditRenderVersion = ( + rows: T[], + editingCell: VirtualEditingCellState | null, +): T[] => { + if (!editingCell) return rows; + + return rows.map((row) => { + const rowKey = row?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || String(rowKey) !== editingCell.rowKey) { + return row; + } + const nextRow = { ...(row as object) } as T; + Object.defineProperty(nextRow, DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, { + value: `${editingCell.rowKey}${CELL_KEY_SEP}${editingCell.dataIndex}`, + enumerable: true, + }); + return nextRow; + }); +}; + +export const attachDataGridDisplayRenderVersion = ( + rows: T[], + renderVersion: string, +): T[] => { + if (!renderVersion) return rows; + + return rows.map((row) => { + if (!row || typeof row !== 'object') return row; + const nextRow = { ...(row as object) } as T; + Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, { + value: renderVersion, + enumerable: true, + }); + return nextRow; + }); +}; + +export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { + const nextVersion = nextRecord && typeof nextRecord === 'object' + ? (nextRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] + : undefined; + return nextVersion !== previousVersion; +}; + +export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { + const nextVersion = nextRecord && typeof nextRecord === 'object' + ? (nextRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + return nextVersion !== previousVersion; +}; + +const toEditableText = (val: any): string => { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return val; + try { + return JSON.stringify(val, null, 2); + } catch { + return String(val); + } +}; + +const toFormText = (val: any): string => { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return normalizeDateTimeString(val); + return toEditableText(val); +}; + +// 用于变更比较:NULL 与 undefined 视为同类空值;与空字符串严格区分。 +const isCellValueEqualForDiff = (left: any, right: any): boolean => { + if (left === right) return true; + const leftNullish = left === null || left === undefined; + const rightNullish = right === null || right === undefined; + if (leftNullish || rightNullish) return leftNullish && rightNullish; + return toFormText(left) === toFormText(right); +}; + +// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。 +const isCellValueEqualForRender = (left: any, right: any): boolean => { + if (left === right) return true; + const leftNullish = left === null || left === undefined; + const rightNullish = right === null || right === undefined; + if (leftNullish || rightNullish) return leftNullish && rightNullish; + + const leftType = typeof left; + const rightType = typeof right; + if (leftType === 'object' || rightType === 'object') { + // 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。 + return false; + } + + if (leftType === 'string' || rightType === 'string') { + return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right)); + } + return left === right; +}; + +const INLINE_EDIT_MAX_CHARS = 2000; + +const shouldOpenModalEditor = (val: any): boolean => { + if (val === null || val === undefined) return false; + if (typeof val === 'string') { + if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true; + const trimmed = val.trimStart(); + return trimmed.startsWith('{') || trimmed.startsWith('['); + } + return typeof val === 'object'; +}; + +const getCellFieldName = (record: Item, dataIndex: string) => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) return dataIndex; + return [String(rowKey), dataIndex]; +}; + +const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => { + if (!form) return; + if (Array.isArray(fieldName)) { + const [rowKey, colKey] = fieldName; + form.setFieldsValue({ [rowKey]: { [colKey]: value } }); + return; + } + form.setFieldsValue({ [fieldName]: value }); +}; + +const looksLikeJsonText = (text: string): boolean => { + const raw = (text || '').trim(); + if (!raw) return false; + const first = raw[0]; + const last = raw[raw.length - 1]; + return (first === '{' && last === '}') || (first === '[' && last === ']'); +}; + +const isPlainObject = (value: any): value is Record => { + return Object.prototype.toString.call(value) === '[object Object]'; +}; + +const normalizeValueForJsonView = (value: any): any => { + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + const normalizedText = normalizeDateTimeString(value); + if (!looksLikeJsonText(normalizedText)) return normalizedText; + try { + return normalizeValueForJsonView(JSON.parse(normalizedText)); + } catch { + return normalizedText; + } + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeValueForJsonView(item)); + } + + if (isPlainObject(value)) { + const next: Record = {}; + Object.entries(value).forEach(([key, val]) => { + next[key] = normalizeValueForJsonView(val); + }); + return next; + } + + return value; +}; + +const isJsonViewValueEqual = (left: any, right: any): boolean => { + const leftNormalized = normalizeValueForJsonView(left); + const rightNormalized = normalizeValueForJsonView(right); + + if (leftNormalized === rightNormalized) return true; + if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized; + if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized; + + if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') { + return String(leftNormalized) === String(rightNormalized); + } + + try { + return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); + } catch { + return false; + } +}; + +const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => { + if (typeof currentValue === 'string') { + const raw = currentValue.trim(); + const parsedCurrent = looksLikeJsonText(raw); + if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) { + return JSON.stringify(editedValue); + } + } + return editedValue; +}; + +// --- Resizable Header (Native Implementation) --- +const ResizableTitle = React.forwardRef((props, ref) => { + const { onResizeStart, onResizeAutoFit, width, ...restProps } = props; + + const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; + if (width) { + nextStyle.width = width; + } + + // 注意:virtual table 模式下,rc-table 会依赖 header cell 的 width 样式来渲染选择列。 + // 若这里丢失 width,可能导致左上角“全选”checkbox 不显示。 + if (!width || typeof onResizeStart !== 'function') { + return
+ ); +}); + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// 静态 CSS 移到组件外,强制去除 th 内边距并确保指针穿透 +const sortableHeaderStaticStyles = ` + .gonavi-sortable-header-cell { + padding: 0 !important; + overflow: hidden; + } + .gonavi-sortable-header-cell[data-cursor-grabbing="true"], + .gonavi-sortable-header-cell[data-cursor-grabbing="true"] *, + .gonavi-sortable-header-cell.is-dragging, + .gonavi-sortable-header-cell.is-dragging * { + cursor: grabbing !important; + } + .sortable-header-cell-drag-handle { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: var(--gonavi-header-min-height, 40px); + padding: 0 10px; + user-select: none; + cursor: inherit; + overflow: hidden; + } +`; + +const SortableHeaderCell: React.FC = React.memo((props) => { + const { id, children, style: propStyle, className: propClassName, ...restProps } = props; + const [isPressed, setIsPressed] = useState(false); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: id || '' }); + + const style: React.CSSProperties = { + ...propStyle, + transform: CSS.Transform.toString(transform), + transition, + ...(isDragging ? { + position: 'relative', + zIndex: 9999, + opacity: 0.6, + backgroundColor: 'rgba(24, 144, 255, 0.15)', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)' + } : {}), + touchAction: 'none', + willChange: 'transform', + // 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制 + cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer', + }; + + useEffect(() => { + const handleGlobalMouseUp = () => setIsPressed(false); + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + if (!id || id === 'GONAVI_SELECTION_COLUMN') { + return {children}; + } + + return ( + { + setIsPressed(true); + if (listeners?.onPointerDown) listeners.onPointerDown(e); + }} + > + +
+
+ {children} +
+
+
+ ); +}); + +// --- Contexts --- +const EditableContext = React.createContext(null); +const CellContextMenuContext = React.createContext<{ + showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; + handleBatchFillToSelected: (record: Item, dataIndex: string) => void; +} | null>(null); +const DataContext = React.createContext<{ + selectedRowKeysRef: React.MutableRefObject; + displayDataRef: React.MutableRefObject; + handleCopyInsert: (r: any) => void; + handleCopyUpdate: (r: any) => void; + handleCopyDelete: (r: any) => void; + handleCopyJson: (r: any) => void; + handleCopyCsv: (r: any) => void; + handleExportSelected: (options: DataExportFileOptions, r: any) => Promise; + copyToClipboard: (t: string) => void; + tableName?: string; + enableRowContextMenu: boolean; + supportsCopyInsert: boolean; +} | null>(null); + +interface Item { + [key: string]: any; +} + +interface EditableCellProps { + title: React.ReactNode; + editable: boolean; + children: React.ReactNode; + dataIndex: string; + record: Item; + handleSave: (record: Item) => void; + focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; + columnType?: string; + dbType?: string; + connectionConfig?: CellDisplayConnectionLike; + inputCellPadding?: React.CSSProperties; + as?: any; + modifiedColumns?: Record>; + rowKeyStr?: (k: React.Key) => string; + deletedRowKeys?: Set; + darkMode?: boolean; + [key: string]: any; +} + +// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。 +// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存, +// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。 +let globalDeletedRowKeys: Set = new Set(); +const setGlobalDeletedRowKeys = (next: Set) => { + globalDeletedRowKeys = next; +}; + +const resolveEditableCellRowKey = ( + record: Item | undefined, + rowKeyStr?: (k: React.Key) => string, +): string | null => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || typeof rowKeyStr !== 'function') { + return null; + } + return rowKeyStr(rowKey); +}; + +const isEditableCellDeleted = ( + record: Item | undefined, + deletedRowKeys?: Set, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!deletedRowKeys?.has(rowKey) : false; +}; + +const isEditableCellModified = ( + record: Item | undefined, + dataIndex: string, + modifiedColumns?: Record>, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!modifiedColumns?.[rowKey]?.has(dataIndex) : false; +}; + +const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: EditableCellProps): boolean => { + if (prevProps.editable !== nextProps.editable) return false; + if (prevProps.dataIndex !== nextProps.dataIndex) return false; + if (prevProps.title !== nextProps.title) return false; + if (prevProps.columnType !== nextProps.columnType) return false; + if (prevProps.dbType !== nextProps.dbType) return false; + if ((prevProps.connectionConfig?.type ?? null) !== (nextProps.connectionConfig?.type ?? null)) return false; + if ((prevProps.connectionConfig?.driver ?? null) !== (nextProps.connectionConfig?.driver ?? null)) return false; + if ((prevProps.connectionConfig?.oceanBaseProtocol ?? null) !== (nextProps.connectionConfig?.oceanBaseProtocol ?? null)) return false; + if (prevProps.darkMode !== nextProps.darkMode) return false; + if (prevProps.as !== nextProps.as) return false; + if (prevProps.handleSave !== nextProps.handleSave) return false; + if (prevProps.focusCell !== nextProps.focusCell) return false; + if ((prevProps.inputCellPadding?.padding ?? null) !== (nextProps.inputCellPadding?.padding ?? null)) return false; + if (prevProps.style !== nextProps.style) return false; + + const prevRecord = prevProps.record; + const nextRecord = nextProps.record; + if (resolveEditableCellRowKey(prevRecord, prevProps.rowKeyStr) !== resolveEditableCellRowKey(nextRecord, nextProps.rowKeyStr)) { + return false; + } + if (hasDataGridFindRenderVersionChanged(nextRecord, prevRecord)) { + return false; + } + if (!isCellValueEqualForRender(prevRecord?.[prevProps.dataIndex], nextRecord?.[nextProps.dataIndex])) { + return false; + } + if (isEditableCellDeleted(prevRecord, prevProps.deletedRowKeys, prevProps.rowKeyStr) !== isEditableCellDeleted(nextRecord, nextProps.deletedRowKeys, nextProps.rowKeyStr)) { + return false; + } + if (isEditableCellModified(prevRecord, prevProps.dataIndex, prevProps.modifiedColumns, prevProps.rowKeyStr) !== isEditableCellModified(nextRecord, nextProps.dataIndex, nextProps.modifiedColumns, nextProps.rowKeyStr)) { + return false; + } + + return true; +}; + +const EditableCell: React.FC = React.memo(({ + title, + editable, + children, + dataIndex, + record, + handleSave, + focusCell, + columnType, + dbType, + connectionConfig, + inputCellPadding, + as: Component = 'td', + modifiedColumns, + rowKeyStr, + deletedRowKeys, + darkMode, + ...restProps +}) => { + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const cellRef = useRef(null); + const pickerOpenRef = useRef(false); + const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); + const form = useContext(EditableContext); + const cellContextMenuContext = useContext(CellContextMenuContext); + const i18nLanguage = useDataGridI18nLanguage(); + const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage); + + /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ + const lockTableScroll = useCallback((lock: boolean) => { + if (lock) { + // 查找虚拟滚动容器或常规滚动容器 + const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; + if (tableWrapper) { + const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); }; + tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); + scrollLockRef.current = { el: tableWrapper, handler }; + } + } else if (scrollLockRef.current) { + const { el, handler } = scrollLockRef.current; + el.removeEventListener('wheel', handler, { capture: true } as any); + scrollLockRef.current = null; + } + }, []); + + useEffect(() => { + if (editing) { + // 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值) + const raw = record[dataIndex]; + const fieldName = getCellFieldName(record, dataIndex); + if (isDateTimeField) { + const dayjsVal = parseToDayjs(raw, pickerType); + setCellFieldValue(form, fieldName, dayjsVal); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } + inputRef.current?.focus(); + } + }, [editing]); + + const toggleEdit = () => { + setEditing(!editing); + }; + + const save = async (pickerValue?: dayjs.Dayjs | null) => { + try { + if (!form || !editing) return; + const fieldName = getCellFieldName(record, dataIndex); + await form.validateFields([fieldName]); + let nextValue = form.getFieldValue(fieldName); + if (isDateTimeField) { + nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); + } + toggleEdit(); + // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 + if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { + handleSave({ ...record, [dataIndex]: nextValue }); + } + // 保存后移除焦点 + if (inputRef.current) { + inputRef.current.blur(); + } + } catch (errInfo) { + console.log('Save failed:', errInfo); + // 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态 + if (isDateTimeField && editing) setEditing(false); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + if (!cellContextMenuContext) return; + e.preventDefault(); + e.stopPropagation(); // 阻止冒泡到行级菜单 + cellContextMenuContext.showMenu(e, record, dataIndex, title); + }; + + let childNode = children; + + const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); + + const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined + ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) + : false; + + const isModified = !editing && modifiedColumns && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined + ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex) + : false; + + const modifiedStyle: React.CSSProperties | undefined = isModified + ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } + : undefined; + + if (editable) { + childNode = editing ? ( + + {isDateTimeField ? ( + pickerType === 'time' ? ( + setTimeout(() => { void save(value); }, 0)} + onOpenChange={lockTableScroll} + onBlur={() => setTimeout(() => { void save(); }, 0)} + needConfirm={false} + /> + ) : pickerType === 'datetime' ? ( + ( + { + // 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。 + // 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。 + const fieldName = getCellFieldName(record, dataIndex); + setCellFieldValue(form, fieldName, dayjs()); + }} + >{dateTimePickerNowLabel} + )} + onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} + onOpenChange={(open) => { + pickerOpenRef.current = open; + lockTableScroll(open); + // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 + if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); + }} + onBlur={() => { + // 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。 + // 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。 + setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150); + }} + needConfirm + /> + ) : ( + setTimeout(() => { void save(value); }, 0)} + onOpenChange={lockTableScroll} + onBlur={() => setTimeout(() => { void save(); }, 0)} + needConfirm={false} + /> + ) + ) : ( + { void save(); }} + onBlur={() => { void save(); }} + onFocus={(e) => { + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> + )} + + ) : ( +
+ {children} +
+ ); + } else if (cellContextMenuContext) { + // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 + childNode = ( +
+ {children} +
+ ); + } else if (isModified) { + childNode = ( +
+ {children} +
+ ); + } + + const handleDoubleClick = () => { + if (!editable) return; + if (isRowDeleted) return; + // 模块级检查:绕过 React 渲染链条,确保即使组件因 memo 缓存未重渲染也能拿到最新状态 + if (record?.[GONAVI_ROW_KEY] !== undefined + && rowKeyStr + && globalDeletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))) return; + // 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。 + if (editing) return; + const raw = record?.[dataIndex]; + if (focusCell && shouldOpenModalEditor(raw)) { + focusCell(record, dataIndex, title); + return; + } + toggleEdit(); + }; + + return ( + + {childNode} + + ); +}, areEditableCellPropsEqual); + +const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { + const context = useContext(DataContext); + + if (!record || !context) return
{children}; + + const { + selectedRowKeysRef, + displayDataRef, + handleCopyInsert, + handleCopyUpdate, + handleCopyDelete, + handleCopyJson, + handleCopyCsv, + handleExportSelected, + copyToClipboard, + enableRowContextMenu, + supportsCopyInsert, + } = context; + + if (!enableRowContextMenu) { + return {children}; + } + + const getTargets = () => { + const keys = selectedRowKeysRef.current; + const recordKey = record?.[GONAVI_ROW_KEY]; + if (recordKey !== undefined && keys.includes(recordKey)) { + return displayDataRef.current.filter(d => keys.includes(d?.[GONAVI_ROW_KEY])); + } + return [record]; + }; + + const menuItems: MenuProps['items'] = [ + ...(supportsCopyInsert ? [{ + key: 'insert', + label: t('data_grid.context_menu.copy_as_insert'), + icon: , + onClick: () => handleCopyInsert(record), + }, { + key: 'update', + label: t('data_grid.context_menu.copy_as_update'), + icon: , + onClick: () => handleCopyUpdate(record), + }, { + key: 'delete', + label: t('data_grid.context_menu.copy_as_delete'), + icon: , + onClick: () => handleCopyDelete(record), + }] : []), + { key: 'json', label: t('data_grid.context_menu.copy_as_json'), icon: , onClick: () => handleCopyJson(record) }, + { key: 'csv', label: t('data_grid.context_menu.copy_as_csv'), icon: , onClick: () => handleCopyCsv(record) }, + { key: 'copy', label: t('data_grid.context_menu.copy_as_markdown'), icon: , onClick: () => { + const records = getTargets(); + const orderedCols = displayDataRef.current.length > 0 + ? Object.keys(displayDataRef.current[0]).filter(c => c !== GONAVI_ROW_KEY) + : []; + const header = `| ${orderedCols.join(' | ')} |`; + const separator = `| ${orderedCols.map(() => '---').join(' | ')} |`; + const rows = records.map((r: any) => { + const values = orderedCols.map(c => { + const v = r[c]; + if (v === null || v === undefined) return 'NULL'; + return String(v).replace(/\|/g, '\\|').replace(/\n/g, ' '); + }); + return `| ${values.join(' | ')} |`; + }); + copyToClipboard([header, separator, ...rows].join('\n')); + } }, + { type: 'divider' }, + { + key: 'export-selected', + label: t('data_grid.context_menu.export_selected'), + icon: , + children: [ + { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) }, + { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) }, + { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) }, + { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) }, + { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) }, + ] + } + ]; + + return ( + document.body} autoAdjustOverflow> + {children} + + ); +}); + +interface DataGridProps { + data: any[]; + columnNames: string[]; + loading: boolean; + tableName?: string; + objectType?: 'table' | 'view' | 'materialized-view'; + exportScope?: 'table' | 'queryResult'; + resultSql?: string; + resultExportAllSql?: string; + dbName?: string; + connectionId?: string; + pkColumns?: string[]; + editLocator?: EditRowLocator; + readOnly?: boolean; + showRowNumberColumn?: boolean; + onReload?: () => void; + onSort?: (field: string, order: string) => void; + onPageChange?: (page: number, size: number) => void; + pagination?: { + current: number, + pageSize: number, + total: number, + totalKnown?: boolean, + totalApprox?: boolean, + approximateTotal?: number, + totalCountLoading?: boolean, + totalCountCancelled?: boolean, + }; + onRequestTotalCount?: () => void; + onCancelTotalCount?: () => void; + sortInfoExternal?: Array<{ columnKey: string, order: string, enabled?: boolean }>; + // Filtering + showFilter?: boolean; + onToggleFilter?: () => void; + exportSqlWithFilter?: string; + onApplyFilter?: (conditions: GridFilterCondition[]) => void; + appliedFilterConditions?: FilterCondition[]; + quickWhereCondition?: string; + onApplyQuickWhereCondition?: (condition: string) => void; + scrollSnapshot?: { top: number; left: number }; + onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; + toolbarExtraActions?: React.ReactNode; +} + +type GridFilterCondition = FilterCondition & { + id: number; + column: string; + op: string; + value: string; + value2?: string; +}; + +type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; +type DdlViewLayoutMode = 'bottom' | 'side'; +type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; +type VirtualEditingCellState = { + rowKey: string; + dataIndex: string; + title: React.ReactNode; + columnType?: string; +}; + +type ColumnMeta = { + type: string; + comment: string; +}; + +const buildColumnMetaMap = (columns: ColumnDefinition[]): Record => { + const nextMap: Record = {}; + (columns || []).forEach((column: any) => { + const name = getColumnDefinitionName(column); + if (!name) return; + nextMap[name] = { + type: getColumnDefinitionType(column), + comment: getColumnDefinitionComment(column), + }; + }); + return nextMap; +}; + +const hasUsableColumnMeta = (metaMap: Record): boolean => ( + Object.values(metaMap || {}).some((meta) => { + const type = String(meta?.type || '').trim(); + const comment = String(meta?.comment || '').trim(); + return type.length > 0 || comment.length > 0; + }) +); + +type ForeignKeyTarget = { + columnName: string; + refTableName: string; + refColumnName: string; + constraintName: string; +}; + +type VirtualTableScrollReference = TableReference & { + scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void; +}; + +const EXACT_GRID_FILTER_OPERATOR = '='; +const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS'; +const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = { + width: 320, + flex: '0 1 320px', + minWidth: 260, + maxWidth: 'min(460px, 100%)', +}; +const FILTER_FIELD_POPUP_WIDTH = 520; +const FILTER_FIELD_OPTION_STYLE: React.CSSProperties = { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; +const STRING_LIKE_GRID_FILTER_TYPES = new Set([ + 'bpchar', + 'char', + 'character', + 'character varying', + 'citext', + 'clob', + 'fixedstring', + 'long nvarchar', + 'long varchar', + 'longtext', + 'mediumtext', + 'nchar', + 'nclob', + 'ntext', + 'nvarchar', + 'nvarchar2', + 'string', + 'text', + 'tinytext', + 'varchar', + 'varchar2', +]); + +const normalizeGridFilterColumnType = (columnType: unknown): string => { + let normalized = String(columnType ?? '').trim().toLowerCase().replace(/\s+/g, ' '); + for (let i = 0; i < 4; i += 1) { + const wrapped = normalized.match(/^(?:nullable|lowcardinality)\((.+)\)$/); + if (!wrapped) break; + normalized = wrapped[1].trim().replace(/\s+/g, ' '); + } + return normalized; +}; + +export const isStringLikeGridFilterColumnType = (columnType: unknown): boolean => { + const normalized = normalizeGridFilterColumnType(columnType); + if (!normalized) return false; + const baseType = normalized.replace(/\(.*/, '').trim(); + return STRING_LIKE_GRID_FILTER_TYPES.has(baseType); +}; + +export const resolveDefaultGridFilterOperator = (columnType: unknown): string => ( + isStringLikeGridFilterColumnType(columnType) ? CONTAINS_GRID_FILTER_OPERATOR : EXACT_GRID_FILTER_OPERATOR +); + +export const resolveNextGridFilterOperatorForColumnChange = ({ + currentOperator, + previousColumnType, + nextColumnType, +}: { + currentOperator: unknown; + previousColumnType: unknown; + nextColumnType: unknown; +}): string => { + const current = String(currentOperator || '').trim(); + if (!current) return resolveDefaultGridFilterOperator(nextColumnType); + const previousDefault = resolveDefaultGridFilterOperator(previousColumnType); + return current === previousDefault ? resolveDefaultGridFilterOperator(nextColumnType) : current; +}; + +export const buildGridFieldSelectOptions = (columnNames: string[]) => ( + (columnNames || []).map((columnName) => { + const text = String(columnName || ''); + return { + value: text, + label: text, + title: text, + }; + }) +); + +const renderGridFieldSelectOption = (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => { + const text = String(option?.title ?? option?.label ?? option?.value ?? ''); + return ( + + {text} + + ); +}; + +type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any; + +type DataGridCommitChangeSet = { + inserts: any[]; + updates: any[]; + deletes: any[]; +}; + +export const buildDataGridCommitChangeSet = ({ + addedRows, + modifiedRows, + deletedRowKeys, + data, + editLocator, + visibleColumnNames, + rowKeyToString, + normalizeCommitCellValue, + shouldCommitColumn, + rowLocatorMessages, +}: { + addedRows: any[]; + modifiedRows: Record; + deletedRowKeys: Set; + data: any[]; + editLocator?: EditRowLocator; + visibleColumnNames: string[]; + rowKeyToString: (key: any) => string; + normalizeCommitCellValue: NormalizeCommitCellValue; + shouldCommitColumn: (columnName: string) => boolean; + rowLocatorMessages?: RowLocatorMessages; +}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => { + if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') { + return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' }; + } + + const normalizeValues = (values: Record, mode: 'insert' | 'update') => { + const normalizedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + if (!shouldCommitColumn(col)) return; + const commitColumnName = resolveWritableColumnName(col, editLocator); + if (!commitColumnName) return; + const normalizedVal = normalizeCommitCellValue(col, val, mode); + if (normalizedVal !== undefined) { + normalizedValues[commitColumnName] = normalizedVal; + } + }); + return normalizedValues; + }; + + const originalRowsByKey = new Map(); + data.forEach((row) => { + const key = row?.[GONAVI_ROW_KEY]; + if (key === undefined || key === null) return; + originalRowsByKey.set(rowKeyToString(key), row); + }); + + const inserts: any[] = []; + const updates: any[] = []; + const deletes: any[] = []; + + addedRows.forEach(row => { + const key = row?.[GONAVI_ROW_KEY]; + if (key !== undefined && key !== null && deletedRowKeys.has(rowKeyToString(key))) return; + inserts.push(normalizeValues(row, 'insert')); + }); + + for (const keyStr of deletedRowKeys) { + const originalRow = originalRowsByKey.get(keyStr); + if (!originalRow) continue; + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); + if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; + deletes.push(locatorValues.values); + } + + for (const [keyStr, newRow] of Object.entries(modifiedRows)) { + if (deletedRowKeys.has(keyStr)) continue; + const originalRow = originalRowsByKey.get(keyStr); + if (!originalRow) continue; + + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); + if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; + + const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); + let values: Record = {}; + if (!hasRowKey) { + values = { ...(newRow as any) }; + } else { + visibleColumnNames.forEach((col) => { + const nextVal = (newRow as any)?.[col]; + const prevVal = (originalRow as any)?.[col]; + if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; + }); + } + + const normalizedValues = normalizeValues(values, 'update'); + if (Object.keys(normalizedValues).length === 0) continue; + updates.push({ keys: locatorValues.values, values: normalizedValues }); + } + + return { ok: true, changes: { inserts, updates, deletes } }; +}; + +// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象 +const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' }; +const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + width: '100%', +}; +const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }; +const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 }; +const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { + margin: 0, + padding: 0, + display: 'flex', + flex: '1 1 auto', + alignItems: 'center', + width: '100%', + minWidth: 0, + minHeight: 'calc(28px * var(--gn-ui-scale, 1))', + height: 'calc(28px * var(--gn-ui-scale, 1))', + overflow: 'visible', + whiteSpace: 'nowrap', + boxSizing: 'border-box', +}; + + +export { + DataGridErrorBoundary, + CELL_KEY_SEP, + CELL_SELECTION_DRAG_THRESHOLD_PX, + DATE_TIME_CACHE_LIMIT, + TABLE_CELL_PREVIEW_MAX_CHARS, + ROW_NUMBER_COLUMN_WIDTH, + DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS, + DATA_GRID_DISPLAY_RENDER_VERSION, + DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, + DEFAULT_GRID_MONO_FONT_FAMILY, + normalizedDateTimeCache, + objectCellPreviewCache, + useDataGridI18nLanguage, + makeCellKey, + splitCellKey, + trimSimpleCache, + looksLikeDateTimeText, + normalizeDateTimeString, + normalizeBitHexDisplayText, + isDateOnlyColumnType, + isOceanBaseOracleDisplayConnection, + normalizeOceanBaseOracleDateDisplayText, + formatClipboardCellText, + normalizeClipboardTsvCell, + buildClipboardTsv, + renderHighlightedCellText, + renderCellDisplayValue, + formatCellValue, + toEditableText, + toFormText, + isCellValueEqualForDiff, + isCellValueEqualForRender, + INLINE_EDIT_MAX_CHARS, + shouldOpenModalEditor, + getCellFieldName, + setCellFieldValue, + looksLikeJsonText, + isPlainObject, + normalizeValueForJsonView, + isJsonViewValueEqual, + coerceJsonEditorValueForStorage, + ResizableTitle, + sortableHeaderStaticStyles, + SortableHeaderCell, + EditableContext, + CellContextMenuContext, + DataContext, + setGlobalDeletedRowKeys, + resolveEditableCellRowKey, + isEditableCellDeleted, + isEditableCellModified, + areEditableCellPropsEqual, + EditableCell, + ContextMenuRow, + buildColumnMetaMap, + hasUsableColumnMeta, + EXACT_GRID_FILTER_OPERATOR, + CONTAINS_GRID_FILTER_OPERATOR, + FILTER_FIELD_SELECT_STYLE, + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_OPTION_STYLE, + STRING_LIKE_GRID_FILTER_TYPES, + normalizeGridFilterColumnType, + renderGridFieldSelectOption, + CELL_ELLIPSIS_STYLE, + VIRTUAL_CELL_TEXT_STYLE, + READONLY_CELL_WRAP_STYLE, + INLINE_EDIT_FORM_ITEM_STYLE, + VIRTUAL_EDITING_CELL_STYLE, +}; + +export type { + DataGridErrorBoundaryState, + DataGridErrorBoundaryProps, + CellDisplayConnectionLike, + SortableHeaderCellProps, + Item, + EditableCellProps, + DataGridProps, + GridFilterCondition, + GridViewMode, + DdlViewLayoutMode, + DataGridExportScope, + VirtualEditingCellState, + ColumnMeta, + ForeignKeyTarget, + VirtualTableScrollReference, + NormalizeCommitCellValue, + DataGridCommitChangeSet, +}; diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx new file mode 100644 index 0000000..3f063e5 --- /dev/null +++ b/frontend/src/components/DataGridShell.tsx @@ -0,0 +1,1123 @@ +import React from 'react'; +import { Button, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { createPortal } from 'react-dom'; + +import Modal from './common/ResizableDraggableModal'; +import ImportPreviewModal from './ImportPreviewModal'; +import DataGridModals from './DataGridModals'; +import DataGridPreviewPanel from './DataGridPreviewPanel'; +import DataGridSecondaryActions from './DataGridSecondaryActions'; +import DataGridToolbarFrame from './DataGridToolbarFrame'; +import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; +import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; +import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; +import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; +import TableDesigner from './TableDesigner'; +import { V2CellContextMenuView, V2ColumnHeaderContextMenuView } from './V2TableContextMenu'; +import { + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_SELECT_STYLE, + GONAVI_ROW_KEY, +} from './DataGridCore'; + +type DataGridShellProps = Record; + +const DataGridShell: React.FC = (props) => { + const { + CellContextMenuContext, + CustomEvent, + DataContext, + DataGridColumnQuickFind, + DataGridPageFind, + DataGridPaginationBar, + DataGridResultViewSwitcher, + DndContext, + EditableContext, + Form, + JSON, + Set, + SortableContext, + Table, + activePageFindPosition, + activeShortcutPlatform, + addFilter, + aiShortcutLabel, + allSelectedAreDeleted, + applyAllFiltersDisabled, + applyAllFiltersEnabled, + applyExternalScrollToTableTargets, + applyFilters, + applyJsonEditor, + applyQuickWhereCondition, + applyRowEditor, + applySortInfo, + autoCommitFailedTokenRef, + autoCommitRemainingSeconds, + batchEditModalOpen, + batchEditSetNull, + batchEditValue, + bgContent, + bgContextMenu, + bgFilter, + canCopyQueryResult, + canExport, + canImport, + canModifyData, + canOpenObjectDesigner, + canUndoContextMenuCellChange, + canViewDdl, + cellContextMenu, + cellContextMenuPortalRef, + cellContextMenuValue, + cellEditMode, + cellEditModeRef, + cellEditorIsJson, + cellEditorMeta, + cellEditorOpen, + cellEditorValue, + clearAllFiltersAndSorts, + clearAutoCommitTimer, + clearQuickWhereCondition, + closeBatchEditModal, + closeCellEditMode, + closeCellEditor, + closeJsonEditor, + closeRowEditor, + closestCenter, + columnInfoSettingContent, + columnMetaCacheRef, + columnMetaMap, + columnMetaMapByLowerName, + columnQuickFindOptions, + columnQuickFindText, + connectionId, + containerRef, + contextHolder, + copiedCellPatch, + copiedRowsForPaste, + copyRowsForPaste, + copyToClipboard, + currentConnConfig, + currentTextRow, + darkMode, + dataContextValue, + dataEditAutoCommitDelayMs, + dataEditCommitMode, + dataPanelDirtyRef, + dataPanelIsJson, + dataPanelOpen, + dataPanelOriginalRef, + dataPanelValue, + dbName, + dbType, + ddlLoading, + ddlModalOpen, + ddlSidebarResizePreviewX, + ddlSidebarWidth, + ddlText, + ddlViewLayout, + displayColumnNames, + displayOutputColumnNames, + effectiveEditLocator, + enableVirtual, + exportProgressModal, + externalHorizontalScrollRef, + externalScrollbarMinWidth, + filterConditions, + filterLogicOptions, + filterOpOptions, + filterPanelRef, + filterTopPadding, + focusedCellInfo, + focusedCellWritable, + foreignKeyCacheRef, + form, + formatTextViewValue, + getTargets, + getTemporalPickerType, + ghostRef, + gridCssText, + gridFieldSelectOptions, + gridId, + handleAddRow, + handleBatchFillCells, + handleBatchFillToSelected, + handleCellEditorSave, + handleCellSetNull, + handleCommit, + handleCopyContextMenuFieldName, + handleCopyCsv, + handleCopyDdl, + handleCopyDelete, + handleCopyInsert, + handleCopyJson, + handleCopyQueryResultCsv, + handleCopyRowData, + handleCopySelectedCellsToClipboard, + handleCopySelectedColumnsFromRow, + handleCopyUpdate, + handleDataPanelFormatJson, + handleDataPanelSave, + handleDdlSidebarResizeStart, + handleDeleteSelected, + handleDragEnd, + handleExportSelected, + handleFormatJsonEditor, + handleFormatJsonInEditor, + handleImport, + handleImportSuccess, + handleNavigatePageFind, + handleOpenContextMenuRowEditor, + handleOpenExportDialog, + handleOpenJsonEditor, + handleOpenTableDdl, + handlePageSizeChange, + handlePasteCopiedColumnsToSelectedRows, + handlePasteCopiedRowsAsNew, + handlePreviewChanges, + handleQuickWherePaste, + handleSubmitColumnQuickFind, + handleTableChange, + handleUndoContextMenuCellChange, + handleUndoDeleteSelected, + handleV2CellContextMenuAction, + handleV2ColumnHeaderContextMenuAction, + handleV2PageStep, + handleViewModeChange, + handleVirtualTableClickCapture, + handleVirtualTableContextMenuCapture, + handleVirtualTableDoubleClickCapture, + hasChanges, + headerCellMinHeight, + horizontalListSortingStrategy, + horizontalScrollVisible, + horizontalScrollWidth, + importFilePath, + importPreviewVisible, + isBetweenOp, + isListOp, + isNoValueOp, + isQueryResultExport, + isTableSurfaceActive, + isV2Ui, + isWritableResultColumn, + jsonEditorOpen, + jsonEditorValue, + jsonViewText, + legacyAiButtonStyle, + loading, + localizedDataEditAutoCommitDelayOptions, + looksLikeJsonText, + mergedDisplayData, + noAutoCapInputProps, + normalizedPageFindText, + onCancelTotalCount, + onPageChange, + onReload, + onRequestTotalCount, + onSort, + onToggleFilter, + openBatchEditModal, + openCurrentViewRowEditor, + openRowEditorFieldEditor, + pageFindMatches, + pageFindSummary, + pageFindText, + pagination, + paginationControlTotal, + paginationHasKnownTotalPages, + paginationPageSizeOptions, + paginationPageText, + paginationSummaryText, + paginationTotalPages, + paginationV2SummaryText, + panelFrameColor, + panelOuterGap, + panelPaddingX, + panelPaddingY, + panelRadius, + pendingChangeCount, + pkColumns, + prefersManualTotalCount, + previewModalOpen, + previewSqlData, + queryResultCopyMenu, + quickWhereCondition, + quickWhereDraft, + quickWhereSuggestionOptions, + quickWhereSuggestionsOpen, + readOnly, + removeFilter, + renderGridFieldSelectOption, + resetCellSelection, + resolveColumnQuickFindTarget, + resolveContextMenuFieldName, + resolveWhereConditionSelectedValue, + rootRef, + rowClassName, + rowEditorDisplayRef, + rowEditorForm, + rowEditorNullColsRef, + rowEditorOpen, + rowEditorRowKey, + rowSelectionConfig, + selectedCells, + selectedRowKeys, + selectionAccentHex, + sensors, + setAddedRows, + setBatchEditSetNull, + setBatchEditValue, + setCellContextMenu, + setCellEditMode, + setCellEditorValue, + setColumnQuickFindText, + setDataEditTransactionOptions, + setDataPanelValue, + setDdlModalOpen, + setDdlViewLayout, + setDeletedRowKeys, + setImportFilePath, + setImportPreviewVisible, + setJsonEditorValue, + setMetadataReloadVersion, + setModifiedColumns, + setModifiedRows, + setPageFindText, + setPreviewModalOpen, + setQuickWhereDraft, + setQuickWhereSuggestionsOpen, + setSelectedRowKeys, + setTextRecordIndex, + setTimeout, + shouldApplyQuickWhereOnEnter, + showColumnComment, + showColumnType, + showFilter, + sortInfo, + stopQuickWhereClipboardPropagation, + supportsCopyInsert, + tableBodyBottomPadding, + tableColumns, + tableComponents, + tableContainerRef, + tableName, + tableOnRow, + tableRef, + tableRenderData, + tableScrollConfig, + textRecordIndex, + textViewRows, + toggleDataPanel, + toolbarBottomPadding, + toolbarDividerColor, + toolbarExtraActions, + translateDataGrid, + uniqueKeyGroupsCacheRef, + updateFilter, + useCallback, + useMemo, + useStore, + viewMode, + virtualListItemHeight, + window, + } = props; + +const renderDataTableView = () => ( +
+
+ + + + + +
; + } + + return ( + + {restProps.children} + { + e.stopPropagation(); + // Pass the header element reference implicitly via event target + onResizeStart(e); + }} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (typeof onResizeAutoFit === 'function') { + onResizeAutoFit(e); + } + }} + onPointerDown={(e) => { + // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, + // 避免调整列宽时意外触发列拖拽排序 + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + title={t('data_grid.column.resize_tooltip')} + style={{ + position: 'absolute', + right: 0, // Align to right edge + bottom: 0, + top: 0, + width: 10, + cursor: 'col-resize', + zIndex: 10, + touchAction: 'none' + }} + /> +
+ + + + + + +
+
+
+
+ ); + const pageFindContent = ( + } + pageFindText={pageFindText} + normalizedPageFindText={normalizedPageFindText} + hasMatches={pageFindMatches.length > 0} + activePageFindPosition={activePageFindPosition} + matchCount={pageFindMatches.length} + occurrenceCount={pageFindSummary.occurrenceCount} + matchedCellCount={pageFindSummary.matchedCellCount} + onPageFindTextChange={setPageFindText} + onCancel={() => setPageFindText('')} + onNavigatePrevious={() => handleNavigatePageFind('previous')} + onNavigateNext={() => handleNavigatePageFind('next')} + translate={translateDataGrid} + /> + ); + const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null; + const columnQuickFindContent = isTableSurfaceActive ? ( + } + value={columnQuickFindText} + options={columnQuickFindOptions} + hasTarget={!!resolveColumnQuickFindTarget(columnQuickFindText)} + translate={translateDataGrid} + onChange={setColumnQuickFindText} + onSubmit={handleSubmitColumnQuickFind} + /> + ) : null; + const resultViewSwitcher = ( + + ); + const paginationContent = ( + + ); + + const rowEditorFields = useMemo(() => ( + displayColumnNames.map((col: string) => { + const sample = rowEditorDisplayRef.current?.[col] ?? ''; + const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; + const isJson = looksLikeJsonText(sample); + const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const pickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); + const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); + const isWritable = isWritableResultColumn(col, effectiveEditLocator); + return { + columnName: col, + sample, + placeholder, + isJson, + useTextArea, + pickerType, + isTemporalValue, + isWritable, + }; + }) + ), [columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, displayColumnNames, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); + + const handleRefreshGrid = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + setSelectedRowKeys([]); + const normalizedTableName = String(tableName || '').trim(); + const normalizedDbName = String(dbName || '').trim(); + if (connectionId && normalizedTableName) { + const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; + delete columnMetaCacheRef.current[cacheKey]; + delete foreignKeyCacheRef.current[cacheKey]; + delete uniqueKeyGroupsCacheRef.current[cacheKey]; + setMetadataReloadVersion((value: number) => value + 1); + } + if (onReload) onReload(); + }, [clearAutoCommitTimer, connectionId, dbName, onReload, tableName]); + + const handleResetPendingChanges = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + }, [clearAutoCommitTimer]); + + const handleToggleFilterWithDefault = useCallback(() => { + if (!onToggleFilter) return; + onToggleFilter(); + if (filterConditions.length === 0 && !showFilter) addFilter(); + }, [onToggleFilter, filterConditions.length, showFilter]); + + const handleToggleCellEditMode = useCallback(() => { + const next = !cellEditMode; + if (!next) { + closeCellEditMode(); + } else { + cellEditModeRef.current = true; + setCellEditMode(true); + resetCellSelection(); + } + void message.info(next + ? translateDataGrid('data_grid.message.cell_edit_mode_entered') + : translateDataGrid('data_grid.message.cell_edit_mode_exited')).then(); + }, [cellEditMode, closeCellEditMode, resetCellSelection, translateDataGrid]); + + const handleRequestAiInsight = useCallback(() => { + const sampleData = mergedDisplayData.slice(0, 10); + const prompt = translateDataGrid('data_grid.ai_insight.prompt', { + count: sampleData.length, + json: JSON.stringify(sampleData, null, 2), + }); + const store = useStore.getState(); + const wasClosed = !store.aiPanelVisible; + if (wasClosed) store.setAIPanelVisible(true); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); + }, wasClosed ? 350 : 0); + }, [mergedDisplayData, translateDataGrid]); + + const handleToggleTotalCount = useCallback(() => { + if (!onRequestTotalCount) return; + if (pagination?.totalCountLoading) { + if (onCancelTotalCount) onCancelTotalCount(); + return; + } + onRequestTotalCount(); + }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); + + return ( +
+ } + filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} + filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} + queryResultCopyMenu={queryResultCopyMenu} + dbType={dbType} + onResetPendingChanges={handleResetPendingChanges} + onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} + onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} + onRefresh={handleRefreshGrid} + onToggleFilterClick={handleToggleFilterWithDefault} + onAddRow={handleAddRow} + onUndoDeleteSelected={handleUndoDeleteSelected} + onDeleteSelected={handleDeleteSelected} + onToggleCellEditMode={handleToggleCellEditMode} + onCopySelectedCellsToClipboard={handleCopySelectedCellsToClipboard} + onCopySelectedColumnsFromRow={handleCopySelectedColumnsFromRow} + onOpenBatchEditModal={openBatchEditModal} + onPasteCopiedColumnsToSelectedRows={() => handlePasteCopiedColumnsToSelectedRows()} + onCommit={handleCommit} + onPreviewChanges={handlePreviewChanges} + onImport={handleImport} + onOpenExportModal={handleOpenExportDialog} + onCopyQueryResultCsv={handleCopyQueryResultCsv} + onRequestAiInsight={handleRequestAiInsight} + onToggleTotalCount={handleToggleTotalCount} + onQuickWhereDraftChange={setQuickWhereDraft} + onQuickWhereSuggestionsOpenChange={setQuickWhereSuggestionsOpen} + onQuickWhereKeyDown={(event) => { + const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); + if (isClipboardShortcut) { + event.stopPropagation(); + return; + } + if (!shouldApplyQuickWhereOnEnter({ + key: event.key, + shiftKey: event.shiftKey, + isComposing: Boolean((event.nativeEvent as any)?.isComposing), + suggestionsOpen: quickWhereSuggestionsOpen, + suggestionCount: quickWhereSuggestionOptions.length, + activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), + })) { + return; + } + event.preventDefault(); + applyQuickWhereCondition(); + }} + onQuickWhereSelect={(value, option) => { + setQuickWhereDraft(resolveWhereConditionSelectedValue({ + selectedValue: value, + currentInput: quickWhereDraft, + insertText: (option as any)?.insertText, + })); + }} + onQuickWhereCopy={stopQuickWhereClipboardPropagation} + onQuickWhereCut={stopQuickWhereClipboardPropagation} + onQuickWherePaste={handleQuickWherePaste} + onApplyQuickWhere={() => applyQuickWhereCondition()} + onClearQuickWhere={clearQuickWhereCondition} + updateFilter={updateFilter} + removeFilter={removeFilter} + addFilter={addFilter} + isListOp={isListOp} + isBetweenOp={isBetweenOp} + isNoValueOp={isNoValueOp} + enableSortControls={!!onSort} + onApplySortInfo={applySortInfo} + onApplyFilters={applyFilters} + onEnableAllFilters={applyAllFiltersEnabled} + onDisableAllFilters={applyAllFiltersDisabled} + onClearFiltersAndSorts={clearAllFiltersAndSorts} + /> + +
+ {contextHolder} + {exportProgressModal} + setDdlModalOpen(false)} + onCopyDdl={handleCopyDdl} + /> + + {viewMode === 'table' ? ( + renderDataTableView() + ) : isV2Ui && viewMode === 'fields' ? ( + canOpenObjectDesigner ? ( + + ) : ( + + ) + ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + ddlSidebarWidth={ddlSidebarWidth} + ddlSidebarResizePreviewX={ddlSidebarResizePreviewX} + onResizeStart={handleDdlSidebarResizeStart} + /> + ) : isV2Ui && viewMode === 'ddl' ? ( + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + /> + ) : isV2Ui && viewMode === 'er' ? ( + + ) : viewMode === 'json' ? ( + + ) : ( + setTextRecordIndex((i: number) => Math.max(0, i - 1))} + onNext={() => setTextRecordIndex((i: number) => Math.min(textViewRows.length - 1, i + 1))} + onEditCurrent={openCurrentViewRowEditor} + formatTextViewValue={formatTextViewValue} + /> + )} + + { + handleDataPanelFormatJson((errorMessage: string) => { + void message.error(translateDataGrid('data_grid.json_editor.invalid_format', { error: errorMessage })); + }); + }} + onSave={handleDataPanelSave} + onValueChange={setDataPanelValue} + onDirtyChange={(dirty) => { + dataPanelDirtyRef.current = dirty; + }} + isDirtyComparedToOriginal={(value) => value !== dataPanelOriginalRef.current} + /> + + {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal( +
e.stopPropagation()} + > + {cellContextMenu.kind === 'column' ? (() => { + const fieldName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); + const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()]; + const activeSort = sortInfo.find((item: any) => item.columnKey === fieldName && item.enabled !== false); + return ( + + ); + })() : ( + + )} +
, + document.body + )} + + setCellContextMenu((prev: any) => ({ ...prev, visible: false }))} + onCopyFieldName={handleCopyContextMenuFieldName} + onCopyRowData={() => { + if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record); + }} + onCopyRowForPaste={() => { + const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) { + void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); + return; + } + setSelectedRowKeys([rowKey]); + copyRowsForPaste([rowKey]); + }} + onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} + onUndoCellChange={handleUndoContextMenuCellChange} + onSetNull={handleCellSetNull} + onEditRow={handleOpenContextMenuRowEditor} + onFillToSelected={() => { + if (selectedRowKeys.length > 0 && cellContextMenu.record) { + handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); + } + }} + onPasteCopiedColumns={() => { + const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + handlePasteCopiedColumnsToSelectedRows(fallbackKey); + }} + onCopyInsert={() => { + if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); + }} + onCopyUpdate={() => { + if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); + }} + onCopyDelete={() => { + if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); + }} + onCopyJson={() => { + if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); + }} + onCopyCsv={() => { + if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); + }} + onCopyMarkdown={() => { + if (cellContextMenu.record) { + const records = getTargets(cellContextMenu.record); + const lines = records.map((r: any) => { + const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; + return `| ${Object.values(vals).join(' | ')} |`; + }); + copyToClipboard(lines.join('\n')); + } + }} + onExportCsv={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error); + }} + onExportXlsx={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error); + }} + onExportJson={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error); + }} + onExportHtml={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error); + }} + /> +
+ + { + void handleOpenTableDdl(); + }} + translate={translateDataGrid} + /> + + + + {/* Ghost Resize Line for Columns */} +
+ + {/* Preview SQL Modal */} + setPreviewModalOpen(false)} + width={800} + footer={null} + > +
+ {previewSqlData.deletes.length > 0 && ( +
+
+ DELETE ({previewSqlData.deletes.length}) +
+ {previewSqlData.deletes.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.updates.length > 0 && ( +
+
+ UPDATE ({previewSqlData.updates.length}) +
+ {previewSqlData.updates.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.inserts.length > 0 && ( +
+
+ INSERT ({previewSqlData.inserts.length}) +
+ {previewSqlData.inserts.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && ( +
+ {translateDataGrid('data_grid.preview_sql.no_changes')} +
+ )} +
+
+ {translateDataGrid('data_grid.preview_sql.summary', { + deletes: previewSqlData.deletes.length, + updates: previewSqlData.updates.length, + inserts: previewSqlData.inserts.length + })} +
+
+ + {/* Import Preview Modal */} + { + setImportPreviewVisible(false); + setImportFilePath(''); + }} + onSuccess={handleImportSuccess} + /> +
+ ); +}; + +export default DataGridShell; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 757a363..4329786 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { readFileSync } from 'node:fs'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; import { setCurrentLanguage } from '../i18n'; import type { SavedQuery, TabData } from '../types'; @@ -618,6 +619,7 @@ describe('QueryEditor external SQL save', () => { editorState.position = { lineNumber: 1, column: 1 }; editorState.selection = null; editorState.domNode.style.cursor = ''; + editorState.providers = []; editorState.hoverProviders = []; editorState.contentChangeListeners = []; editorState.cursorPositionListeners = []; @@ -4919,2237 +4921,4 @@ describe('QueryEditor external SQL save', () => { renderer?.unmount(); }); - it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => { - storeState.connections[0].config.type = 'oracle'; - storeState.connections[0].config.database = 'ORCLPDB1'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }], - }); - const plsql = [ - 'BEGIN', - " INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');", - " UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';", - " DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';", - 'END;', - ].join('\n'); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1'); - expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ - sql: plsql, - status: 'success', - })); - renderer?.unmount(); - }); - - it('renders result grid for sqlserver exec statements that return rows', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['SPID', 'STATUS'], rows: [{ SPID: 52, STATUS: 'RUNNABLE' }] }], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:'); - expect(dataGridState.latestProps?.columnNames).toEqual(['SPID', 'STATUS']); - expect(Array.isArray(dataGridState.latestProps?.data)).toBe(true); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ SPID: 52, STATUS: 'RUNNABLE' }); - }); - - it('renders standalone message result for sqlserver statistics statements', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: [], - rows: [], - messages: ["Table 'users'. Scan count 1, logical reads 3."], - }], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 1'); - expect(textContent(renderer!.toJSON())).toContain("Table 'users'. Scan count 1, logical reads 3."); - expect(dataGridState.latestProps?.columnNames).not.toEqual([]); - }); - - it('keeps multiple result sets from a single sqlserver statement', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(dataGridState.latestProps?.columnNames).toEqual(['name']); - }); - - it('prefers the first displayable sqlserver procedure result when empty result sets are returned', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: [], rows: [] }, - { - statementIndex: 1, - columns: ['insert_sql'], - rows: [ - { insert_sql: "insert into c_user(userid) values('168')" }, - { insert_sql: "insert into c_user(userid) values('169')" }, - ], - }, - { statementIndex: 1, columns: [], rows: [] }, - { statementIndex: 1, columns: [], rows: [] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 4'); - expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ - insert_sql: "insert into c_user(userid) values('168')", - }); - }); - - it('prefers concrete sqlserver procedure rows over affected-row status results', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - { statementIndex: 1, columns: [], rows: [] }, - { - statementIndex: 1, - columns: ['insert_sql'], - rows: [ - { insert_sql: "insert into c_user(userid) values('168')" }, - ], - }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ - insert_sql: "insert into c_user(userid) values('168')", - }); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - }); - - it('prefers sqlserver print output messages over affected-row status results', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - { - statementIndex: 1, - columns: [], - rows: [], - messages: [ - "insert into c_dyscript(projectid,name) values (1,'demo')", - "insert into c_dyscript(projectid,name) values (2,'next')", - ], - }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - expect(dataGridState.latestProps).toBeNull(); - }); - - it('renders top-level sqlserver print messages when result sets contain only status rows', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - ], - messages: [ - "insert into c_dyscript(projectid,name) values (1,'demo')", - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - expect(dataGridState.latestProps).toBeNull(); - }); - - it('keeps both tabs when rerunning the same single sqlserver statement with multiple result sets', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'tempdb' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - }); - expect(tabLabels).toHaveLength(2); - expect(dataGridState.latestProps?.columnNames).toEqual(['name']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'tempdb' }); - }); - - it('reloads the active secondary result set for a single sqlserver statement', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const resultTabButtons = renderer!.root.findAll((node) => node.type === 'button' && node.props['data-tab-key']); - expect(resultTabButtons).toHaveLength(2); - - await act(async () => { - resultTabButtons[1].props.onClick(); - }); - - expect(dataGridState.latestProps?.columnNames).toEqual(['owner']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'sa' }); - - await act(async () => { - await dataGridState.latestProps?.onReload?.(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(dataGridState.latestProps?.columnNames).toEqual(['owner']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'dbo' }); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'master' })])); - }); - - it('localizes the non-Oracle no-safe-locator read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'NAME', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.pkColumns).toEqual([]); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'No primary key or usable unique index was detected, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: main.users No primary key or usable unique index was detected, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。', - ); - }); - - it('localizes the non-Oracle index-metadata-unavailable read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'NAME', key: '' }], - }); - backendApp.DBGetIndexes.mockResolvedValueOnce({ - success: false, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'Unable to load unique index metadata, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: main.users Unable to load unique index metadata, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:main.users 无法加载唯一索引元数据,无法安全提交修改。', - ); - }); - - it('localizes the table-locator-metadata-unavailable read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: false, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。', - ); - }); - - it('keeps MySQL information_schema routine results read-only without a locator warning', async () => { - const sql = [ - 'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE', - 'FROM information_schema.ROUTINES', - "WHERE ROUTINE_SCHEMA = 'mkefu_location_dev_local'", - " AND ROUTINE_NAME = 'init_orgi'", - ].join('\n'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: ['ROUTINE_SCHEMA', 'ROUTINE_NAME', 'DEFINER', 'SECURITY_TYPE'], - rows: [{ - ROUTINE_SCHEMA: 'mkefu_location_dev_local', - ROUTINE_NAME: 'init_orgi', - DEFINER: 'root@%', - SECURITY_TYPE: 'DEFINER', - }], - }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('ROUTINES'); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); - expect(backendApp.DBGetIndexes).not.toHaveBeenCalled(); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 8 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.(); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ - sql: expect.stringContaining('select 2 as two'), - })); - }); - - it('keeps cursor statement execution available in v2 UI', async () => { - storeState.appearance.uiVersion = 'v2'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 8 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.(); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - }); - - it('renders V2 empty state copy for the active non-Chinese language', async () => { - storeState.appearance.uiVersion = 'v2'; - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - const rendered = textContent(renderer!.toJSON()); - expect(rendered).toContain('Awaiting SQL execution'); - expect(rendered).toContain('Run a query to display results below in the new data grid.'); - expect(rendered).not.toContain('等待执行 SQL'); - expect(rendered).not.toContain('运行查询后,结果会在下方以新版数据网格展示。'); - }); - - it('uses the last editor cursor position when the run button takes focus', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); - }); - editorState.hasTextFocus = false; - editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - }); - - it('prefers the last editor cursor event even if Monaco still reports text focus', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); - }); - editorState.hasTextFocus = true; - editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - }); - - it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - editorState.position = { lineNumber: 3, column: 1 }; - - await act(async () => { - const runButton = findButton(renderer!, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - }); - - it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - }); - - it('shows "No executable SQL." in English when the cursor is on a blank line', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - backendApp.DBQueryMulti.mockClear(); - messageApi.info.mockClear(); - - editorState.position = { lineNumber: 3, column: 1 }; - editorState.selection = { - startLineNumber: 3, - startColumn: 1, - endLineNumber: 3, - endColumn: 1, - positionLineNumber: 3, - positionColumn: 1, - }; - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 3, column: 1 } }); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); - expect(messageApi.info).toHaveBeenCalledWith('No executable SQL.'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - }); - - it('shows "Select a database first." in English before running without a database', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Select a database first.'); - expect(messageApi.error).not.toHaveBeenCalledWith('请先选择数据库'); - }); - - it('shows "Connection not found." in English before running without a matching connection', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections = []; - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Connection not found.'); - expect(messageApi.error).not.toHaveBeenCalledWith('Connection not found'); - }); - - it('shows the unsupported source guard in English before running', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'redis'; - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith( - 'This data source does not support the SQL query editor. Use its dedicated page instead.', - ); - expect(messageApi.error).not.toHaveBeenCalledWith('当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。'); - }); - - describe('execution toast localization', () => { - it('shows the Mongo multi-statement success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = 'db.users.find({});\ndb.logs.find({});'; - backendApp.DBQueryWithCancel - .mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] }) - .mockResolvedValueOnce({ success: true, data: [{ _id: 2 }], fields: ['_id'] }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 2, - endColumn: 'db.logs.find({});'.length + 1, - positionLineNumber: 2, - positionColumn: 'db.logs.find({});'.length + 1, - }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Executed 2 statements and produced 2 result sets.'); - expect(messageApi.success).not.toHaveBeenCalledWith('已执行 2 条语句,生成 2 个结果集。'); - }); - - it('shows the Mongo multi-statement failure prefix localization in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = 'db.users.find({});\ndb.logs.find({});'; - backendApp.DBQueryWithCancel - .mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] }) - .mockResolvedValueOnce({ success: false, message: 'driver exploded' }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 2, - endColumn: 'db.logs.find({});'.length + 1, - positionLineNumber: 2, - positionColumn: 'db.logs.find({});'.length + 1, - }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const rendered = textContent(renderer.toJSON()); - expect(rendered).toContain('Statement 2 failed: driver exploded'); - expect(rendered).not.toContain('第 2 条语句执行失败:driver exploded'); - }); - - it('shows the Mongo zero-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = '{"ping":1}'; - backendApp.DBQueryWithCancel.mockResolvedValueOnce({ success: true, data: { ok: 1 } }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.position = { lineNumber: 1, column: query.length + 1 }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); - expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); - }); - - it('shows the non-Mongo multi-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a; select 2 as b;'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }] }, - { columns: ['b'], rows: [{ b: 2 }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 2 as b'); - expect(messageApi.success).toHaveBeenCalledWith('Execution finished and produced 2 result sets.'); - expect(messageApi.success).not.toHaveBeenCalledWith('已执行完成,生成 2 个结果集。'); - }); - - it('shows the non-Mongo zero-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'update users set active = 1 where 1 = 0;'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, data: [] }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('update users set active = 1 where 1 = 0'); - expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); - expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); - }); - - it('shows the wrapped execution failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1;'; - backendApp.DBQueryMulti.mockRejectedValueOnce(new Error('driver exploded')); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1'); - expect(messageApi.error).toHaveBeenCalledWith('Query execution failed: driver exploded'); - expect(messageApi.error).not.toHaveBeenCalledWith('Error executing query: driver exploded'); - }); - }); - - describe('result refresh toast localization', () => { - const renderAndRunQuery = async (query: string) => { - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.onReload).toEqual(expect.any(Function)); - }; - - it('shows the response refresh failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a;'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ success: false, message: 'network down' }); - - await renderAndRunQuery(query); - messageApi.error.mockClear(); - - await act(async () => { - await dataGridState.latestProps.onReload(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: network down'); - expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: network down'); - }); - - it('shows the rejected refresh failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a;'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockRejectedValueOnce(new Error('socket closed')); - - await renderAndRunQuery(query); - messageApi.error.mockClear(); - - await act(async () => { - await dataGridState.latestProps.onReload(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: socket closed'); - expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: socket closed'); - }); - }); - - it('shows "No running query to cancel." in English when stop is clicked before a query id exists', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockReturnValueOnce(new Promise(() => {})); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.warning).toHaveBeenCalledWith('No running query to cancel.'); - expect(messageApi.warning).not.toHaveBeenCalledWith('没有正在运行的查询可取消'); - }); - - it('shows "Query canceled." in English when stop cancels a running query', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockResolvedValueOnce('query-1'); - backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {})); - backendApp.CancelQuery.mockResolvedValueOnce({ success: true }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Query canceled.'); - expect(messageApi.success).not.toHaveBeenCalledWith('查询已取消'); - }); - - it('shows "Failed to cancel query" in English while preserving the raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockResolvedValueOnce('query-1'); - backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {})); - backendApp.CancelQuery.mockRejectedValueOnce(new Error('network down')); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Failed to cancel query: network down'); - expect(messageApi.error).not.toHaveBeenCalledWith('取消查询失败: network down'); - }); - - it('runs only appended SQL and keeps existing results after a full editor execution', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - editorState.value = 'select 1 as a;\nselect 2 as b;'; - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-count') && textContent(node) === '1'; - })).toHaveLength(2); - }); - - it('replaces existing result tabs when rerunning the same formatted SQL', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['id'], rows: [{ id: 1 }, { id: 2 }, { id: 3 }] }, - { columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 1 })) }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['id'], rows: [{ id: 11 }, { id: 12 }, { id: 13 }] }, - { columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 11 })) }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'SELECT * FROM fs_org_auth_application;'.length + 1 }; - editorState.selection = null; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - - editorState.value = [ - 'SELECT', - ' *', - 'FROM', - ' fs_org_auth_application;', - '', - 'SELECT', - ' *', - 'FROM', - ' fs_bcp_auth_info;', - ].join('\n'); - editorState.position = { lineNumber: 4, column: ' fs_org_auth_application;'.length + 1 }; - editorState.selection = null; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 3'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 4'); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(2); - }); - - it('provides context menu actions for query result tabs', async () => { - backendApp.DBQueryMulti.mockResolvedValue({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }] }, - { columns: ['b'], rows: [{ b: 2 }] }, - { columns: ['c'], rows: [{ c: 3 }] }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(3); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭右侧')[1].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(2); - expect(textContent(renderer!.toJSON())).not.toContain('结果 3'); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭左侧')[1].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(1); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ c: 3 })])); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭所有')[0].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(0); - }); - - it('replaces the current result when rerunning the same cursor SQL', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 10 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 ')); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 2'); - expect(tabLabels.length).toBeGreaterThan(0); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })])); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b'); - }); - - it('appends a result when running a different cursor SQL after an existing result', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c'); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - }); - - it('renders compact result tab labels with row counts outside the title text', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }, { a: 2 }] }, - { columns: ['b'], rows: [{ b: 3 }] }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - }); - const counts = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-count'); - }); - const titles = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-text'); - }); - - expect(tabLabels).toHaveLength(2); - expect(titles.map((node) => textContent(node))).toEqual(['结果 1', '结果 2']); - expect(counts.map((node) => textContent(node))).toEqual(['2', '1']); - expect(textContent(renderer!.toJSON())).not.toContain('结果 1 (2)'); - }); - - it('keeps query result tabs compact, centered, and readable in v2 UI', () => { - const source = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - - expect(source).toContain('.query-result-tabs .ant-tabs-tab {'); - expect(source).toContain('width: auto !important;'); - expect(source).toContain('max-width: 148px !important;'); - expect(source).toContain('height: 30px !important;'); - expect(source).toContain('align-items: center !important;'); - expect(source).toContain('font-size: 14px !important;'); - expect(source).toContain('.query-result-tab-text {'); - expect(source).toContain('user-select: none;'); - expect(source).toContain('font-weight: 700;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab-btn {'); - expect(css).toContain('user-select: none;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); - }); - - it('keeps the v2 query editor toolbar grouped and compact', () => { - const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); - const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); - const resultsPanelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); - const transactionSettingsSource = readFileSync(new URL('./QueryEditorTransactionSettings.tsx', import.meta.url), 'utf8'); - const transactionToolbarSource = readFileSync(new URL('./QueryEditorTransactionToolbar.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - - expect(source).toContain('QueryEditorToolbar'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-selects'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-actions'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-connection-select'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-database-select'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-max-rows-select'); - expect(toolbarSource).toContain('QueryEditorTransactionSettings'); - expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-mode-select'); - expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-delay-select'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.tooltip'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.manual'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.auto'); - expect(transactionSettingsSource).not.toContain("label: '手动提交'"); - expect(transactionSettingsSource).not.toContain("label: '自动提交'"); - expect(transactionSettingsSource).toContain('query_editor.transaction.delay.immediate'); - expect(transactionSettingsSource).toContain("label: '3s'"); - expect(source).toContain('QueryEditorTransactionToolbar'); - expect(transactionToolbarSource).toContain("className={isV2Ui ? 'gn-v2-query-transaction-toolbar' : undefined}"); - expect(transactionToolbarSource).toContain(": null;"); - expect(transactionToolbarSource).toContain('gn-v2-query-transaction-commit-button'); - expect(transactionToolbarSource).toContain('gn-v2-toolbar-kbd'); - expect(transactionToolbarSource).toContain('query_editor.transaction.status.auto_committing'); - expect(transactionToolbarSource).toContain('onFinish'); - expect(toolbarSource).toContain('{isV2Ui && pendingTransactionToolbar}'); - expect(toolbarSource).not.toContain('gn-v2-query-toolbar-transaction-row'); - expect(resultsPanelSource).not.toContain('transactionToolbar?: React.ReactNode;'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-action-group'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-action-pair'); - expect(toolbarSource).toContain('const aiMenuItems'); - expect(toolbarSource).toContain('key: "toggle-result-panel"'); - expect(toolbarSource).toContain('{!isV2Ui && ('); - expect(toolbarSource).toContain('trigger={["click"]}'); - expect(toolbarSource.indexOf('onClick={onQuickSave}')).toBeLessThan(toolbarSource.indexOf('menu={{ items: aiMenuItems }}')); - expect(toolbarSource.indexOf('menu={{ items: aiMenuItems }}')).toBeLessThan(toolbarSource.indexOf('menu={{ items: moreMenuItems }}')); - expect(toolbarSource.indexOf('menu={{ items: moreMenuItems }}')).toBeLessThan(toolbarSource.indexOf('icon={}')); - expect(transactionSettingsSource).toContain('style={isV2Ui ? undefined : { width: 78 }}'); - expect(transactionSettingsSource).toContain('style={isV2Ui ? undefined : { width: 68 }}'); - expect(toolbarSource).toContain('style={isV2Ui ? undefined : { width: 200 }}'); - expect(toolbarSource).toContain('style={isV2Ui ? undefined : { width: 170 }}'); - - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions'); - expect(css).toContain('width: 74px !important;'); - expect(css).toContain('width: 62px !important;'); - expect(css).toContain('flex: 0 0 auto !important;'); - expect(css).toContain('justify-content: flex-start;'); - expect(css).toContain('height: 32px !important;'); - expect(css).toContain('line-height: 30px !important;'); - expect(css).toContain('display: inline-flex !important;'); - expect(css).toContain('gap: 6px;'); - expect(css).toContain('overflow-x: auto;'); - expect(css).toContain('overflow-y: hidden;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair'); - expect(css).toContain('gap: 8px;'); - expect(css).toContain('margin-left: 0 !important;'); - expect(css).toContain('max-width: 760px;'); - expect(css).toContain('width: 140px !important;'); - expect(css).toContain('width: 166px !important;'); - expect(css).toContain('width: 132px !important;'); - expect(css).toContain('width: 34px !important;'); - expect(css).toContain('@media (max-width: 900px)'); - expect(css).not.toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row {'); - - const queryToolbarMainCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-main {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects {')); - expect(queryToolbarMainCss).toContain('flex-wrap: nowrap;'); - expect(queryToolbarMainCss).toContain('width: max-content;'); - expect(queryToolbarMainCss).not.toContain('flex-wrap: wrap;'); - expect(queryToolbarMainCss).not.toContain('margin-left: auto;'); - expect(queryToolbarMainCss).not.toContain('justify-content: flex-end;'); - }); - - it('keeps custom SQL snippet syntax help editable and uses it in completion details', () => { - const modalSource = readFileSync(new URL('./SnippetSettingsModal.tsx', import.meta.url), 'utf8'); - const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); - - expect(modalSource).toContain('片段语法说明(可编辑)'); - expect(modalSource).toContain('data-sql-snippet-syntax-help-editor="true"'); - expect(modalSource).toContain("defaultActiveKey={['snippet-help']}"); - expect(modalSource).toContain('footer={null}'); - expect(modalSource).toContain('data-sql-snippet-action-row="true"'); - expect(modalSource).toContain('body: { paddingTop: 8, paddingBottom: 24 }'); - expect(modalSource).toContain("size=\"large\""); - expect(modalSource).toContain('minWidth: 96'); - expect(modalSource).toContain('syntaxHelp'); - expect(modalSource).toContain('占位符语法参考'); - expect(source).toContain('s.syntaxHelp || s.description || s.body'); - }); - - it('coalesces editor result splitter dragging through requestAnimationFrame', async () => { - const moveListeners: Array<(event: MouseEvent) => void> = []; - const upListeners: Array<() => void> = []; - const frameCallbacks: FrameRequestCallback[] = []; - vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => { - if (type === 'mousemove') moveListeners.push(listener); - if (type === 'mouseup') upListeners.push(listener); - }); - vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => { - frameCallbacks.push(callback); - return frameCallbacks.length; - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度'); - await act(async () => { - resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() }); - moveListeners.forEach((listener) => listener({ clientY: 340 } as MouseEvent)); - moveListeners.forEach((listener) => listener({ clientY: 380 } as MouseEvent)); - }); - - expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); - expect(editorState.editor.layout).not.toHaveBeenCalled(); - - await act(async () => { - frameCallbacks.splice(0).forEach((callback) => callback(16)); - }); - expect(editorState.editor.layout).toHaveBeenCalledTimes(1); - - await act(async () => { - upListeners.forEach((listener) => listener()); - }); - expect(editorState.editor.layout).toHaveBeenCalledTimes(2); - expect(document.removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function)); - expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function)); - }); - - it('inserts sidebar object text when dropped into the SQL editor', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - - await act(async () => { - create(); - }); - - editorState.position = { lineNumber: 1, column: 'select * from '.length + 1 }; - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ text: 'reporting.active_users' }); - } - if (type === 'text/plain') { - return 'reporting.active_users'; - } - return ''; - }, - }, - })); - }); - - expect(editorState.editor.executeEdits).toHaveBeenCalledWith( - 'gonavi-sidebar-drop', - [expect.objectContaining({ text: 'reporting.active_users' })], - ); - expect(editorState.value).toContain('reporting.active_users'); - }); - - it('prevents Monaco native drag marker and keeps metadata hover after sidebar object drops', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - editorState.editor.getTargetAtClientPoint = vi.fn(() => ({ - position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 }, - })); - editorState.value = 'SELECT * FROM '; - autoFetchState.visible = true; - backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'front_end_sys' }] }); - backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] }); - backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); - - await act(async () => { - create(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const dragOverEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - dropEffect: 'none', - getData: vi.fn(() => ''), - }, - }; - await act(async () => { - domListeners.dragover?.forEach((listener) => listener(dragOverEvent)); - }); - - expect(dragOverEvent.preventDefault).toHaveBeenCalled(); - expect(dragOverEvent.stopPropagation).toHaveBeenCalled(); - expect(dragOverEvent.dataTransfer.dropEffect).toBe('copy'); - expect(dragOverEvent.dataTransfer.getData).not.toHaveBeenCalled(); - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ text: 'fs_mkefu_regist_record' }); - } - if (type === 'text/plain') { - return 'fs_mkefu_regist_record'; - } - return ''; - }, - }, - })); - }); - - const hover = editorState.hoverProviders[0]?.provideHover( - editorState.editor.getModel(), - { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length }, - ); - expect(editorState.value).toContain('fs_mkefu_regist_record'); - expect(hover?.contents?.[0]?.value).toContain('**表** `fs_mkefu_regist_record`'); - - await act(async () => { - editorState.mouseDownListeners[0]?.({ - target: { position: { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length } }, - event: { - leftButton: true, - ctrlKey: true, - metaKey: false, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }, - }); - }); - - expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' }); - expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - type: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - tableName: 'fs_mkefu_regist_record', - objectType: 'table', - })); - }); - - it('keeps sidebar object navigation tied to the dragged database after drop', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - editorState.editor.getTargetAtClientPoint = vi.fn(() => ({ - position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 }, - })); - editorState.value = 'SELECT * FROM '; - autoFetchState.visible = true; - backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'front_end_sys' }] }); - backendApp.DBGetTables - .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) - .mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] }); - backendApp.DBGetAllColumns - .mockResolvedValueOnce({ success: true, data: [] }) - .mockResolvedValueOnce({ success: true, data: [] }); - - await act(async () => { - create(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ - text: 'fs_mkefu_regist_record', - nodeType: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - }); - } - if (type === 'text/plain') { - return 'fs_mkefu_regist_record'; - } - return ''; - }, - }, - })); - }); - - expect(editorState.value).toContain('front_end_sys.fs_mkefu_regist_record'); - - await act(async () => { - editorState.mouseDownListeners[0]?.({ - target: { position: { lineNumber: 1, column: 'SELECT * FROM front_end_sys.fs_mkefu_regist_record'.length } }, - event: { - leftButton: true, - ctrlKey: true, - metaKey: false, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }, - }); - }); - - expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' }); - expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - type: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - tableName: 'fs_mkefu_regist_record', - objectType: 'table', - })); - }); - - it('runs selected SQL before cursor SQL', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['selected'], rows: [{ selected: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 4 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 1, - endLineNumber: 2, - endColumn: 'select 2 as selected'.length + 1, - }; - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - }); - - it('allows editable table columns while leaving expression columns out of commits', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'], - rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }], - }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'primary-key', - columns: ['ID'], - valueColumns: ['__gonavi_locator_1_ID'], - hiddenColumns: ['__gonavi_locator_1_ID'], - writableColumns: { - DISPLAY_NAME: 'NAME', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => { - storeState.connections[0].config.type = 'duckdb'; - storeState.connections[0].config.database = 'main'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events'); - expect(dataGridState.latestProps?.tableName).toBe('main.events'); - expect(dataGridState.latestProps?.pkColumns).toEqual(['id']); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'primary-key', - columns: ['id'], - valueColumns: ['__gonavi_locator_1_id'], - hiddenColumns: ['__gonavi_locator_1_id'], - writableColumns: { - NAME: 'name', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('uses hidden DuckDB rowid when query results have no primary or unique key', async () => { - storeState.connections[0].config.type = 'duckdb'; - storeState.connections[0].config.database = 'main'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME', '__gonavi_duckdb_rowid__'], rows: [{ NAME: 'launch', __gonavi_duckdb_rowid__: 17 }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'name', key: '' }], - }); - backendApp.DBGetIndexes.mockResolvedValueOnce({ - success: true, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('main.events'); - expect(dataGridState.latestProps?.pkColumns).toEqual([]); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'duckdb-rowid', - columns: ['rowid'], - valueColumns: ['__gonavi_duckdb_rowid__'], - hiddenColumns: ['__gonavi_duckdb_rowid__'], - writableColumns: { - NAME: 'name', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('rowid AS "__gonavi_duckdb_rowid__"'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it.each([ - 'mysql', - 'mariadb', - 'oceanbase', - 'diros', - 'sphinx', - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'sqlserver', - 'sqlite', - 'duckdb', - 'oracle', - 'dameng', - 'tdengine', - 'clickhouse', - ])( - 'keeps aggregate query results silently read-only for %s', - async (dbType) => { - storeState.connections[0].config.type = dbType; - storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main'; - const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const expectedTableName = dbType === 'oracle' || dbType === 'dameng' ? 'USERS' : 'users'; - expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : expectedTableName); - expect(dataGridState.latestProps?.editLocator).toBeUndefined(); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); - expect(backendApp.DBGetIndexes).not.toHaveBeenCalled(); - expect(messageApi.warning).not.toHaveBeenCalled(); - }, - ); }); diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx new file mode 100644 index 0000000..b339419 --- /dev/null +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -0,0 +1,2890 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; + +import { setCurrentLanguage } from '../i18n'; +import type { SavedQuery, TabData } from '../types'; +import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; +import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import QueryEditor, { + collectQueryEditorObjectDecorationCandidates, + resolveQueryEditorNavigationDecorations, + resolveQueryEditorNavigationTarget, +} from './QueryEditor'; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + password: '', + database: 'main', + }, + }, + ], + addSqlLog: vi.fn(), + addTab: vi.fn(), + setActiveContext: vi.fn(), + updateQueryTabDraft: vi.fn(), + savedQueries: [] as SavedQuery[], + saveQuery: vi.fn(), + theme: 'light', + languagePreference: 'zh-CN' as 'zh-CN' | 'en-US', + appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' }, + sqlFormatOptions: { keywordCase: 'upper' as const }, + setSqlFormatOptions: vi.fn(), + queryOptions: { + maxRows: 5000, + showColumnComment: true, + showColumnType: true, + showQueryResultsPanel: false, + }, + setQueryOptions: vi.fn(), + sqlEditorTransactionOptions: { + commitMode: 'manual' as 'manual' | 'auto', + autoCommitDelayMs: 0, + }, + setSqlEditorTransactionOptions: vi.fn(), + sqlEditorPendingTransactions: {} as Record, + setSqlEditorPendingTransaction: vi.fn(), + shortcutOptions: { + runQuery: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, + selectCurrentStatement: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, + saveQuery: { + mac: { enabled: true, combo: 'Meta+S' }, + windows: { enabled: true, combo: 'Ctrl+S' }, + }, + toggleQueryResultsPanel: { + mac: { enabled: true, combo: 'Meta+Shift+M' }, + windows: { enabled: true, combo: 'Ctrl+Shift+M' }, + }, + }, + activeTabId: 'tab-1', + aiPanelVisible: false, + setAIPanelVisible: vi.fn(), + sqlSnippets: [] as any[], +})); + +const storeSubscribers = vi.hoisted(() => new Set<() => void>()); + +const notifyStoreSubscribers = () => { + storeSubscribers.forEach((subscriber) => subscriber()); +}; + +const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), + DBQueryWithCancel: vi.fn(), + DBQueryMulti: vi.fn(), + DBQueryMultiTransactional: vi.fn(), + DBCommitTransaction: vi.fn(), + DBRollbackTransaction: vi.fn(), + DBGetTables: vi.fn(), + DBGetAllColumns: vi.fn(), + DBGetDatabases: vi.fn(), + DBGetColumns: vi.fn(), + DBGetIndexes: vi.fn(), + CancelQuery: vi.fn(), + GenerateQueryID: vi.fn(), + WriteSQLFile: vi.fn(), + ExportSQLFile: vi.fn(), +})); + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +})); + +const dataGridState = vi.hoisted(() => ({ + latestProps: null as any, +})); + +const tabsState = vi.hoisted(() => ({ + activeKey: undefined as string | undefined, +})); + +const autoFetchState = vi.hoisted(() => ({ + visible: false, +})); + +const editorState = vi.hoisted(() => { + const state = { + value: '', + editor: null as any, + domNode: { style: { cursor: '' }, addEventListener: vi.fn(), removeEventListener: vi.fn() }, + position: { lineNumber: 1, column: 1 }, + selection: null as any, + providers: [] as any[], + hoverProviders: [] as any[], + contentChangeListeners: [] as Array<() => void>, + cursorPositionListeners: [] as Array<(event: any) => void>, + modelContentListeners: [] as Array<(event: any) => void>, + mouseMoveListeners: [] as Array<(event: any) => void>, + mouseDownListeners: [] as Array<(event: any) => void>, + mouseLeaveListeners: [] as Array<() => void>, + hasTextFocus: true, + decorationIds: [] as string[], + contentHoverCalls: [] as any[], + latestOnChange: null as null | ((value?: string) => void), + }; + const offsetAt = (position: { lineNumber: number; column: number }) => { + const text = state.value; + let offset = 0; + for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) { + const nextLineBreak = text.indexOf('\n', offset); + if (nextLineBreak === -1) { + return text.length; + } + offset = nextLineBreak + 1; + } + return Math.min(text.length, offset + Math.max(0, position.column - 1)); + }; + const positionAt = (offset: number) => { + const text = state.value.replace(/\r\n/g, '\n'); + const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0)); + const prefix = text.slice(0, safeOffset); + const lines = prefix.split('\n'); + return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 }; + }; + const valueInRange = (range: any) => { + if (!range) return ''; + const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }); + const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn }); + return state.value.slice(Math.min(start, end), Math.max(start, end)); + }; + const model = { + getValue: vi.fn(() => state.value), + getValueLength: vi.fn(() => state.value.length), + setValue: (value: string) => { + state.value = value; + }, + getValueInRange: valueInRange, + getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '', + getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length, + getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1, + getWordUntilPosition: (position: { lineNumber: number; column: number }) => { + const lineContent = model.getLineContent(position.lineNumber); + const beforeCursor = lineContent.slice(0, Math.max(0, position.column - 1)); + const word = beforeCursor.match(/[A-Za-z0-9_$]*$/)?.[0] || ''; + return { + startColumn: position.column - word.length, + endColumn: position.column, + word, + }; + }, + getOffsetAt: offsetAt, + getPositionAt: positionAt, + }; + state.editor = { + getValue: vi.fn(() => state.value), + setValue: vi.fn((value: string) => { + state.value = value; + }), + getModel: vi.fn(() => model), + getPosition: vi.fn(() => state.position), + setPosition: vi.fn((position: any) => { + state.position = position; + }), + getSelection: vi.fn(() => state.selection), + getDomNode: vi.fn(() => state.domNode), + getContribution: vi.fn((id: string) => { + if (id === 'editor.contrib.contentHover') { + return { + showContentHover: vi.fn((range: any, mode: any, source: any, focus: any) => { + state.contentHoverCalls.push({ range, mode, source, focus }); + }), + }; + } + return null; + }), + setSelection: vi.fn((selection: any) => { + state.selection = selection; + }), + executeEdits: vi.fn((_source: string, edits: any[]) => { + edits.forEach((edit) => { + const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn }); + const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn }); + state.value = state.value.slice(0, start) + edit.text + state.value.slice(end); + }); + }), + addAction: vi.fn(), + onDidChangeModelContent: vi.fn((listener: (event?: any) => void) => { + state.contentChangeListeners.push(listener); + state.modelContentListeners.push(listener); + return { dispose: vi.fn() }; + }), + onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => { + state.cursorPositionListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseMove: vi.fn((listener: (event: any) => void) => { + state.mouseMoveListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseDown: vi.fn((listener: (event: any) => void) => { + state.mouseDownListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseLeave: vi.fn((listener: () => void) => { + state.mouseLeaveListeners.push(listener); + return { dispose: vi.fn() }; + }), + deltaDecorations: vi.fn((oldDecorations: string[], newDecorations: any[]) => { + state.decorationIds = newDecorations.map((_: any, index: number) => `decoration-${index + 1}`); + return state.decorationIds; + }), + updateOptions: vi.fn(), + pushUndoStop: vi.fn(), + onDidDispose: vi.fn(), + hasTextFocus: vi.fn(() => state.hasTextFocus), + revealLineInCenterIfOutsideViewport: vi.fn(), + revealRangeInCenterIfOutsideViewport: vi.fn(), + layout: vi.fn(), + focus: vi.fn(), + trigger: vi.fn(), + }; + return state; +}); + +vi.mock('../store', () => { + const useStore = Object.assign( + (selector: (state: typeof storeState) => any) => React.useSyncExternalStore( + (subscriber) => { + storeSubscribers.add(subscriber); + return () => { + storeSubscribers.delete(subscriber); + }; + }, + () => selector(storeState), + () => selector(storeState), + ), + { getState: () => storeState }, + ); + return { useStore }; +}); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('../utils/autoFetchVisibility', () => ({ + useAutoFetchVisibility: () => autoFetchState.visible, +})); + +vi.mock('@monaco-editor/react', () => ({ + default: ({ defaultValue, onChange, onMount }: any) => { + React.useEffect(() => { + editorState.value = String(defaultValue || ''); + editorState.latestOnChange = onChange; + onMount?.(editorState.editor, { + editor: { setTheme: vi.fn() }, + KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, + KeyCode: { KeyM: 77, KeyQ: 81, KeyS: 83 }, + languages: { + CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, + CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, + registerCompletionItemProvider: vi.fn((_language: string, provider: any) => { + editorState.providers.push(provider); + return { dispose: vi.fn() }; + }), + registerHoverProvider: vi.fn((_language: string, provider: any) => { + editorState.hoverProviders.push(provider); + return { dispose: vi.fn() }; + }), + }, + Range: class { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) { + this.startLineNumber = startLineNumber; + this.startColumn = startColumn; + this.endLineNumber = endLineNumber; + this.endColumn = endColumn; + } + }, + MarkdownString: class { + value: string; + constructor(value: string) { + this.value = value; + } + }, + Position: class { + lineNumber: number; + column: number; + constructor(lineNumber: number, column: number) { + this.lineNumber = lineNumber; + this.column = column; + } + }, + }); + }, []); + return