Compare commits

..

32 Commits

Author SHA1 Message Date
Syngnat
2d9d5f0e98 feat(data-grid): 支持右键复制字段名称
- 新增单元格右键菜单“复制字段名称”
- 将表格复制成功提示改为中文
- 补充字段名称解析回归测试
2026-05-10 20:55:16 +08:00
Syngnat
7dc9da0fd0 🐛 fix(dameng): 修复特殊字符密码导致连接认证失败
- 调整达梦 DSN 生成逻辑,密码按驱动解析规则原样传入
- 移除默认 escapeProcess 参数示例,避免误导配置
- 补充特殊字符密码与问号密码的回归测试
Refs #446
2026-05-10 20:26:19 +08:00
Syngnat
a11d39f981 Merge pull request #452 from TonyJiangWJ/feature/shortcuts-conflict
🐛 fix(shortcuts): 修复编辑器快捷键冲突处理
2026-05-10 19:55:24 +08:00
Syngnat
4ce920cc86 Merge pull request #453 from TonyJiangWJ/feature/datagrid-enhance
 feat(data-grid): 增强数据表编辑与展示体验
2026-05-10 19:26:58 +08:00
TonyJiangWJ
1965564386 feat(data-grid): 增强数据表编辑与展示体验
- 新增变更预览能力,支持在提交前查看删除、更新和新增对应的 SQL 语句
- 增加表格密度配置,统一控制默认列宽、行高、字号与单元格内边距
- 优化 DataGrid 编辑状态展示,区分新增、修改和删除行列的视觉反馈
- 调整导出入口与 Wails 前端绑定,补齐变更预览相关调用与测试覆盖
2026-05-10 19:00:47 +08:00
TonyJiangWJ
f3d325ddab 🐛 fix(shortcuts): 修复编辑器快捷键冲突处理
- 新增保留快捷键冲突检测,区分浏览器、Monaco 编辑器和数据表格等不同冲突来源。
- 在快捷键设置弹窗中展示冲突提示,并在录入冲突快捷键时给出覆盖或可能失效的反馈。
- 将执行 SQL 快捷键注册到 Monaco 内部 keybinding,确保可覆盖编辑器默认快捷键并触发当前活跃查询。
- 增加快捷键冲突检测和 Monaco keybinding 转换的单元测试,覆盖常见组合键与边界情况。
2026-05-10 18:43:36 +08:00
Syngnat
c0ae40c638 🐛 fix(mysql): 修复旧版 Windows 无法解析 Asia/Shanghai 时区
- 嵌入 Go IANA 时区数据,兼容 Windows Server 2012 等缺少 zoneinfo 的环境
- 保持 MySQL serverTimezone=GMT+8 到 loc=Asia/Shanghai 的时间语义
- 增加 MySQL DSN 时区解析回归测试
Refs #449
2026-05-10 17:29:11 +08:00
Syngnat
947bdbbe0c Merge pull request #451 from TonyJiangWJ/feature/sql-snippets
# Conflicts:
#	frontend/package.json.md5
2026-05-10 12:46:45 +08:00
Syngnat
c99287dc10 Merge pull request #450 from jsfaint/fix/pg_schema 2026-05-10 12:44:19 +08:00
Syngnat
49c20bef89 🐛 fix(data-grid): 修复快捷 WHERE 自动补全回车行为
- 调整快捷 WHERE 输入框 Enter 处理,避免抢占 AutoComplete 选中事件
- 方向键高亮建议项后回车优先选择字段
- 增加快捷 WHERE 回车行为回归测试
2026-05-10 12:41:08 +08:00
Syngnat
d26d7d2ff0 🐛 fix(data-grid): 修复数据输出列序与时间精度问题
- 统一复制、导出、JSON/Text 视图按表格展示列序输出
- 表级导出改用显式列查询,避免 SELECT * 丢失界面列序
- 保留 datetime(3) 等时间字段的小数秒展示与复制输出
Refs #434
2026-05-10 12:32:41 +08:00
TonyJiangWJ
30f3ac86aa feat(query-editor): 支持 SQL 片段配置 2026-05-10 08:59:25 +08:00
Jia Sui
741fba4c27 🐛 fix(postgres): 修复 LIKE 'pg_%' 误匹配 pgsrpschema 等非系统 schema
LIKE 模式中 '_' 是单字符通配符,'pg_%' 不仅匹配 pg_catalog/pg_toast,
还会匹配 pgsrpschema 等以 'pgs' 开头的 schema,导致这些 schema
下的表被 GetTables 漏掉,侧边栏不显示 schema 分组。
改用 LIKE 'pg|_%' ESCAPE '|','_' 仅匹配字面量下划线。
2026-05-09 19:11:18 +08:00
Syngnat
baed7a2721 🐛 fix(sidebar): 修复树节点左侧图标对齐
- 调整树节点内容区布局,固定展开符和图标宽度
- 保持树节点标题、展开符和图标左侧对齐稳定
- 补充侧边栏树横向滚动 CSS 回归测试
2026-05-09 16:08:49 +08:00
Syngnat
4ad074a90c 🐛 fix(window): 修复 Windows 最大化还原后文字变大
- 将缩放修正改为去抖检查,避免 focus/resize/visibilitychange 连续触发
- 最大化/还原改为显式切换窗口状态,减少重复 toggle 带来的抖动
- 补充 Windows 缩放修正相关工具测试
2026-05-09 16:08:31 +08:00
Syngnat
6a0f3f3a73 feat(sidebar): 支持当前表定位到左侧菜单
- 新增左侧工具栏定位按钮,支持按当前激活标签定位表/视图
- 抽离 sidebarLocate 工具函数,统一定位请求解析、路径匹配和 schema 分组
- 侧边栏接收定位事件后自动展开、选中并滚动到目标节点
- 移除 DataGrid 内部定位入口,补充定位与工具栏回归测试
2026-05-09 16:08:03 +08:00
Syngnat
ecdbe09c6c 🐛 fix(sidebar): 优化侧边栏拖拽热区并减少误触
- 将右侧边缘分隔条改为独立拖拽带
- 给树内容右侧预留缓冲区,避免拖宽时误点连接、库或表
- 拖拽期间锁定光标并禁用选中,提升拖动稳定性
- 保持原有宽度边界和拖拽反馈不变
2026-05-09 11:31:15 +08:00
Syngnat
8d8366c190 🐛 fix(query-editor): 修复 Oracle 星号查询定位列别名非法
- Oracle `SELECT *` 改写时使用合法源表别名 `gonavi_query_source`
- 让自动注入的 `ROWID` 绑定到源表别名,避免 `ORA-00911`
- 保留显式字段查询的 `ROWID` 追加逻辑
- 新增回归测试覆盖 `SELECT * FROM EDC_LOG` 的执行 SQL
- 校验生成 SQL 不再包含非法自动别名
2026-05-09 11:11:40 +08:00
Syngnat
faef619413 🐛 fix(mac-window): 修复查询替换框在 macOS 无法关闭
- 放行编辑器和输入控件内的 Escape 按键事件

- 保留 macOS 原生全屏下普通区域的 Escape 抑制逻辑

- 补充 Mac 窗口快捷键回归测试

Refs #433
2026-05-08 23:00:23 +08:00
Syngnat
0c2b112234 🐛 fix(duckdb): 修复 Windows 扩展下载平台不匹配问题
- 改用官方 duckdb.dll 动态库构建 Windows DuckDB driver-agent

- 安装、总包和本地导入流程同步携带运行时依赖

- 更新 DuckDB driver-agent revision 并补充安装链路测试

Refs #430
2026-05-08 22:50:03 +08:00
Syngnat
ff0661d285 🐛 fix(sqlserver): 修复新建数据库语法兼容问题
Refs #438

- SQL Server 创建数据库改用方言标识符

- 补齐 mssql/sql_server 别名归一

- 增加回归测试
2026-05-08 21:41:01 +08:00
Syngnat
5052c7fa6f 🐛 fix(doris): 修复数据库重命名与字段变更预览
Refs #439
- Doris 重命名数据库改走原生 ALTER DATABASE RENAME
- Doris 字段/注释预览改为兼容语法,移除 AFTER/FIRST 和无效 NONE
- 补充相关回归测试
2026-05-08 21:24:47 +08:00
Syngnat
ab420e3d24 🐛 fix(driver-manager): 统一驱动管理页明暗主题底色
Refs #440
2026-05-08 20:28:41 +08:00
Syngnat
1616ba8ae4 🐛 fix(DataGrid): 修复聚合查询结果无法复制的问题
- 为查询结果页新增独立复制入口
- 支持 CSV、JSON、Markdown 复制当前结果集
- 补充聚合列复制与按钮可点击回归测试
2026-05-06 21:47:16 +08:00
Syngnat
da9a76715a 🐛 fix(driver): 修复驱动代理校验与 DuckDB 表预览超时
- 校验可选 driver-agent revision,避免重装后复用旧代理
- DuckDB 表预览默认不再追加兜底 ORDER BY
- 优化 DuckDB 超时中断提示并补充回归测试
2026-05-06 19:32:55 +08:00
Syngnat
3c68325132 🐛 fix(oceanbase): 修复 Oracle 协议保存与连接链路
- 测试连接统一走 RPC 配置构造,确保 OceanBase Oracle 协议生效

- 保存连接时同步写入 oceanBaseProtocol 与 protocol 参数

- 编辑回显支持从显式字段、连接参数和 URI 恢复协议

- 双击连接时清理旧树缓存,避免复用 MySQL 协议子节点

- 补充 OceanBase 协议解析与缓存 key 隔离测试
2026-04-30 17:27:17 +08:00
Syngnat
5f9adcac37 🐛 fix(ai): 兼容 DeepSeek reasoning 内容响应
- 增加 reasoning_content 字段解析与前后端类型定义

- 兼容 DeepSeek 流式和非流式响应中的推理内容

- 统一 AI 消息 payload 映射,避免历史消息丢失推理内容

- 补充 OpenAI 兼容 Provider 与前端消息映射测试
2026-04-30 17:26:36 +08:00
Syngnat
d2dad75167 ♻️ refactor(oceanbase): 完善双协议连接链路
- 抽象 OceanBase 协议解析与运行态参数注入
- 复用 OracleDB 实现 OceanBase Oracle 租户连接能力
- 调整 DDL、schema、SQL 方言和数据源能力判断
- 补充协议优先级、缓存隔离和 RPC 参数测试
- 支持按指定 driver 自动生成 agent revision
2026-04-30 15:05:05 +08:00
Syngnat
98c62fd6bd 🎨 style(driver): 重做驱动管理页面布局与交互
- 页面结构:将驱动表格改为卡片列表,移除横向滚动依赖
- 信息展示:新增顶部状态统计,清晰区分全部、已启用、需重装、未启用
- 重装提示:将长原因文案收敛为摘要展示,并支持展开查看完整原因
- 操作优化:集中展示版本、进度、安装、重装、移除、本地导入和日志入口
- 响应式适配:窄屏下驱动卡片自动堆叠,避免内容挤压
2026-04-30 13:35:07 +08:00
Syngnat
7fd6d78c83 feat(driver): 新增 OceanBase 与 OpenGauss Agent 数据源
- 数据源支持:新增 OceanBase 与 OpenGauss optional driver-agent 实现
- 连接适配:复用 MySQL/PostgreSQL 兼容链路并补齐查询、DDL、同步能力
- 前端入口:补充连接表单、侧边栏、图标、SQL 方言和危险操作识别
- 驱动管理:更新 driver manifest、安装提示和 revision 自动生成链路
- 构建发布:支持多平台 driver-agent 打包并优化 release 构建失败提示
2026-04-30 13:13:01 +08:00
Syngnat
c92959f3e8 feat(connection): 支持多数据源额外连接参数配置
- 前端连接表单新增额外连接参数入口,支持 URI query 格式录入与解析回填
- MySQL 兼容驱动支持 JDBC 常见参数映射,修复 UTF-8 字符集与 serverTimezone 兼容问题
- 扩展 Oracle、PostgreSQL 兼容、SQL Server、ClickHouse、MongoDB、达梦、TDengine 参数合并
- 按不同驱动通道处理 DSN、URI、Options 与 Settings,避免统一透传导致连接异常
- 修复编辑已保存连接时解析无认证 URI 会清空已有账号密码的问题
- 补充连接参数透传、缓存隔离、DSN 合并与 URI 回填回归测试
2026-04-30 10:57:52 +08:00
Syngnat
c65e429072 🐛 fix(oracle): 兼容旧版本自动限行语法
- Oracle/Dameng 自动限行改为 ROWNUM 外层包裹
- 避免旧版本 Oracle 不支持 FETCH FIRST 导致 ORA-00933
- 保留尾部分号与注释,避免执行语句结构丢失
- 跳过 FOR UPDATE 语句自动包裹,避免改变锁语义
- 补充 Oracle/Dameng 自动限行回归测试
Refs #429
2026-04-30 08:33:24 +08:00
151 changed files with 9301 additions and 3618 deletions

View File

@@ -261,9 +261,26 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
prepare_duckdb_windows_library() {
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
echo "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
unzip -qo "$zip_path" -d "$lib_dir"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
echo "$lib_dir"
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
@@ -275,22 +292,38 @@ jobs:
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
BUILD_TAGS="$TAG"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
DUCKDB_LIB_DIR=""
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
fi
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
if [ -n "$DUCKDB_LIB_DIR" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
else
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \

View File

@@ -252,9 +252,26 @@ jobs:
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
prepare_duckdb_windows_library() {
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/duckdb.lib" ]; then
echo "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
unzip -qo "$zip_path" -d "$lib_dir"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
echo "$lib_dir"
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
@@ -266,22 +283,38 @@ jobs:
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
BUILD_TAGS="$TAG"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
DUCKDB_LIB_DIR=""
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
fi
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
if [ -n "$DUCKDB_LIB_DIR" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="-L${DUCKDB_LIB_DIR} -lduckdb" PATH="${DUCKDB_LIB_DIR}:$PATH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
else
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-tags "${BUILD_TAGS}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
@@ -534,6 +567,7 @@ jobs:
REQUIRED_FILES=(
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
"drivers/Windows/duckdb.dll"
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
"drivers/Linux/duckdb-driver-agent-linux-amd64"

View File

@@ -5,7 +5,11 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
DEFAULT_DRIVERS=(mariadb oceanbase doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss mongodb tdengine clickhouse)
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
DUCKDB_WINDOWS_SUPPORT_DLL="duckdb.dll"
usage() {
cat <<'EOF'
@@ -14,8 +18,8 @@ usage() {
选项:
--drivers <列表> 指定驱动列表逗号分隔例如kingbase,mongodb
--platform <GOOS/GOARCH>
目标平台,默认使用当前 Go 环境go env GOOS/GOARCH
--platform <目标> 目标平台current、all、GOOS/GOARCH,或逗号分隔列表
默认 current当前 Go 环境
--out-dir <目录> 输出目录根路径默认dist/driver-agents
--bundle-name <文件名> 驱动总包 zip 名称默认GoNavi-DriverAgents.zip
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
@@ -25,6 +29,8 @@ usage() {
./build-driver-agents.sh
./build-driver-agents.sh --drivers kingbase
./build-driver-agents.sh --platform windows/amd64 --drivers kingbase,mongodb
./build-driver-agents.sh --platform all
./build-driver-agents.sh --platform darwin/arm64,windows/amd64,linux/amd64
EOF
}
@@ -33,7 +39,8 @@ normalize_driver() {
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$name" in
doris|diros) echo "doris" ;;
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
open_gauss|open-gauss) echo "opengauss" ;;
mariadb|oceanbase|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|mongodb|tdengine|clickhouse)
echo "$name"
;;
*)
@@ -58,6 +65,136 @@ platform_dir_name() {
esac
}
current_platform() {
echo "$(go env GOOS)/$(go env GOARCH)"
}
append_platform() {
local candidate
candidate="$1"
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
return 0
fi
platforms+=("$candidate")
platform_seen="${platform_seen}${candidate}|"
}
normalize_platform() {
local value goos goarch platform_dir
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
current|"")
current_platform
;;
*/*)
goos="${value%%/*}"
goarch="${value##*/}"
platform_dir="$(platform_dir_name "$goos")"
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
return 1
fi
echo "$goos/$goarch"
;;
*)
return 1
;;
esac
}
zip_bundle() {
local bundle_zip_path="$1"
local bundle_stage_dir="$2"
local -a bundle_dirs=()
local dir
for dir in "$bundle_stage_dir"/*; do
[[ -d "$dir" ]] || continue
bundle_dirs+=("$(basename "$dir")")
done
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
echo "❌ 驱动总包 staging 目录为空。"
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "${bundle_dirs[@]}"
)
elif command -v python3 >/dev/null 2>&1; then
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
import os
import zipfile
from pathlib import Path
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
target = Path(os.environ["BUNDLE_ZIP_PATH"])
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in stage.rglob("*"):
if path.is_file():
zf.write(path, path.relative_to(stage).as_posix())
PY
else
echo "❌ 未找到 zip 或 python3无法生成驱动总包 zip。"
exit 1
fi
}
prepare_duckdb_windows_library() {
local cache_root="$1"
local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
local zip_path="$cache_root/libduckdb-windows-amd64.zip"
if [[ -f "$lib_dir/duckdb.dll" && -f "$lib_dir/duckdb.lib" ]]; then
printf '%s\n' "$lib_dir"
return 0
fi
mkdir -p "$lib_dir"
echo "⬇️ 下载 DuckDB Windows 官方动态库:$DUCKDB_WINDOWS_LIBRARY_URL" >&2
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"
elif command -v wget >/dev/null 2>&1; then
wget -q "$DUCKDB_WINDOWS_LIBRARY_URL" -O "$zip_path"
else
echo "❌ 未找到 curl 或 wget无法下载 DuckDB Windows 动态库。" >&2
return 1
fi
if command -v unzip >/dev/null 2>&1; then
unzip -qo "$zip_path" -d "$lib_dir"
elif command -v python3 >/dev/null 2>&1; then
DUCKDB_LIB_ZIP="$zip_path" DUCKDB_LIB_DIR="$lib_dir" python3 - <<'PY'
import os
import zipfile
zip_path = os.environ["DUCKDB_LIB_ZIP"]
target = os.environ["DUCKDB_LIB_DIR"]
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(target)
PY
else
echo "❌ 未找到 unzip 或 python3无法解压 DuckDB Windows 动态库。" >&2
return 1
fi
if [[ ! -f "$lib_dir/duckdb.dll" || ! -f "$lib_dir/duckdb.lib" ]]; then
echo "❌ DuckDB Windows 动态库包缺少 duckdb.dll 或 duckdb.lib。" >&2
return 1
fi
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.dll.a"
cp "$lib_dir/duckdb.lib" "$lib_dir/libduckdb.a"
printf '%s\n' "$lib_dir"
}
join_by_comma() {
local IFS=,
echo "$*"
}
driver_csv=""
target_platform=""
out_root="dist/driver-agents"
@@ -103,20 +240,6 @@ if ! command -v go >/dev/null 2>&1; then
exit 1
fi
if [[ -z "$target_platform" ]]; then
target_platform="$(go env GOOS)/$(go env GOARCH)"
fi
if [[ "$target_platform" != */* ]]; then
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH例如 darwin/arm64"
exit 1
fi
goos="${target_platform%%/*}"
goarch="${target_platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
declare -a drivers=()
if [[ -n "$driver_csv" ]]; then
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
@@ -130,69 +253,133 @@ if [[ -n "$driver_csv" ]]; then
else
drivers=("${DEFAULT_DRIVERS[@]}")
fi
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
output_dir="${out_root%/}/${platform_key}"
declare -a platforms=()
platform_seen="|"
if [[ -z "$target_platform" ]]; then
target_platform="current"
fi
IFS=',' read -r -a raw_platforms <<<"$target_platform"
for item in "${raw_platforms[@]}"; do
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
if [[ "$normalized_platform" == "all" ]]; then
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
append_platform "$default_platform"
done
continue
fi
normalized_platform="$(normalize_platform "$item")" || {
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
exit 1
}
append_platform "$normalized_platform"
done
if [[ ${#platforms[@]} -eq 0 ]]; then
echo "❌ 未指定有效目标平台。"
exit 1
fi
mkdir -p "$out_root"
out_root_abs="$(cd "$out_root" && pwd)"
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
cleanup() {
rm -rf "$bundle_stage_dir"
}
trap cleanup EXIT
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
bundle_zip_path="$output_dir_abs/$bundle_name"
if [[ ${#platforms[@]} -eq 1 ]]; then
single_platform="${platforms[0]}"
single_platform_key="${single_platform/\//-}"
single_output_dir="${out_root%/}/$single_platform_key"
mkdir -p "$single_output_dir"
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
else
bundle_zip_path="$out_root_abs/$bundle_name"
fi
declare -a built_assets=()
declare -a failed_drivers=()
declare -a skipped_drivers=()
echo "🚀 开始构建 optional-driver-agent"
echo " 平台:$goos/$goarch"
echo " 输出目录:$output_dir_abs"
echo " 平台:${platforms[*]}"
echo " 输出目录:$out_root_abs"
echo " 驱动列表:${drivers[*]}"
echo "🧭 生成 driver-agent revision 指纹"
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$target_platform"
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb仅支持 windows/amd64"
skipped_drivers+=("$driver")
continue
fi
for platform in "${platforms[@]}"; do
goos="${platform%%/*}"
goarch="${platform##*/}"
platform_key="${goos}-${goarch}"
platform_dir="$(platform_dir_name "$goos")"
output_dir="${out_root%/}/${platform_key}"
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
mkdir -p "$output_dir" "$bundle_platform_dir"
output_dir_abs="$(cd "$output_dir" && pwd)"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
echo ""
echo "🧭 生成 driver-agent revision 指纹:$platform"
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
set +e
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver"
failed_drivers+=("$driver")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
for driver in "${drivers[@]}"; do
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
echo "⚠️ 跳过 duckdb$platform 仅支持 windows/amd64"
skipped_drivers+=("duckdb($platform)")
continue
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
built_assets+=("$asset_name")
build_driver="$(build_driver_name "$driver")"
tag="gonavi_${build_driver}_driver"
build_tags="$tag"
asset_name="${driver}-driver-agent-${goos}-${goarch}"
if [[ "$goos" == "windows" ]]; then
asset_name="${asset_name}.exe"
fi
output_path="$output_dir_abs/$asset_name"
cgo_enabled=0
if [[ "$driver" == "duckdb" ]]; then
cgo_enabled=1
fi
duckdb_lib_dir=""
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" == "amd64" ]]; then
duckdb_lib_dir="$(prepare_duckdb_windows_library "$bundle_stage_dir")"
build_tags="$build_tags duckdb_use_lib"
fi
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tags=$build_tags, CGO_ENABLED=$cgo_enabled)"
set +e
if [[ -n "$duckdb_lib_dir" ]]; then
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
CGO_LDFLAGS="-L${duckdb_lib_dir} -lduckdb" PATH="${duckdb_lib_dir}:$PATH" \
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
else
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
fi
build_exit=$?
set -e
if [[ $build_exit -ne 0 ]]; then
echo "❌ 构建失败:$driver ($platform)"
failed_drivers+=("$driver($platform)")
if [[ "$strict_mode" == "true" ]]; then
exit $build_exit
fi
continue
fi
cp "$output_path" "$bundle_platform_dir/$asset_name"
if [[ -n "$duckdb_lib_dir" ]]; then
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL"
cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL"
built_assets+=("$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL")
fi
built_assets+=("$platform_dir/$asset_name")
done
done
if [[ ${#built_assets[@]} -eq 0 ]]; then
@@ -200,25 +387,11 @@ if [[ ${#built_assets[@]} -eq 0 ]]; then
exit 1
fi
rm -f "$bundle_zip_path"
if command -v zip >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
zip -qry "$bundle_zip_path" "$platform_dir"
)
elif command -v ditto >/dev/null 2>&1; then
(
cd "$bundle_stage_dir"
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
)
else
echo "❌ 未找到 zip/ditto无法生成驱动总包 zip。"
exit 1
fi
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
echo ""
echo "✅ 构建完成"
echo " 单文件输出目录:$output_dir_abs"
echo " 单文件输出目录:$out_root_abs"
echo " 驱动总包:$bundle_zip_path"
echo " 已构建:${built_assets[*]}"
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then

View File

@@ -46,6 +46,13 @@ RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
BUILD_FAILURES=()
record_build_failure() {
local target="$1"
BUILD_FAILURES+=("$target")
}
get_file_size_bytes() {
local target="$1"
if [ ! -f "$target" ]; then
@@ -159,6 +166,7 @@ package_macos_release() {
wails build -platform "darwin/${platform}" -clean -ldflags "$LDFLAGS"
if [ $? -ne 0 ]; then
echo -e "${RED} ❌ macOS ${platform} 构建失败。${NC}"
record_build_failure "macOS ${platform}"
return
fi
@@ -213,6 +221,7 @@ if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
else
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
record_build_failure "Windows amd64"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
@@ -230,6 +239,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
record_build_failure "Windows arm64"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
@@ -259,6 +269,7 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
record_build_failure "Linux amd64"
fi
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
# macOS 或其他系统,尝试交叉编译
@@ -279,6 +290,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
else
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
record_build_failure "Linux amd64"
fi
unset CC CXX CGO_ENABLED
else
@@ -304,6 +316,7 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
record_build_failure "Linux arm64"
fi
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
# 交叉编译
@@ -324,6 +337,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
else
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
record_build_failure "Linux arm64"
fi
unset CC CXX CGO_ENABLED
else
@@ -357,12 +371,21 @@ else
fi
echo ""
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
else
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
fi
ls -lh "$DIST_DIR"
echo ""
echo -e "${GREEN}📋 支持的平台:${NC}"
echo " • macOS (Intel/Apple Silicon): .dmg"
echo " • macOS (Intel/Apple Silicon): .zip"
echo " • Windows (x64/ARM64): .exe"
echo " • Linux (x64/ARM64): .tar.gz"
echo ""
echo -e "${YELLOW}💡 提示Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
exit 1
fi

View File

@@ -0,0 +1,12 @@
//go:build gonavi_oceanbase_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "oceanbase"
agentDatabaseFactory = func() db.Database {
return &db.OceanBaseDB{}
}
}

View File

@@ -0,0 +1,12 @@
//go:build gonavi_opengauss_driver
package main
import "GoNavi-Wails/internal/db"
func init() {
agentDriverType = "opengauss"
agentDatabaseFactory = func() db.Database {
return &db.OpenGaussDB{}
}
}

View File

@@ -7,6 +7,12 @@
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"oceanbase": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/oceanbase"
},
"doris": {
"engine": "go",
"version": "1.9.3",
@@ -61,6 +67,12 @@
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"opengauss": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/opengauss"
},
"mongodb": {
"engine": "go",
"version": "2.5.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,483 +0,0 @@
# JVM 缓存可视化编辑设计
## 1. 背景
当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式:
- 为特定业务临时补管理接口
- 重启应用并依赖重新初始化
这两种方式都存在明显问题:
- 临时接口会污染业务代码,并带来后续维护和权限风险
- 重启应用成本高,且不适合用于精确修复单个缓存项
GoNavi 现有已具备三类可复用基础:
- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx``frontend/src/components/Sidebar.tsx``frontend/src/components/TabManager.tsx`
- 独立运行时能力样板Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象
- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx``frontend/src/components/QueryEditor.tsx``frontend/src/components/LogPanel.tsx`
因此GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。
## 2. 目标
- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象
- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换
- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录
- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行
- 尽量避免强依赖 `-javaagent` 或运行时动态 attach适配企业内对生产进程注入普遍敏感的环境
## 3. 非目标
- 不承诺“任意 JVM 内任意对象均可直接读写”
- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测
- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象
- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入
- 不绕过目标服务现有认证、鉴权和网络边界
## 4. 需求与约束
### 4.1 需求清单
- 统一配置 JVM 连接
- 探测当前 JVM 支持的接入模式与可用能力
- 浏览缓存空间、管理对象和受控操作
- 查看值快照与元数据
- 执行受控修改,并提供 before/after 预览
- 将操作结果写入审计记录
- 支持 AI 对资源结构和修改方案进行分析
### 4.2 已确认约束
- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力”
- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作
## 5. 现状分析
### 5.1 GoNavi 架构启示
- `internal/db/database.go` 面向标准化数据源 CRUD适合 SQL 类资源
- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线”
- `frontend/src/components/RedisViewer.tsx``frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板
- `frontend/src/components/AIChatPanel.tsx``frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力
### 5.2 结论
JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。
## 6. 方案比较
### 方案 A单一路径通用 Agent
- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力
- 优点:
- 理论能力上限最高
- 可覆盖更多自研缓存和深层对象
- 缺点:
- 与已知企业约束直接冲突
- 风险最高,部署与安全成本高
- 与首期产品化目标不匹配
### 方案 B多接入模式 + 能力协商
- 描述:统一做 `JVM Connector`,底层同时支持 `JMX``Management Endpoint``Agent`
- 优点:
- 产品形态统一
- 能根据目标 JVM 能力降级
- 可先做低风险路径,后续再扩展高级模式
- 缺点:
- 不同模式能力不一致UI 与权限模型更复杂
### 方案 C只做业务侧管理端点
- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入
- 优点:
- 结构最稳AI 最容易接入
- 权限、审计、预览、回滚最好做
- 缺点:
- 不满足“尽量通用”的产品定位
- 无法覆盖仅开放 JMX 的存量系统
## 7. 选型
采用方案 B。当前已落地
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent
## 8. 目标架构
### 8.1 总体结构
新增统一的 `JVM Connector` 子系统,分为五层:
- `Connection Layer`
- 新增 `jvm` 连接类型
- 保存目标地址、认证、允许模式、首选模式、环境标签等配置
- `Capability Layer`
- 建立连接后探测当前支持的 provider 与能力矩阵
- `Provider Layer`
- `JMX Provider`
- `Management Endpoint Provider`
- `Agent Provider`(预留)
- `Resource Layer`
- 将不同来源统一映射为结构化资源
- `Guard Layer`
- 统一负责预览、确认、审计、回读验证、错误归一化
### 8.2 设计原则
- UI 统一,协议多态
- 读写分离,修改必须经过 Guard Layer
- provider 不得自行绕过权限与审计链路
- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口
## 9. Provider 设计
### 9.1 JMX Provider
- 负责:
- 建立 JMX/RMI 连接
- 发现 MBean
- 读取属性
- 调用白名单操作
- 写入允许修改的白名单属性
- 适用场景:
- 目标 JVM 已开放 JMX
- 缓存或管理对象已暴露为 MBean
- 特点:
- 低侵入、标准化、可落地
- key/value 级资源能力通常有限
### 9.2 Management Endpoint Provider
- 负责:
- 调用业务服务暴露的 GoNavi 管理端点或 Starter
- 返回结构化缓存资源、元数据和受控动作
- 提供修改预览与回滚信息
- 适用场景:
- 业务方愿意接入轻量 Starter/管理端点
- 需要更强的 key/value 级浏览与修改能力
- 特点:
- 最适合产品化和 AI 协同
- 权限、脱敏、审计、回滚最容易做
### 9.3 Agent Provider
- 负责:
- 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口
- 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力
- 定位:
- 高级模式
- 不默认启用
- 需要目标 Java 服务以 `-javaagent` 方式显式启动
## 10. 统一资源模型
建议统一抽象以下资源:
- `runtime`
- 目标 JVM 实例
- `cacheNamespace`
- 缓存空间,如某个 CacheManager 下的 cacheName
- `cacheEntry`
- 具体缓存项 key/value
- `managedBean`
- 可读写的托管对象或 MBean
- `operation`
- 受控操作,如 `evict``put``refresh``clear`
- `auditRecord`
- 每次读写与 AI 建议的审计记录
统一资源模型要求:
- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别
- 值快照必须区分原始值、展示值和可编辑值
- 资源定位信息必须可写入审计
## 11. AI 协同设计
### 11.1 AI 的角色
AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。
AI 可以:
- 解释缓存/Bean 的结构和当前状态
- 生成筛选条件和定位建议
- 生成结构化修改计划
- 生成风险说明和回滚建议
- 对执行前后结果做对比分析
AI 不应默认做:
- 直接执行 JVM 修改
- 自由生成任意脚本并直写内存
- 绕过人工确认直接调用 provider
### 11.2 AI 输出形态
AI 不直接输出脚本,而输出结构化变更计划,例如:
```json
{
"targetType": "cacheEntry",
"selector": {
"namespace": "userSessionCache",
"key": "user:1001"
},
"action": "updateValue",
"payload": {
"format": "json",
"value": {
"status": "ACTIVE"
}
},
"reason": "修复错误缓存态"
}
```
### 11.3 AI 执行链路
1. AI 读取结构化上下文
2. AI 产出结构化变更计划
3. Guard Layer 校验目标资源、能力和权限
4. UI 展示修改预览与风险提示
5. 用户确认
6. provider 执行
7. 系统回读验证并写审计
### 11.4 一期 AI 边界
- 支持 AI 分析资源
- 支持 AI 生成修改计划
- 不默认支持 AI 自动执行修改
## 12. 页面与交互设计
### 12.1 连接层
`ConnectionModal` 中新增 `JVM` 类型,建议配置:
- 连接名称
- 目标地址/端口
- 认证信息
- 允许模式列表
- 首选模式
- 环境标签DEV/UAT/PROD
- 默认权限级别(只读/读写)
### 12.2 侧边栏
展示结构:
- 连接
- 模式能力
- 资源类型
- `cacheNamespace` / `managedBean` / `operation`
每个连接或节点显示能力徽标,例如:
- `JMX`
- `Endpoint`
- `Agent`
- `只读`
- `可写`
### 12.3 主工作区 Tab
建议新增以下 Tab 类型:
- `概览`
- `资源浏览`
- `值检查器`
- `修改预览`
- `AI 助手`
- `审计记录`
### 12.4 标准操作流
1. 用户连接 JVM
2. 系统探测 provider 能力
3. 用户选择资源并读取快照
4. 用户手工修改或让 AI 生成计划
5. 系统生成 before/after 预览
6. 用户二次确认
7. provider 执行
8. 系统回读验证
9. 写入审计与操作日志
## 13. 权限与审计
### 13.1 权限模型
权限建议分四层:
- `连接级`
- 决定默认 `readonly` / `readwrite`
- `模式级`
- 决定某 provider 支持哪些动作
- `资源级`
- 某些资源永远只读
- `环境级`
- `PROD` 默认强制二次确认,禁用 AI 自动执行
### 13.2 审计要求
JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。
建议记录:
- 连接 ID / 名称
- provider 类型
- 资源定位信息
- 动作类型
- 修改原因
- AI 是否参与
- 执行前摘要
- 执行后摘要
- 结果状态
- 耗时
- 错误信息
建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`
## 14. 错误处理与兼容性边界
### 14.1 错误分层
- `连接层失败`
- 认证失败、证书失败、JMX/RMI 不通、端点 401/403
- `能力层失败`
- 连接成功但不支持列 key、写值或批量操作
- `执行层失败`
- 资源不存在、值格式非法、provider 拒绝写入
- `验证层失败`
- 执行返回成功但回读校验不一致
所有错误都应显式标明是哪个 provider、哪一层失败避免泛化为“修改失败”。
### 14.2 首期兼容性承诺
优先承诺以下边界:
- Java 8 / 11 / 17 / 21
- Spring Boot 服务优先
- JMX 标准 MBean
- Management Endpoint 模式下优先支持:
- Caffeine
- Ehcache
- Guava Cache
- Spring Cache 抽象下可枚举缓存
- 接入 GoNavi Starter 的自研缓存
- 值类型首期优先:
- string
- number
- boolean
- JSON object / JSON array
- map / list 的结构化展示
### 14.3 首期不承诺
- 任意 Java 对象深度反射编辑
- 无类型信息的二进制对象直接改写
- 跨 classloader 任意对象定位
- 生产环境默认开放批量危险写入
## 15. MVP 分期
### Phase 1连接与只读探测
- JVM 连接类型
- JMX / Endpoint 能力探测
- 资源树浏览
- 值查看
- 概览页与能力徽标
- 不开放写入
### Phase 2受控修改与审计
- 白名单资源写入
- before/after 预览
- 二次确认
- 审计日志
- 回读验证
- 环境级保护策略
### Phase 3AI 协同
- AI 解释资源
- AI 生成修改计划
- AI 风险分析
- AI 回滚建议
- 仍默认不允许 AI 自动执行
### Phase 4高级模式
- Agent Provider
- 预埋 Java Agent 的 runtime 资源治理能力
- 仅在特殊环境启用
## 16. 验证策略
### 16.1 功能验证
- 能连接 JMX 目标
- 能连接 Endpoint 目标
- 能列出缓存空间
- 能查看 key/value
- 能完成受控修改并回读成功
### 16.2 兼容性验证
- Java 8 / 11 / 17 / 21
- 本地、容器、K8s 内网场景
- 开启认证 / 不开启认证
- 仅 JMX、仅 Endpoint、双模式并存
### 16.3 安全验证
- 只读连接无法写入
- `PROD` 环境必须二次确认
- AI 无法绕过人工确认直接执行
- 审计日志完整记录修改链路
### 16.4 稳定性验证
- 目标 JVM 不可达时 UI 不假死
- 资源树大数量时支持分页或懒加载
- 回读失败时标识“不确定状态”
- provider 超时、部分失败、降级路径清晰
## 17. 风险与缓解
### 17.1 风险
- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改”
- JMX 模式的 key/value 级能力可能明显不足
- 管理端点模式需要业务接入,推广成本高于纯客户端方案
- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本
### 17.2 缓解
- 在 UI 中显式展示能力矩阵和当前 provider 来源
- 所有修改都强制经过预览、确认与审计
- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力”
- Agent 仅作为高级扩展位,避免污染 MVP 边界
## 18. 最终结论
JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。
推荐结论如下:
- 新增独立的 `JVM Connector` 子系统
- 首期支持 `JMX + Management Endpoint`
- `Agent` 作为高级可选模式交付
- AI 首期支持分析与生成修改计划,不默认开放自动执行
- 所有修改必须经过预览、确认、审计和回读验证
这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。

View File

@@ -1,73 +0,0 @@
# 需求进度追踪 - AI聊天发送快捷键
## 1. 需求摘要
- 需求名称AI 聊天发送快捷键
- 提出日期2026-04-28
- 负责人Claude Code
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
## 2. 范围与验收
- 范围工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
- 验收标准工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送Shift+Enter 始终换行;输入法 composing 状态不发送刷新后快捷键保持AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
- [x] 阶段 2影响分析影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
- [x] 阶段 3方案设计采用共享 `shortcutOptions` actionAI 输入框局部消费,不走全局快捷键执行器。
- [x] 阶段 4实施计划计划已按用户反馈调整为工具中心统一方案。
- [x] 阶段 5实现与自检目标红灯测试已补充新方案核心实现已完成。
- [x] 阶段 6评审与交付已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
- [ ] 阶段 7发布与观察发布后观察用户输入法场景反馈。
## 4. 变更清单
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
- 进行中:无。
- 待处理:发布后观察输入法场景反馈。
## 5. 风险与阻塞
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
- 阻塞:无。
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter普通 Enter 不再触发发送AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
## 6. 决策记录
- 决策 1AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
- 决策 2`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
- 决策 3AI 发送快捷键允许默认无修饰键 Enter但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
- 决策 4输入法 composing 状态始终不发送。
- 决策 5AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
- 决策 6AI 输入框命中发送快捷键后同时执行 `preventDefault``stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
## 7. 验证记录
- 验证项:初版两档下拉方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
- 验证项:工具中心统一方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`
- 验证项:工具中心统一方案目标绿灯测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`6 passed`src/components/ai/AIChatInput.notice.test.tsx`2 passed`src/store.test.ts`10 passed
- 验证项:代码审查反馈红灯测试。
- 结果:已确认旧实现失败。
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
- 验证项:代码审查反馈修复后目标测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`3 files passed22 tests passed
- 验证项:浏览器手工验证。
- 结果:已通过。
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
- 验证项:前端全量测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run`88 files passed421 tests passed
- 验证项diff 空白检查。
- 结果:已通过。
- 证据:`git diff --check` 无输出。
- 验证项:生产构建。
- 结果:已通过。
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
## 8. 下一步
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
- 负责人Claude Code

View File

@@ -1,246 +0,0 @@
# 需求进度追踪 - JVM缓存可视化编辑
## 1. 需求摘要
- 需求名称JVM缓存可视化编辑
- 提出日期2026-04-22
- 负责人Codex
- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本
- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行
## 2. 范围与验收
- 范围:
- 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测
- 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路
- 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力
- 交付 Guard、审计、来源标记、真实集成测试与构建验证
- 验收标准:
- 可以在 GoNavi 中新增 JVM 连接并完成连接测试
- 可以按资源树浏览 JVM 对象并查看结构化快照
- 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计
- 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行
- 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归
- 依赖与约束:
- 需复用 GoNavi 当前 Wails + React + driver-agent 架构
- 新能力不得破坏现有数据库/Redis 工作流
- 高风险写操作必须具备明确鉴权、审计与回滚思路
- JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件
## 3. 里程碑与进度
- [x] 阶段 1需求澄清完成
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [x] 阶段 5实现与自检完成Task 1 至 Task 7 已完成,代码与构建回归通过)
- [x] 阶段 6评审与交付完成已完成契约复核、上下文隔离修正、文档回填与交付检查
- [ ] 阶段 7发布与观察未开始
## 4. 变更清单
- 已完成:
- 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制
- 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用
- 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层
- 用户已确认目标方向为“通用型 JVM 接入”
- 用户已确认升级到完整模式,开始高风险架构评估
- 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach
- 已形成 JVM 缓存可视化编辑正式设计文档
- 已形成 JVM Connector MVP 正式实施计划文档
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 已完成 Task 6AI 结构化变更计划
- 已完成 Task 7全量回归、文档回填与交付检查
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
- 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行
- 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为
- 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke`
- 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac`
- 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿
- 已完成 AI 参与来源写入 JVM 审计记录审计页可区分“手工”与“AI 辅助”
- 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent
- 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行
- 已完成 JVM 收口优化Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义
- 待处理:
- 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项
## 5. 风险与阻塞
- 风险:
- 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染
- 不同缓存框架Caffeine/Ehcache/Guava/自研 Map缺少统一标准协议
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
- 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源JMX 复杂 target 仍建议优先使用 `resourcePath`
- JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java`
- Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent并额外暴露管理端口
- JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用
- JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
- 阻塞:
- 当前开发收口阶段无新增阻塞
- 缓解措施:
- 优先收敛到标准接入面JMX / Spring Actuator / Java Agent 三选一)
- 首期只支持白名单对象类型与受控写操作
- 要求变更审计、预览、确认与失败回滚路径
- 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入”
- JMX helper 改为内嵌 runtime jar默认写入用户缓存目录必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath
- 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行
## 6. 决策记录
- 决策 1先做可行性评估与方案设计不直接进入实现
- 决策 2默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
- 决策 3已按完整模式推进后续方案将优先评估通用 Agent 路径是否成立
- 决策 4由于目标服务大概率不允许 agent/attach后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
- 决策 9JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java`
- 决策 10Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach
## 7. 验证记录
- 验证项:
- GoNavi 驱动代理机制核查
- GoNavi 现有 Redis/编辑器/UI 复用能力核查
- JVM Connector 正式设计文档自检
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7Java Endpoint fixture 真实集成验证
- Task 7JMX helper 内嵌分发与运行时缓存验证
- Task 7Agent provider 与真实 Java Agent 集成验证
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
- 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节
- 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测
- Task 1 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
- Task 2 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
- 已完成 Task 5后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 已完成 Task 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- 已完成 Task 7Java Endpoint fixture可真实验证 `resources / value / preview / apply` 四个 endpoint contract
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过
- 已完成 Task 7JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过
- 已完成 Task 7Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试
- `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过11 tests
- `go test ./... -count=1` 通过
- `cd frontend && npm test -- --run` 通过61 files259 tests
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
- `wails build -clean` 通过,成功生成 macOS 应用包
- 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server``baseURL is required` 这类原始报错
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过Endpoint 能力探测只读语义回归)
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归)
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过JVM 资源页布局回归)
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归)
- `cd frontend && npm run build` 再次通过
- `wails build -clean` 再次通过,成功生成最新可验收桌面包
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
- `frontend/src/components/RedisViewer.tsx`
- `frontend/src/components/RedisCommandEditor.tsx`
- `frontend/src/components/QueryEditor.tsx`
- `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md`
- `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`
- `internal/connection/types.go`
- `internal/jvm/types.go`
- `internal/jvm/config.go`
- `internal/jvm/config_test.go`
- `frontend/src/types.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `go test ./internal/jvm -count=1`
- `go test ./...`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- --run`
- `cd frontend && npm run build`
- `internal/jvm/provider.go`
- `internal/jvm/jmx_provider.go`
- `internal/jvm/http_provider.go`
- `internal/jvm/http_provider_test.go`
- `internal/jvm/jmx_helper.go`
- `internal/jvm/jmx_helper_test.go`
- `internal/jvm/provider_contract_test.go`
- `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar`
- `internal/jvm/jmxhelper_assets/README.md`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java`
- `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java`
- `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/wailsjs/go/app/App.d.ts`
- `frontend/wailsjs/go/app/App.js`
- `frontend/wailsjs/go/models.ts`
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `wails build -clean`
- `frontend/src/components/DatabaseIcons.tsx`
- `frontend/src/components/ConnectionModal.tsx`
- `frontend/src/utils/jvmRuntimePresentation.ts`
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm run build`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/JVMOverview.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/components/jvm/JVMModeBadge.tsx`
- `frontend/src/store.ts`
- `frontend/src/types.ts`
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm run build`
- `internal/jvm/guard.go`
- `internal/jvm/guard_test.go`
- `internal/jvm/audit_store.go`
- `internal/jvm/audit_store_test.go`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/JVMAuditViewer.tsx`
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `cd frontend && npm run build`
- `frontend/src/utils/jvmAiPlan.ts`
- `frontend/src/utils/jvmAiPlan.test.ts`
- `frontend/src/components/AIChatPanel.tsx`
- `frontend/src/components/ai/AIMessageBubble.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/types.ts`
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
- `go test ./... -count=1`
- `go test ./internal/jvm -run 'TestHTTPProvider' -count=1`
- `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1`
- `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx`
- `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts`
- `cd frontend && npm test -- --run`
- `wails build -clean`
## 8. 下一步
- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入
- 负责人Codex

View File

@@ -1,24 +0,0 @@
# SQL 方言适配需求进度追踪
## 背景
- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。
- GitHub 相关问题Refs #402(金仓字段类型/DDL 方言、Refs #409Oracle 删除数据 DATE 字面量)。
## 范围
- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。
- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。
- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。
- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。
- Oracle/Dameng 数据复制/删除 SQLDATE/TIMESTAMP 字段使用 Oracle 时间构造函数。
## 验证
- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts`
- `npm run build`
## 风险与后续
- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER并用中文注释阻止 MySQL 专属子句外溢。
- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。

View File

@@ -1,71 +0,0 @@
# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互
## 1. 需求摘要
- 需求名称:发布脚本测试版号与 Mac 打包无交互
- 提出日期2026-04-24
- 负责人Codex
- 目标:
- `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。
- `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。
- 非目标:
- 不调整 GitHub Release 工作流。
- 不修改正式发布 tag 版本策略。
## 2. 范围与验收
- 范围:
- 发布脚本 `build-release.sh`
- 版本解析逻辑 `internal/app/version.go`
- 共享测试版号文件
- 验收标准:
- `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。
- 本地开发态版本显示与发布脚本默认版本号一致。
- 保留环境变量覆盖版本号能力。
- 依赖与约束:
- 维持现有 Windows/Linux 构建逻辑不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认去掉 DMG 排版,统一测试版号来源
- [x] 阶段 2影响分析锁定 `build-release.sh``internal/app/version.go`
- [x] 阶段 3方案设计共享 `version/dev-version.txt`macOS 改 ZIP 打包
- [x] 阶段 4实施计划先补版本回归测试再改实现
- [ ] 阶段 5实现与自检
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
## 4. 变更清单
- 已完成:
- 新增共享测试版号文件。
- 新增版本回归测试。
- 改造发布脚本 macOS 打包为无交互 ZIP。
- 进行中:
- 自检验证。
- 待处理:
- 无。
## 5. 风险与阻塞
- 风险:
- 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。
- 阻塞:
- 无。
- 缓解措施:
- 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。
## 6. 决策记录
- 决策 1`version/dev-version.txt` 作为本地开发/测试共享版本号来源。
- 决策 2发布脚本的 macOS 产物改为 ZIP避免 `create-dmg` 的 Finder 交互。
## 7. 验证记录
- 验证项:
- 版本回归测试
- 发布脚本语法检查
- 发布脚本运行输出
- 结果:
- 进行中
- 证据(日志/截图/链接):
- 待补充
## 8. 下一步
- 下一步行动:
- 跑通回归测试和脚本验证,确认输出产物与版本号
- 负责人:
- Codex

View File

@@ -1 +1 @@
d0464f9da25e9356e61652e638c99ffe
d0464f9da25e9356e61652e638c99ffe

View File

@@ -63,6 +63,25 @@ body, #root {
.sidebar-tree-scroll-shell .ant-tree .ant-tree-node-content-wrapper {
width: auto !important;
min-width: 0;
display: flex !important;
align-items: center;
gap: 8px;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-switcher {
flex: 0 0 24px;
width: 24px;
min-width: 24px;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-iconEle {
flex: 0 0 16px;
width: 16px;
min-width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-end: 0;
}
.sidebar-tree-scroll-shell .ant-tree .ant-tree-title {
@@ -326,35 +345,194 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
color: #fff !important;
}
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
.driver-manager-table .ant-table-sticky-scroll {
display: none !important;
.driver-manager-modal .ant-modal-body {
background: var(--ant-color-bg-layout, #f5f5f5);
}
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
overflow-x: auto !important;
-ms-overflow-style: none;
scrollbar-width: none;
}
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
height: 0 !important;
}
.driver-manager-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.driver-manager-footer {
width: 100%;
.driver-manager-shell {
display: flex;
flex-direction: column;
gap: 14px;
}
.driver-manager-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: stretch;
padding: 14px 16px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-heading {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.driver-manager-stats {
display: grid;
grid-template-columns: repeat(4, minmax(64px, 1fr));
gap: 8px;
min-width: 360px;
}
.driver-manager-stat {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
min-height: 58px;
padding: 8px 10px;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: rgba(5, 5, 5, 0.02);
}
.driver-manager-stat span:first-child {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
}
.driver-manager-stat-warning span:first-child {
color: #d48806;
}
.driver-manager-directory-panel {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
}
.driver-manager-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.driver-manager-search {
min-width: 280px;
flex: 1 1 360px;
}
.driver-manager-toolbar-actions {
justify-content: flex-end;
}
.driver-manager-list-head {
display: flex;
justify-content: space-between;
gap: 12px;
min-height: 24px;
}
.driver-manager-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.driver-manager-card {
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: var(--ant-color-bg-container, #fff);
overflow: hidden;
}
.driver-manager-card-warning {
border-color: rgba(250, 173, 20, 0.35);
}
.driver-manager-card-ready {
border-color: rgba(82, 196, 26, 0.22);
}
.driver-manager-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 38%);
gap: 16px;
padding: 16px;
}
.driver-manager-card-info {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-title-row,
.driver-manager-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.driver-manager-driver-name {
font-size: 16px;
}
.driver-manager-meta-row {
row-gap: 4px;
}
.driver-manager-update-note {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(250, 173, 20, 0.1);
}
.driver-manager-note-text,
.driver-manager-muted-message {
margin-bottom: 0 !important;
}
.driver-manager-muted-message {
color: var(--ant-color-text-secondary);
}
.driver-manager-card-controls {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.driver-manager-control-block {
display: grid;
gap: 4px;
}
.driver-manager-control-label,
.driver-manager-small-text {
font-size: 12px;
}
.driver-manager-version-control {
display: grid;
gap: 4px;
}
.driver-manager-version-lock {
line-height: 24px;
}
.driver-manager-card-actions {
justify-content: flex-end;
}
.driver-manager-card-actions .ant-btn {
min-width: 88px;
}
.driver-manager-footer-actions {
@@ -363,17 +541,20 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
justify-content: flex-end;
}
.driver-manager-hscroll {
width: 100%;
height: 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-gutter: stable;
background: transparent;
}
@media (max-width: 900px) {
.driver-manager-header,
.driver-manager-card-main {
grid-template-columns: 1fr;
}
.driver-manager-hscroll-inner {
height: 1px;
.driver-manager-stats {
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.driver-manager-card-actions {
justify-content: flex-start;
}
}
.security-update-action-btn.ant-btn,

View File

@@ -2,10 +2,11 @@
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
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 } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
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';
import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
@@ -19,7 +20,7 @@ import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModa
import { DEFAULT_APPEARANCE, useStore } from './store';
import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay';
import { DENSITY_OPTIONS, sanitizeDataTableDensity } from './utils/dataGridDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
import { resolveAboutDisplayVersion } from './utils/appVersionDisplay';
@@ -64,12 +65,15 @@ import {
ShortcutAction,
canRecordShortcutForAction,
eventToShortcut,
findReservedConflicts,
getShortcutDisplay,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
splitConflictsByContext,
type ConflictInfo,
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './utils/windowStateUi';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
@@ -630,6 +634,7 @@ function App() {
let lastRatio = Number(window.devicePixelRatio) || 1;
let lastFixAt = 0;
let activationTimer: number | null = null;
let resizeTimer: number | null = null;
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
@@ -669,12 +674,12 @@ function App() {
}
try {
WindowToggleMaximise();
await wait(48);
WindowToggleMaximise();
await wait(64);
WindowUnmaximise();
await wait(96);
WindowMaximise();
await wait(96);
} catch (e) {
console.warn("Wails Window maximise toggle unavailable in fixWindowScaleIfNeeded", e);
console.warn("Wails Window maximise restore unavailable in fixWindowScaleIfNeeded", e);
}
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
@@ -687,7 +692,7 @@ function App() {
return;
}
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
if (!shouldApplyWindowsScaleFix(reason, hasViewportScaleDrift)) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
@@ -718,6 +723,24 @@ function App() {
void fixWindowScaleIfNeeded('ratio-change');
};
const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {
if (cancelled) return;
const delayMs = resolveWindowsScaleCheckDelayMs(trigger);
if (delayMs <= 0) {
checkDevicePixelRatio();
return;
}
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}
resizeTimer = window.setTimeout(() => {
resizeTimer = null;
if (cancelled) return;
checkDevicePixelRatio();
}, delayMs);
};
const scheduleActivationFix = () => {
if (cancelled) return;
if (activationTimer !== null) {
@@ -732,7 +755,7 @@ function App() {
const handleWindowFocus = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleDevicePixelRatioCheck('focus');
scheduleActivationFix();
};
@@ -741,18 +764,22 @@ function App() {
if (document.visibilityState !== 'visible') {
return;
}
checkDevicePixelRatio();
scheduleDevicePixelRatioCheck('visibilitychange');
scheduleActivationFix();
};
const handlePageShow = () => {
if (cancelled) return;
checkDevicePixelRatio();
scheduleDevicePixelRatioCheck('pageshow');
scheduleActivationFix();
};
const handleWindowResize = () => {
scheduleDevicePixelRatioCheck('resize');
};
const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
window.addEventListener('resize', checkDevicePixelRatio);
window.addEventListener('resize', handleWindowResize);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -762,8 +789,11 @@ function App() {
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}
window.clearInterval(pollTimer);
window.removeEventListener('resize', checkDevicePixelRatio);
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
@@ -1860,7 +1890,20 @@ function App() {
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const shortcutConflictMap = useMemo(() => {
const map: Partial<Record<ShortcutAction, ConflictInfo[]>> = {};
for (const action of SHORTCUT_ACTION_ORDER) {
const binding = shortcutOptions[action];
if (!binding?.enabled || !binding.combo) continue;
const conflicts = findReservedConflicts(normalizeShortcutCombo(binding.combo));
if (conflicts.length > 0) {
map[action] = conflicts;
}
}
return map;
}, [shortcutOptions]);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
@@ -2202,9 +2245,15 @@ function App() {
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
return;
}
await WindowToggleMaximise();
const isMaximised = await WindowIsMaximised().catch(() => false);
if (isMaximised) {
WindowUnmaximise();
} else {
WindowMaximise();
}
await new Promise((resolve) => window.setTimeout(resolve, 96));
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
void emitWindowDiagnostic('action:titlebar-toggle:after-set-maximise-state');
} catch (_) {
// ignore
}
@@ -2222,10 +2271,37 @@ function App() {
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
const rafRef = React.useRef<number | null>(null);
const ghostRef = React.useRef<HTMLDivElement>(null);
const sidebarDragBodyStyleRef = React.useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
const sidebarResizeHandleWidth = Math.max(16, Math.round(16 * effectiveUiScale));
const restoreSidebarDragBodyStyles = () => {
if (!sidebarDragBodyStyleRef.current || typeof document === 'undefined') {
sidebarDragBodyStyleRef.current = null;
return;
}
const previous = sidebarDragBodyStyleRef.current;
document.body.style.cursor = previous.cursor;
document.body.style.userSelect = previous.userSelect;
(document.body.style as any).WebkitUserSelect = previous.webkitUserSelect;
sidebarDragBodyStyleRef.current = null;
};
const handleSidebarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (typeof document !== 'undefined') {
sidebarDragBodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect,
webkitUserSelect: (document.body.style as any).WebkitUserSelect || '',
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
(document.body.style as any).WebkitUserSelect = 'none';
}
if (ghostRef.current) {
ghostRef.current.style.left = `${sidebarWidth}px`;
@@ -2271,6 +2347,7 @@ function App() {
if (ghostRef.current) {
ghostRef.current.style.display = 'none';
}
restoreSidebarDragBodyStyles();
sidebarDragRef.current = null;
document.removeEventListener('mousemove', handleSidebarMouseMove);
@@ -2365,13 +2442,29 @@ function App() {
};
}, []);
useEffect(() => {
const handleOpenSnippetSettingsEvent = () => {
setIsSnippetModalOpen(true);
};
window.addEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
};
}, []);
useEffect(() => {
if (!isMacRuntime || !useNativeMacWindowControls) {
return;
}
const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) {
if (!shouldSuppressMacNativeEscapeExit(
isMacRuntime,
useNativeMacWindowControls,
useStore.getState().windowState === 'fullscreen',
event,
{ isEditableTarget: isEditableElement(event.target) },
)) {
return;
}
event.preventDefault();
@@ -2483,6 +2576,17 @@ function App() {
return;
}
const reservedConflicts = findReservedConflicts(normalizedCombo);
if (reservedConflicts.length > 0) {
const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts);
if (hasMonaco) {
void message.info(`已覆盖编辑器「${monacoLabels}」默认快捷键`, 4);
}
if (hasOther) {
void message.warning(`${otherContexts}${otherLabels}」冲突,可能失效`, 4);
}
}
updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true });
setCapturingShortcutAction(null);
};
@@ -2660,7 +2764,7 @@ function App() {
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, position: 'relative' }}>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58, paddingRight: sidebarResizeHandleWidth, position: 'relative' }}>
<div style={{ height: '100%', opacity: connectionWorkbenchState.ready ? 1 : 0.72, pointerEvents: connectionWorkbenchState.ready ? 'auto' : 'none' }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
@@ -2698,6 +2802,25 @@ function App() {
</div>
</div>
)}
<div
onMouseDown={handleSidebarMouseDown}
role="separator"
aria-orientation="vertical"
title="拖动调整宽度"
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: sidebarResizeHandleWidth,
cursor: 'col-resize',
zIndex: 3,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
background: 'transparent',
}}
/>
</div>
{/* Floating SQL Log Toggle */}
@@ -2737,22 +2860,6 @@ function App() {
</Button>
</div>
</div>
{/* Sidebar Resize Handle */}
<div
onMouseDown={handleSidebarMouseDown}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '5px',
cursor: 'col-resize',
zIndex: 100,
// background: 'transparent' // transparent usually, visible on hover if desired
}}
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: bgContent, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (
@@ -3396,15 +3503,15 @@ function App() {
/>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Segmented
block
options={DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS}
value={appearance.dataTableColumnWidthMode}
onChange={(value) => setAppearance({ dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value) })}
options={DENSITY_OPTIONS}
value={appearance.dataTableDensity}
onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
200px 140px
</div>
</div>
</div>
@@ -3499,6 +3606,8 @@ function App() {
}
const binding = shortcutOptions[action] ?? { combo: '', enabled: false };
const isCapturing = capturingShortcutAction === action;
const conflicts = shortcutConflictMap[action];
const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null;
return (
<div
key={action}
@@ -3514,6 +3623,16 @@ function App() {
<div>
<div style={{ fontWeight: 500 }}>{meta.label}</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
{conflictInfo && (
<div style={{ fontSize: 11, color: darkMode ? '#faad14' : '#d48806', marginTop: 2 }}>
{conflictInfo.hasMonaco && (
<> {conflictInfo.monacoLabels}</>
)}
{conflictInfo.hasOther && (
<> {conflictInfo.otherContexts}{conflictInfo.otherLabels}</>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Input
@@ -3537,6 +3656,12 @@ function App() {
})}
</div>
</Modal>
<SnippetSettingsModal
open={isSnippetModalOpen}
onClose={() => setIsSnippetModalOpen(false)}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
<Modal
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
open={isProxyModalOpen}

View File

@@ -28,6 +28,7 @@ import {
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
interface AIChatPanelProps {
width?: number;
@@ -74,7 +75,7 @@ export const getDynamicMaxContextChars = (modelName?: string) => {
// 当超出指定字符上限时触发上下文自建压缩
const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
@@ -508,7 +509,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
let isFirstCompletion = false;
// 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢
const streamBuffer = { thinking: '', content: '' };
const streamBuffer = { thinking: '', reasoningContent: '', content: '' };
let flushPending = false;
const flushStreamBuffer = () => {
@@ -523,6 +524,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
updates.phase = 'thinking';
streamBuffer.thinking = '';
}
if (streamBuffer.reasoningContent) {
updates.reasoning_content = (existing.reasoning_content || '') + streamBuffer.reasoningContent;
streamBuffer.reasoningContent = '';
}
if (streamBuffer.content) {
updates.content = (existing.content || '') + streamBuffer.content;
updates.phase = 'generating';
@@ -535,7 +540,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
flushPending = false;
};
const handler = (data: { content?: string; thinking?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
const handler = (data: { content?: string; thinking?: string; reasoning_content?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
// Find connecting message if there's no active assistant string
if (!assistantMsgId) {
const history = useStore.getState().aiChatHistory[sid] || [];
@@ -589,7 +594,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}
// 处理 thinking模型思考过程
if (data.thinking) {
const displayThinking = data.thinking || data.reasoning_content || '';
if (displayThinking || data.reasoning_content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, {
@@ -597,7 +603,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
role: 'assistant',
phase: 'thinking',
content: '',
thinking: data.thinking,
thinking: displayThinking || undefined,
reasoning_content: data.reasoning_content || undefined,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
@@ -605,7 +612,10 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
});
if (sending) setSending(false);
} else {
streamBuffer.thinking += data.thinking;
streamBuffer.thinking += displayThinking;
if (data.reasoning_content) {
streamBuffer.reasoningContent += data.reasoning_content;
}
if (sending) setSending(false);
}
}
@@ -632,7 +642,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}
}
if (streamBuffer.thinking || streamBuffer.content) {
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
if (!flushPending) {
flushPending = true;
requestAnimationFrame(flushStreamBuffer);
@@ -641,7 +651,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (data.done) {
// 如果有残留未 flush 的 buffer立刻推入状态树
if (streamBuffer.thinking || streamBuffer.content) {
if (streamBuffer.thinking || streamBuffer.reasoningContent || streamBuffer.content) {
flushStreamBuffer();
}
const doneAssistantId = assistantMsgId;
@@ -676,12 +686,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
(async () => {
try {
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
const messagesPayload = currentHistory.map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const messagesPayload = currentHistory.map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
existing.jvmPlanContext,
existing.jvmDiagnosticPlanContext,
@@ -804,7 +809,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
addAIChatMessage(sid, connectingMsg);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
const messagesPayload = truncatedHistory.map(toAIRequestMessage);
try {
const sysMessages = await buildSystemContextMessages(
@@ -823,6 +828,8 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errClean}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined,
timestamp: Date.now(),
jvmPlanContext: retryJVMPlanContext,
@@ -1268,12 +1275,7 @@ SELECT * FROM users WHERE status = 1;
setSending(true);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
// 过滤掉 connecting 占位消息,不发给模型
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const messagesPayload = currentHistory.filter(m => m.phase !== 'connecting').map(toAIRequestMessage);
const sysMessages = await buildSystemContextMessages(
inheritedJVMPlanContext,
inheritedJVMDiagnosticPlanContext,
@@ -1313,6 +1315,8 @@ SELECT * FROM users WHERE status = 1;
useStore.getState().addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errC}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errC !== errR) ? errR : undefined,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
@@ -1380,12 +1384,7 @@ SELECT * FROM users WHERE status = 1;
// 【过渡状态 2】上下文已组装完成即将接入模型
updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' });
const chatMessages = [...messages, userMsg].map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const chatMessages = [...messages, userMsg].map(toAIRequestMessage);
let finalMessagesPayload = chatMessages;
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
@@ -1421,6 +1420,8 @@ SELECT * FROM users WHERE status = 1;
const assistantMsg: AIChatMessage = {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${errC2}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined,
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
@@ -1588,7 +1589,7 @@ SELECT * FROM users WHERE status = 1;
return connection ? buildRpcConnectionConfig(connection.config) : undefined;
}, [inferredConnectionId, connections]);
const contextUsageChars = useMemo(() =>
messages.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
messages.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0),
[messages]);
const contextTableNames = useMemo(() => {
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';

View File

@@ -61,6 +61,8 @@ import {
} from "../utils/connectionModalPresentation";
import { resolveConnectionSecretDraft } from "../utils/connectionSecretDraft";
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { CUSTOM_CONNECTION_DRIVER_HELP } from "../utils/driverImportGuidance";
import {
applyNoAutoCapAttributes,
@@ -96,7 +98,9 @@ type ChoiceCardOption = {
description?: string;
};
type ClickHouseProtocolChoice = "auto" | "http" | "native";
type OceanBaseProtocolChoice = "mysql" | "oracle";
const MAX_URI_LENGTH = 4096;
const MAX_CONNECTION_PARAMS_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
const MAX_TIMEOUT_SECONDS = 3600;
const CONNECTION_MODAL_WIDTH = 960;
@@ -111,6 +115,21 @@ const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
{ value: "http", label: "HTTP" },
{ value: "native", label: "Native" },
];
const OCEANBASE_PROTOCOL_OPTIONS: Array<{
value: OceanBaseProtocolChoice;
label: string;
}> = [
{ value: "mysql", label: "MySQL" },
{ value: "oracle", label: "Oracle" },
];
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
"protocol",
"oceanBaseProtocol",
"oceanbaseProtocol",
"tenantMode",
"compatMode",
"mode",
];
const normalizeClickHouseProtocolValue = (
value: unknown,
@@ -122,6 +141,55 @@ const normalizeClickHouseProtocolValue = (
if (text === "native" || text === "tcp") return "native";
return "auto";
};
const normalizeOceanBaseProtocolValue = (
value: unknown,
): OceanBaseProtocolChoice => {
const text = String(value || "")
.trim()
.toLowerCase();
return text === "oracle" ? "oracle" : "mysql";
};
const resolveOceanBaseProtocolValue = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
const text = String(value || "")
.trim()
.toLowerCase();
if (!text) return undefined;
return ["oracle", "oracle-mode", "oracle_mode", "oboracle"].includes(text)
? "oracle"
: "mysql";
};
const resolveOceanBaseProtocolFromQueryText = (
value: unknown,
): OceanBaseProtocolChoice | undefined => {
let text = String(value || "").trim();
if (!text) return undefined;
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
const protocol = resolveOceanBaseProtocolValue(params.get(key));
if (protocol) return protocol;
}
return undefined;
};
const resolveOceanBaseProtocolForConfig = (
config: Partial<ConnectionConfig>,
): OceanBaseProtocolChoice => {
return (
resolveOceanBaseProtocolValue(config.oceanBaseProtocol) ||
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
"mysql"
);
};
type ConnectionSecretKey =
| "primaryPassword"
| "sshPassword"
@@ -152,6 +220,8 @@ const getDefaultPortByType = (type: string) => {
return 9010;
case "mysql":
return 3306;
case "oceanbase":
return 2881;
case "doris":
case "diros":
return 9030;
@@ -160,6 +230,7 @@ const getDefaultPortByType = (type: string) => {
case "clickhouse":
return 9000;
case "postgres":
case "opengauss":
return 5432;
case "redis":
return 6379;
@@ -192,6 +263,7 @@ const getDefaultPortByType = (type: string) => {
const singleHostUriSchemesByType: Record<string, string[]> = {
postgres: ["postgresql", "postgres"],
opengauss: ["opengauss", "jdbc:opengauss", "postgresql", "postgres"],
clickhouse: ["clickhouse"],
oracle: ["oracle"],
sqlserver: ["sqlserver"],
@@ -206,6 +278,7 @@ const singleHostUriSchemesByType: Record<string, string[]> = {
const sslSupportedTypes = new Set([
"mysql",
"mariadb",
"oceanbase",
"doris",
"diros",
"sphinx",
@@ -217,6 +290,7 @@ const sslSupportedTypes = new Set([
"kingbase",
"highgo",
"vastbase",
"opengauss",
"mongodb",
"redis",
"tdengine",
@@ -232,6 +306,28 @@ const supportsSSLForType = (type: string) =>
const isFileDatabaseType = (type: string) =>
type === "sqlite" || type === "duckdb";
const isMySQLCompatibleType = (type: string) =>
type === "mysql" ||
type === "mariadb" ||
type === "oceanbase" ||
type === "doris" ||
type === "diros" ||
type === "sphinx";
const supportsConnectionParamsForType = (type: string) =>
isMySQLCompatibleType(type) ||
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase" ||
type === "opengauss" ||
type === "oracle" ||
type === "sqlserver" ||
type === "clickhouse" ||
type === "mongodb" ||
type === "dameng" ||
type === "tdengine";
type DriverStatusSnapshot = {
type: string;
name: string;
@@ -249,6 +345,12 @@ const normalizeDriverType = (value: string): string => {
.toLowerCase();
if (normalized === "postgresql") return "postgres";
if (normalized === "doris") return "diros";
if (
normalized === "open_gauss" ||
normalized === "open-gauss" ||
normalized === "opengauss"
)
return "opengauss";
return normalized;
};
@@ -330,6 +432,9 @@ const ConnectionModal: React.FC<{
const mongoTopology = Form.useWatch("mongoTopology", form) || "single";
const mongoSrv = Form.useWatch("mongoSrv", form) || false;
const redisTopology = Form.useWatch("redisTopology", form) || "single";
const oceanBaseProtocol = normalizeOceanBaseProtocolValue(
Form.useWatch("oceanBaseProtocol", form),
);
const sslMode = Form.useWatch("sslMode", form) || "preferred";
const proxyType = Form.useWatch("proxyType", form) || "socks5";
const customDriver = Form.useWatch("driver", form) || "";
@@ -355,16 +460,15 @@ const ConnectionModal: React.FC<{
}),
[jvmAllowedModes, jvmPreferredMode],
);
const isMySQLLike =
dbType === "mysql" ||
dbType === "mariadb" ||
dbType === "doris" ||
dbType === "diros" ||
dbType === "sphinx";
const isOceanBaseOracle = dbType === "oceanbase" && oceanBaseProtocol === "oracle";
const isMySQLLike = isMySQLCompatibleType(dbType) && !isOceanBaseOracle;
const supportsConnectionParams = supportsConnectionParamsForType(dbType);
const isSSLType = supportsSSLForType(dbType);
const sslHintText = isMySQLLike
? "当 MySQL/MariaDB/Doris/Sphinx 开启安全传输策略时,请启用 SSL本地自签证书场景可先用 Preferred 或 Skip Verify。"
: dbType === "dameng"
: isOceanBaseOracle
? "OceanBase Oracle 租户使用 Oracle 协议连接SSL 参数按 Oracle 驱动规则传递。"
: dbType === "dameng"
? "达梦驱动启用 SSL 需要客户端证书与私钥路径sslCertPath / sslKeyPath。"
: dbType === "sqlserver"
? "SQL Server 推荐在生产环境使用 Required并关闭 TrustServerCertificate。"
@@ -1047,6 +1151,56 @@ const ConnectionModal: React.FC<{
return text === "1" || text === "true" || text === "yes" || text === "on";
};
const normalizeConnectionParamsText = (raw: unknown) => {
let text = String(raw || "").trim();
if (!text) return "";
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
return text.replace(/^[?&]+/, "").trim().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
};
const serializeConnectionParams = (params: URLSearchParams) => {
const cloned = new URLSearchParams();
params.forEach((value, key) => {
if (String(key || "").trim()) {
cloned.append(key, value);
}
});
return cloned.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
};
const normalizeOceanBaseConnectionParamsText = (
rawParams: unknown,
selectedProtocol: OceanBaseProtocolChoice,
) => {
const params = new URLSearchParams(normalizeConnectionParamsText(rawParams));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
params.delete(key);
}
params.set("protocol", selectedProtocol);
return params.toString().slice(0, MAX_CONNECTION_PARAMS_LENGTH);
};
const mergeConnectionParams = (
params: URLSearchParams,
rawParams: unknown,
) => {
const text = normalizeConnectionParamsText(rawParams);
if (!text) return;
const extra = new URLSearchParams(text);
extra.forEach((value, key) => {
if (String(key || "").trim()) {
params.set(key, value);
}
});
};
const normalizeFileDbPath = (rawPath: string): string => {
let pathText = String(rawPath || "").trim();
if (!pathText) {
@@ -1199,6 +1353,7 @@ const ConnectionModal: React.FC<{
clickHouseProtocol: "http",
useSSL: isHttps,
sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable",
connectionParams: serializeConnectionParams(parsed.params),
};
};
@@ -1214,15 +1369,13 @@ const ConnectionModal: React.FC<{
return null;
}
if (
type === "mysql" ||
type === "mariadb" ||
type === "diros" ||
type === "sphinx"
) {
if (isMySQLCompatibleType(type)) {
const mysqlDefaultPort = getDefaultPortByType(type);
const parsed =
parseMultiHostUri(trimmedUri, "mysql") ||
parseMultiHostUri(trimmedUri, "jdbc:mysql") ||
parseMultiHostUri(trimmedUri, "oceanbase") ||
parseMultiHostUri(trimmedUri, "jdbc:oceanbase") ||
parseMultiHostUri(trimmedUri, "diros") ||
parseMultiHostUri(trimmedUri, "doris");
if (!parsed) {
@@ -1246,9 +1399,22 @@ const ConnectionModal: React.FC<{
const topology = String(
parsed.params.get("topology") || "",
).toLowerCase();
const tlsValue = String(parsed.params.get("tls") || "")
const tlsValue = String(
parsed.params.get("tls") || parsed.params.get("useSSL") || "",
)
.trim()
.toLowerCase();
const parsedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(
parsed.params.get("protocol") ||
parsed.params.get("oceanBaseProtocol") ||
parsed.params.get("oceanbaseProtocol") ||
parsed.params.get("tenantMode") ||
parsed.params.get("compatMode") ||
parsed.params.get("mode"),
)
: undefined;
const sslMode =
tlsValue === "true"
? "required"
@@ -1265,9 +1431,15 @@ const ConnectionModal: React.FC<{
database: parsed.database || "",
useSSL: sslMode !== "disable",
sslMode,
oceanBaseProtocol: parsedOceanBaseProtocol,
mysqlTopology:
hostList.length > 1 || topology === "replica" ? "replica" : "single",
parsedOceanBaseProtocol === "oracle"
? "single"
: hostList.length > 1 || topology === "replica"
? "replica"
: "single",
mysqlReplicaHosts: hostList.slice(1),
connectionParams: serializeConnectionParams(parsed.params),
timeout:
Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(3600, Math.trunc(timeoutValue))
@@ -1414,6 +1586,7 @@ const ConnectionModal: React.FC<{
mongoAuthSource: parsed.params.get("authSource") || "",
mongoReadPreference: parsed.params.get("readPreference") || "primary",
mongoAuthMechanism: parsed.params.get("authMechanism") || "",
connectionParams: serializeConnectionParams(parsed.params),
timeout:
Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000))
@@ -1450,6 +1623,9 @@ const ConnectionModal: React.FC<{
password: parsed.password,
database: parsed.database,
};
if (supportsConnectionParamsForType(type)) {
parsedValues.connectionParams = serializeConnectionParams(parsed.params);
}
if (supportsSSLForType(type)) {
const normalizeBool = (raw: unknown) => {
@@ -1464,7 +1640,8 @@ const ConnectionModal: React.FC<{
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
type === "vastbase" ||
type === "opengauss"
) {
const sslMode = String(parsed.params.get("sslmode") || "")
.trim()
@@ -1619,14 +1796,13 @@ const ConnectionModal: React.FC<{
});
const getUriPlaceholder = () => {
if (
dbType === "mysql" ||
dbType === "mariadb" ||
dbType === "diros" ||
dbType === "sphinx"
) {
if (isMySQLCompatibleType(dbType)) {
const defaultPort = getDefaultPortByType(dbType);
const scheme = dbType === "diros" ? "doris" : "mysql";
const scheme =
dbType === "diros" ? "doris" : dbType === "oceanbase" ? "oceanbase" : "mysql";
if (dbType === "oceanbase") {
return `${scheme}://sys%40oracle001:pass@127.0.0.1:${defaultPort}/SERVICE_NAME?protocol=oracle`;
}
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
}
if (isFileDatabaseType(dbType)) {
@@ -1646,9 +1822,45 @@ const ConnectionModal: React.FC<{
if (dbType === "oracle") {
return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1";
}
if (dbType === "opengauss") {
return "opengauss://user:pass@127.0.0.1:5432/db_name";
}
return "例如: postgres://user:pass@127.0.0.1:5432/db_name";
};
const getConnectionParamsPlaceholder = () => {
if (dbType === "oceanbase") {
return oceanBaseProtocol === "oracle"
? "PREFETCH_ROWS=5000"
: "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
}
if (isMySQLCompatibleType(dbType)) {
return "useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false";
}
switch (dbType) {
case "postgres":
case "kingbase":
case "highgo":
case "vastbase":
case "opengauss":
return "application_name=GoNavi&statement_timeout=30000";
case "oracle":
return "PREFETCH_ROWS=5000&TRACE FILE=/tmp/go-ora.trc";
case "sqlserver":
return "app name=GoNavi&packet size=32767";
case "clickhouse":
return "max_execution_time=60&compress=lz4";
case "mongodb":
return "retryWrites=true&readPreference=secondaryPreferred";
case "dameng":
return "schema=SYSDBA";
case "tdengine":
return "timezone=Asia%2FShanghai";
default:
return "key=value&another=value";
}
};
const buildUriFromValues = (values: any) => {
const type = String(values.type || "")
.trim()
@@ -1664,15 +1876,14 @@ const ConnectionModal: React.FC<{
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ""}@`
: "";
if (
type === "mysql" ||
type === "mariadb" ||
type === "diros" ||
type === "sphinx"
) {
if (isMySQLCompatibleType(type)) {
const selectedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(values.oceanBaseProtocol)
: "mysql";
const primary = toAddress(host, port, defaultPort);
const replicas =
values.mysqlTopology === "replica"
selectedOceanBaseProtocol !== "oracle" && values.mysqlTopology === "replica"
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
: [];
const hosts = normalizeAddressList([primary, ...replicas], defaultPort);
@@ -1695,9 +1906,14 @@ const ConnectionModal: React.FC<{
if (Number.isFinite(timeout) && timeout > 0) {
params.set("timeout", String(timeout));
}
mergeConnectionParams(params, values.connectionParams);
if (type === "oceanbase") {
params.set("protocol", selectedOceanBaseProtocol);
}
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
const query = params.toString();
const scheme = type === "diros" ? "doris" : "mysql";
const scheme =
type === "diros" ? "doris" : type === "oceanbase" ? "oceanbase" : "mysql";
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
}
@@ -1797,6 +2013,7 @@ const ConnectionModal: React.FC<{
params.set("connectTimeoutMS", String(timeout * 1000));
params.set("serverSelectionTimeoutMS", String(timeout * 1000));
}
mergeConnectionParams(params, values.connectionParams);
const dbPath = database ? `/${encodeURIComponent(database)}` : "/";
const query = params.toString();
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
@@ -1824,7 +2041,8 @@ const ConnectionModal: React.FC<{
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
type === "vastbase" ||
type === "opengauss"
) {
params.set("sslmode", "require");
} else if (type === "sqlserver") {
@@ -1863,7 +2081,8 @@ const ConnectionModal: React.FC<{
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase"
type === "vastbase" ||
type === "opengauss"
) {
params.set("sslmode", "disable");
} else if (type === "sqlserver") {
@@ -1876,6 +2095,9 @@ const ConnectionModal: React.FC<{
if (type === "clickhouse" && clickHouseProtocol !== "auto") {
params.set("protocol", clickHouseProtocol);
}
if (supportsConnectionParamsForType(type)) {
mergeConnectionParams(params, values.connectionParams);
}
const query = params.toString();
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`;
};
@@ -1909,7 +2131,13 @@ const ConnectionModal: React.FC<{
});
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
form.setFieldsValue(
mergeParsedUriValuesForForm(
form.getFieldsValue(true),
parsedValues,
uriText,
),
);
if (testResult) {
setTestResult(null);
}
@@ -2043,6 +2271,7 @@ const ConnectionModal: React.FC<{
const mysqlReplicaHosts =
configType === "mysql" ||
configType === "mariadb" ||
configType === "oceanbase" ||
configType === "diros" ||
configType === "sphinx"
? normalizedHosts.slice(1)
@@ -2082,10 +2311,19 @@ const ConnectionModal: React.FC<{
password: config.password,
database: config.database,
uri: config.uri || "",
connectionParams:
config.connectionParams ||
(config.uri
? parseUriToValues(config.uri, configType)?.connectionParams || ""
: ""),
clickHouseProtocol:
configType === "clickhouse"
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
: "auto",
oceanBaseProtocol:
configType === "oceanbase"
? resolveOceanBaseProtocolForConfig(config)
: "mysql",
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSL: !!config.useSSL,
@@ -2294,11 +2532,7 @@ const ConnectionModal: React.FC<{
forceClear: !config.useHttpTunnel,
});
const mysqlReplicaEnabled =
(config.type === "mysql" ||
config.type === "mariadb" ||
config.type === "diros" ||
config.type === "sphinx") &&
config.topology === "replica";
isMySQLCompatibleType(config.type) && config.topology === "replica";
const mysqlReplicaDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasMySQLReplicaPassword,
valueInput: config.mysqlReplicaPassword,
@@ -2528,10 +2762,7 @@ const ConnectionModal: React.FC<{
}
if (
clearSecrets.mysqlReplicaPassword &&
(values.type === "mysql" ||
values.type === "mariadb" ||
values.type === "diros" ||
values.type === "sphinx") &&
isMySQLCompatibleType(values.type) &&
values.mysqlTopology === "replica" &&
String(values.mysqlReplicaPassword ?? "") === ""
) {
@@ -2611,12 +2842,14 @@ const ConnectionModal: React.FC<{
// Use different API for Redis / JVM
const isRedisType = values.type === "redis";
const isJVMType = values.type === "jvm";
const dbTestConfig =
!isRedisType && !isJVMType ? buildRpcConnectionConfig(config as any) : config;
const res = await withClientTimeout(
isJVMType
? TestJVMConnection(config as any)
: isRedisType
? RedisConnect(config as any)
: TestConnection(config as any),
: TestConnection(dbTestConfig as any),
rpcTimeoutMs,
`连接测试超时(>${timeoutSeconds} 秒),请检查网络/代理/SSH配置后重试`,
);
@@ -2629,7 +2862,7 @@ const ConnectionModal: React.FC<{
} else if (!isJVMType) {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(config as any),
DBGetDatabases(dbTestConfig as any),
rpcTimeoutMs,
`连接成功但拉取数据库列表超时(>${timeoutSeconds} 秒)`,
);
@@ -2884,6 +3117,10 @@ const ConnectionModal: React.FC<{
const type = String(mergedValues.type || "").toLowerCase();
const defaultPort = getDefaultPortByType(type);
const selectedOceanBaseProtocol =
type === "oceanbase"
? normalizeOceanBaseProtocolValue(mergedValues.oceanBaseProtocol)
: "mysql";
if (type === "clickhouse") {
const requestedProtocol = normalizeClickHouseProtocolValue(
mergedValues.clickHouseProtocol,
@@ -2983,12 +3220,7 @@ const ConnectionModal: React.FC<{
const savePassword =
type === "mongodb" ? mergedValues.savePassword !== false : true;
if (
type === "mysql" ||
type === "mariadb" ||
type === "diros" ||
type === "sphinx"
) {
if (isMySQLCompatibleType(type) && selectedOceanBaseProtocol !== "oracle") {
const replicas =
mergedValues.mysqlTopology === "replica"
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
@@ -3129,6 +3361,14 @@ const ConnectionModal: React.FC<{
}
const keepPassword = !forPersist || savePassword;
const normalizedConnectionParams = supportsConnectionParamsForType(type)
? type === "oceanbase"
? normalizeOceanBaseConnectionParamsText(
mergedValues.connectionParams,
selectedOceanBaseProtocol,
)
: normalizeConnectionParamsText(mergedValues.connectionParams)
: "";
return {
type: mergedValues.type,
@@ -3150,6 +3390,7 @@ const ConnectionModal: React.FC<{
httpTunnel: httpTunnelConfig,
driver: mergedValues.driver,
dsn: mergedValues.dsn,
connectionParams: normalizedConnectionParams,
timeout: Number(mergedValues.timeout || 30),
redisDB: Number.isFinite(Number(mergedValues.redisDB))
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
@@ -3159,6 +3400,8 @@ const ConnectionModal: React.FC<{
type === "clickhouse"
? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol)
: undefined,
oceanBaseProtocol:
type === "oceanbase" ? selectedOceanBaseProtocol : undefined,
hosts: hosts,
topology: topology,
mysqlReplicaUser: mysqlReplicaUser,
@@ -3189,6 +3432,7 @@ const ConnectionModal: React.FC<{
form.setFieldsValue({
type: type,
clickHouseProtocol: type === "clickhouse" ? "auto" : undefined,
oceanBaseProtocol: type === "oceanbase" ? "mysql" : undefined,
});
const defaultPort = getDefaultPortByType(type);
@@ -3226,6 +3470,7 @@ const ConnectionModal: React.FC<{
httpTunnelPassword: "",
timeout: 30,
uri: "",
connectionParams: "",
includeDatabases: undefined,
includeRedisDatabases: undefined,
mysqlTopology: "single",
@@ -3303,6 +3548,7 @@ const ConnectionModal: React.FC<{
mongoReplicaUser: "",
mongoReplicaPassword: "",
redisDB: 0,
connectionParams: "",
});
} else if (type !== "custom") {
const defaultUser =
@@ -3340,6 +3586,7 @@ const ConnectionModal: React.FC<{
mongoReplicaUser: "",
mongoReplicaPassword: "",
redisDB: 0,
connectionParams: "",
});
}
@@ -3441,6 +3688,11 @@ const ConnectionModal: React.FC<{
{
label: "国产数据库",
items: [
{
key: "oceanbase",
name: "OceanBase",
icon: getDbIcon("oceanbase", undefined, 36),
},
{
key: "dameng",
name: "Dameng (达梦)",
@@ -3461,6 +3713,11 @@ const ConnectionModal: React.FC<{
name: "Vastbase (海量)",
icon: getDbIcon("vastbase", undefined, 36),
},
{
key: "opengauss",
name: "OpenGauss",
icon: getDbIcon("opengauss", undefined, 36),
},
],
},
{
@@ -3516,6 +3773,8 @@ const ConnectionModal: React.FC<{
return "单机 / 集群";
case "mongodb":
return "单机 / 副本集";
case "oceanbase":
return "MySQL / Oracle 租户";
case "sqlite":
case "duckdb":
return "本地文件连接";
@@ -3749,6 +4008,19 @@ const ConnectionModal: React.FC<{
placeholder={getUriPlaceholder()}
/>
</Form.Item>
{supportsConnectionParams && (
<Form.Item
name="connectionParams"
label="额外连接参数"
help="按当前数据源驱动支持的 URI/DSN query 格式填写;认证密码请使用上方密码字段。"
>
<Input.TextArea
{...noAutoCapInputProps}
rows={2}
placeholder={getConnectionParamsPlaceholder()}
/>
</Form.Item>
)}
<Space
size={8}
style={{ marginBottom: uriFeedback ? 12 : 16 }}
@@ -4495,10 +4767,33 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "oceanbase" &&
renderConfigSectionCard({
sectionKey: "oceanBaseProtocol",
icon: <ClusterOutlined />,
children: (
<Form.Item
name="oceanBaseProtocol"
label="OceanBase 协议"
help="MySQL 租户选择 MySQLOracle 租户选择 Oracle。该选择会同时影响连接测试、浏览表结构和 SQL 方言。"
style={{ marginBottom: 0 }}
>
<Select
options={OCEANBASE_PROTOCOL_OPTIONS}
onChange={() => {
form.setFieldsValue({ mysqlTopology: "single" });
clearConnectionTestResultForChoice();
}}
/>
</Form.Item>
),
})}
{(dbType === "postgres" ||
dbType === "kingbase" ||
dbType === "highgo" ||
dbType === "vastbase") &&
dbType === "vastbase" ||
dbType === "opengauss") &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
@@ -4514,20 +4809,26 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "oracle" &&
{(dbType === "oracle" || isOceanBaseOracle) &&
renderConfigSectionCard({
sectionKey: "service",
icon: <DatabaseOutlined />,
children: (
<Form.Item
name="database"
label="服务名 (Service Name)"
label={isOceanBaseOracle ? "OceanBase Oracle 服务名 (Service Name)" : "服务名 (Service Name)"}
rules={[
createUriAwareRequiredRule(
"请输入 Oracle 服务名(例如 ORCLPDB1",
isOceanBaseOracle
? "请输入 OceanBase Oracle 服务名"
: "请输入 Oracle 服务名(例如 ORCLPDB1",
),
]}
help="请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
help={
isOceanBaseOracle
? "Oracle 租户必须填写监听器注册的 SERVICE_NAME用户名仍按 OceanBase 租户格式填写。"
: "请填写监听器注册的 SERVICE_NAME不是用户名。例如ORCLPDB1"
}
style={{ marginBottom: 0 }}
>
<Input
@@ -5926,6 +6227,8 @@ const ConnectionModal: React.FC<{
httpTunnelPort: 8080,
timeout: 30,
uri: "",
connectionParams: "",
oceanBaseProtocol: "mysql",
mysqlTopology: "single",
redisTopology: "single",
mongoTopology: "single",
@@ -5971,7 +6274,12 @@ const ConnectionModal: React.FC<{
setTestResult(null);
setTestErrorLogOpen(false);
}
if (changed.uri !== undefined || changed.type !== undefined) {
if (
changed.uri !== undefined ||
changed.connectionParams !== undefined ||
changed.type !== undefined ||
changed.oceanBaseProtocol !== undefined
) {
setUriFeedback(null);
}
if (changed.useSSL !== undefined) {

View File

@@ -27,7 +27,7 @@ const storeState = vi.hoisted(() => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
import DataGrid, { formatCellDisplayText, resolveContextMenuFieldName } from './DataGrid';
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
@@ -14,7 +14,7 @@ vi.mock('../store', () => ({
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
dataTableDensity: 'comfortable',
},
queryOptions: {
showColumnComment: false,
@@ -83,6 +83,15 @@ describe('DataGrid layout', () => {
expect(markup).toContain('当前页查找...');
});
it('preserves fractional seconds when rendering datetime values', () => {
expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456');
});
it('resolves the field name copied from the cell context menu', () => {
expect(resolveContextMenuFieldName('created_at', '创建时间')).toBe('created_at');
expect(resolveContextMenuFieldName('', 'fallback_name')).toBe('fallback_name');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
@@ -103,6 +112,7 @@ describe('DataGrid layout', () => {
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
@@ -169,6 +179,26 @@ describe('DataGrid layout', () => {
expect(markup).toContain('粘贴行');
});
it('renders a clickable copy action for aggregate query results', () => {
const markup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
'COUNT(*)': 12,
},
]}
columnNames={['COUNT(*)']}
loading={false}
exportScope="queryResult"
/>,
);
expect(markup).toContain('data-grid-query-copy-action="true"');
expect(markup).not.toMatch(/data-grid-query-copy-action="true"[^>]*disabled/);
expect(markup).toContain('复制');
});
it('renders a quick WHERE condition editor when table filters are visible', () => {
const markup = renderToStaticMarkup(
<DataGrid

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@ const quoteSqlIdent = (dbType: string, ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
const t = String(dbType || '').toLowerCase();
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
if (t === 'mysql' || t === 'mariadb' || t === 'oceanbase' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
if (t === 'sqlserver') {

View File

@@ -176,6 +176,46 @@ describe('DataViewer safe editing locator', () => {
renderer.unmount();
});
it('does not add fallback ORDER BY for DuckDB table preview when a primary key is available', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-order', dbName: 'main', tableName: 'events', title: 'events' }));
const tableQueries = backendApp.DBQuery.mock.calls
.map((call: any[]) => String(call[2] || ''))
.filter((sql: string) => sql.includes('FROM "events"'));
expect(tableQueries.length).toBeGreaterThan(0);
expect(tableQueries.every((sql: string) => !/\border\s+by\b/i.test(sql))).toBe(true);
expect(tableQueries[tableQueries.length - 1]).toContain('LIMIT 101 OFFSET 0');
renderer.unmount();
});
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: false,
message: 'context deadline exceeded INTERRUPT Error: Interrupted!',
fields: [],
data: [],
});
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' }));
expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。');
expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true);
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';

View File

@@ -165,6 +165,20 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => {
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
};
const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => {
const rawMessage = String(messageText || '查询失败').trim() || '查询失败';
const lower = rawMessage.toLowerCase();
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时');
const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted'));
if (isTimeout || isDuckDBInterrupted) {
if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。';
}
return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。';
}
return rawMessage;
};
const reverseOrderBySQL = (orderBySQL: string): string => {
const raw = String(orderBySQL || '').trim();
if (!raw) return '';
@@ -457,7 +471,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const dbType = resolveDataSourceType(config);
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros';
const normalizedQuickWhereCondition = normalizeQuickWhereCondition(quickWhereCondition);
const quickWhereValidation = validateQuickWhereCondition(normalizedQuickWhereCondition);
if (!quickWhereValidation.ok) {
@@ -929,11 +943,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}
}
} else {
message.error(String(resData.message || '查询失败'));
message.error(formatDataViewerQueryError(dbTypeLower, resData.message));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
message.error("Error fetching data: " + e.message);
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e));
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),

View File

@@ -12,6 +12,7 @@ export interface DbIconProps {
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
oceanbase: '#0052CC',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
@@ -24,6 +25,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
opengauss: '#2446A8',
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
@@ -90,6 +92,9 @@ const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mariadb" size={size} color={color} />
);
const OceanBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oceanbase} label="OB" />
);
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="postgres" size={size} color={color} />
);
@@ -131,6 +136,9 @@ const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
);
const OpenGaussIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.opengauss} label="OG" />
);
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
);
@@ -165,6 +173,7 @@ const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mysql: MySQLIcon,
mariadb: MariaDBIcon,
oceanbase: OceanBaseIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
@@ -179,6 +188,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
opengauss: OpenGaussIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
@@ -186,9 +196,9 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'jvm',
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'tdengine', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
@@ -204,12 +214,12 @@ export const getDbIcon = (type: string, color?: string, size?: number): React.Re
/** 获取数据库图标显示名称(中文) */
export const getDbIconLabel = (type: string): string => {
const labels: Record<string, string> = {
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
mysql: 'MySQL', mariadb: 'MariaDB', oceanbase: 'OceanBase', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', jvm: 'JVM',
oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',
};
return labels[type?.toLowerCase()] || type;

View File

@@ -43,9 +43,12 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -133,7 +136,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
const schemaRef = schema || 'public';
return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`];
}
@@ -179,7 +183,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
const schemaRef = schema || 'public';
return [`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 = '${safeName}' LIMIT 1`];
}

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd';
import { Alert, Button, Collapse, Empty, Input, Modal, Progress, Select, Space, Switch, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { buildDriverManagerWorkbenchTheme } from '../utils/driverManagerWorkbenchTheme';
import {
DRIVER_LOCAL_IMPORT_BUTTON_LABEL,
DRIVER_LOCAL_IMPORT_DIRECTORY_HELP,
@@ -113,7 +114,6 @@ type DriverVersionOption = {
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const DRIVER_TABLE_SCROLL_X = 1450;
const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000;
const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000;
const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase();
@@ -179,11 +179,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const modalContentRef = useRef<HTMLDivElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
const externalHScrollRef = useRef<HTMLDivElement | null>(null);
const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>('');
const driverManagerTheme = useMemo(
() => buildDriverManagerWorkbenchTheme(darkMode, opacity),
[darkMode, opacity],
);
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
@@ -201,13 +200,39 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X);
const downloadDirRef = useRef(downloadDir);
useEffect(() => {
downloadDirRef.current = downloadDir;
}, [downloadDir]);
const modalBodyStyle = useMemo<React.CSSProperties>(() => ({
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
background: driverManagerTheme.pageBg,
color: driverManagerTheme.titleText,
}), [driverManagerTheme]);
const managerSectionStyle = useMemo<React.CSSProperties>(() => ({
border: driverManagerTheme.sectionBorder,
borderRadius: 8,
background: driverManagerTheme.sectionBg,
}), [driverManagerTheme]);
const managerStatStyle = useMemo<React.CSSProperties>(() => ({
border: driverManagerTheme.statBorder,
borderRadius: 8,
background: driverManagerTheme.statBg,
}), [driverManagerTheme]);
const managerUpdateNoteStyle = useMemo<React.CSSProperties>(() => ({
border: driverManagerTheme.updateNoteBorder,
borderRadius: 8,
background: driverManagerTheme.updateNoteBg,
}), [driverManagerTheme]);
const appendOperationLog = useCallback((
driverType: string,
text: string,
@@ -254,76 +279,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
});
}, []);
const refreshHorizontalScrollState = useCallback(() => {
const tableContainer = tableContainerRef.current;
const targets = tableContainer
? [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
]
: tableScrollTargetsRef.current;
if (!targets || targets.length === 0) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
return;
}
const nextWidth = Math.max(
DRIVER_TABLE_SCROLL_X,
...targets.map((target) => Math.max(0, target.scrollWidth)),
);
setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth));
const externalScroll = externalHScrollRef.current;
if (!externalScroll || horizontalSyncSourceRef.current === 'external') {
return;
}
const preferredTarget =
targets.find((target) => target.scrollWidth > target.clientWidth + 1) ||
targets[0];
const targetScrollLeft = preferredTarget?.scrollLeft || 0;
if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) {
externalScroll.scrollLeft = targetScrollLeft;
}
}, []);
const applyExternalScrollToTableTargets = useCallback(() => {
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
if (horizontalSyncSourceRef.current === 'table') {
return;
}
const liveTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
if (liveTargets.length === 0) {
return;
}
horizontalSyncSourceRef.current = 'external';
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
}
if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) {
target.scrollLeft = externalScroll.scrollLeft;
}
});
horizontalSyncSourceRef.current = '';
}, []);
const refreshStatus = useCallback(async (
toastOnError = true,
options?: { showLoading?: boolean },
@@ -601,8 +556,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
useEffect(() => {
if (!open) {
setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X);
tableScrollTargetsRef.current = [];
return;
}
@@ -630,117 +583,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
return;
}
const tableContainer = tableContainerRef.current;
const externalScroll = externalHScrollRef.current;
if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) {
return;
}
let currentTargets: HTMLElement[] = [];
let rafId: number | null = null;
let bodyResizeObserver: ResizeObserver | null = null;
let containerResizeObserver: ResizeObserver | null = null;
const pickSyncTarget = () => {
if (currentTargets.length === 0) {
return null;
}
return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0];
};
const syncFromTableTarget = (event?: Event) => {
const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const activeTarget = source || pickSyncTarget();
if (!activeTarget) {
return;
}
if (horizontalSyncSourceRef.current === 'external') {
return;
}
horizontalSyncSourceRef.current = 'table';
if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) {
externalScroll.scrollLeft = activeTarget.scrollLeft;
}
horizontalSyncSourceRef.current = '';
};
const bindCurrentTableTargets = () => {
const nextTargets = [
...new Set(
[
...Array.from(tableContainer.querySelectorAll('.ant-table-content')),
...Array.from(tableContainer.querySelectorAll('.ant-table-body')),
].filter((node): node is HTMLElement => node instanceof HTMLElement),
),
];
const sameTargets =
nextTargets.length === currentTargets.length &&
nextTargets.every((target, index) => target === currentTargets[index]);
if (sameTargets) {
return;
}
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
bodyResizeObserver?.unobserve(target);
});
currentTargets = nextTargets;
tableScrollTargetsRef.current = nextTargets;
currentTargets.forEach((target) => {
target.addEventListener('scroll', syncFromTableTarget, { passive: true });
bodyResizeObserver?.observe(target);
});
refreshHorizontalScrollState();
syncFromTableTarget();
};
const scheduleRefresh = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
bindCurrentTableTargets();
refreshHorizontalScrollState();
});
};
const mutationObserver = new MutationObserver(scheduleRefresh);
mutationObserver.observe(tableContainer, { childList: true, subtree: true });
bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null;
containerResizeObserver?.observe(tableContainer);
if (typeof ResizeObserver !== 'undefined') {
modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current);
}
window.addEventListener('resize', scheduleRefresh);
scheduleRefresh();
return () => {
mutationObserver.disconnect();
window.removeEventListener('resize', scheduleRefresh);
currentTargets.forEach((target) => {
target.removeEventListener('scroll', syncFromTableTarget);
});
if (bodyResizeObserver) {
bodyResizeObserver.disconnect();
}
if (containerResizeObserver) {
containerResizeObserver.disconnect();
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [open, refreshHorizontalScrollState]);
useEffect(() => {
if (!open) {
return;
@@ -1011,221 +853,155 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
width: 220,
render: (_: string, row: DriverStatusRow) => (
<div style={{ display: 'grid', gap: 4 }}>
<Text strong>{row.name}</Text>
{row.message ? (
<Text type={row.needsUpdate ? 'warning' : 'secondary'} style={{ fontSize: 12 }}>
{row.message}
</Text>
) : null}
</div>
),
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
},
},
{
title: '状态',
key: 'status',
width: 140,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Tag color="success"></Tag>;
}
const progress = progressMap[row.type];
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.needsUpdate) {
return <Tag color="warning"></Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
if (row.packageInstalled) {
return <Tag color="warning"></Tag>;
}
return <Tag color="default"></Tag>;
},
},
{
title: '安装进度',
key: 'progress',
width: 170,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const resolvePackageSizeText = (row: DriverStatusRow): string => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
};
const progress = progressMap[row.type];
let percent = 0;
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
const resolveDriverStatusTag = (row: DriverStatusRow) => {
if (row.builtIn) {
return <Tag color="success"></Tag>;
}
const progress = progressMap[row.type];
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.needsUpdate) {
return <Tag color="warning"></Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
if (row.packageInstalled) {
return <Tag color="warning"></Tag>;
}
return <Tag></Tag>;
};
if (progress?.status === 'error') {
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
status = 'exception';
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
status = 'active';
} else if (row.connectable || row.packageInstalled) {
percent = 100;
status = 'success';
}
const resolveDriverProgress = (row: DriverStatusRow) => {
const progress = progressMap[row.type];
let percent = 0;
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
return <Progress percent={percent} status={status} size="small" />;
},
},
{
title: '驱动版本',
key: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
const revisionHint = row.needsUpdate ? ',需重装' : '';
if (installedVersion) {
return <Text type="secondary">{installedVersion}{revisionHint}</Text>;
if (progress?.status === 'error') {
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
status = 'exception';
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
status = 'active';
} else if (row.connectable || row.packageInstalled) {
percent = 100;
status = 'success';
}
return { percent, status };
};
const renderVersionControl = (row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
const revisionHint = row.needsUpdate ? ',需重装' : '';
return (
<Text type="secondary" className="driver-manager-version-lock">
{installedVersion ? `${installedVersion}(已安装${revisionHint}` : `已安装${row.needsUpdate ? ',需重装' : ''}`}
</Text>
);
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<div className="driver-manager-version-control">
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
return <Text type="secondary">{row.needsUpdate ? '需重装,' : ''}</Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
const mongoHint = row.type === 'mongodb'
? '当前仅支持 MongoDB 1.17.x 和 2.x更老 1.x 暂不提供安装。'
: '';
return (
<div style={{ display: 'grid', gap: 4 }}>
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionState.driverType === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" style={{ fontSize: 12 }}>{mongoHint}</Text> : null}
</div>
);
},
},
{
title: '操作',
key: 'actions',
width: 320,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
{mongoHint ? <Text type="secondary" className="driver-manager-small-text">{mongoHint}</Text> : null}
</div>
);
};
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const renderDriverActions = (row: DriverStatusRow) => {
if (row.builtIn) {
return null;
}
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
const loadingInstallOrRemove =
actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove');
const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local';
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.needsUpdate ? (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
</Button>
) : row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingInstallOrRemove}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
</Button>
);
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary">使 Full </Text>;
}
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingLocal}
onClick={() => installDriverFromLocalFile(row)}
>
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionState, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const mainAction = row.needsUpdate ? (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
) : row.connectable ? (
<Button danger icon={<DeleteOutlined />} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
</Button>
) : (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
);
return (
<Space size={8} wrap className="driver-manager-card-actions">
{mainAction}
<Button icon={<FileSearchOutlined />} loading={loadingLocal} onClick={() => installDriverFromLocalFile(row)}>
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
</Button>
<Button type={hasLogs ? 'default' : 'text'} disabled={!hasLogs} onClick={() => openDriverLog(row.type)}>
</Button>
</Space>
);
};
const activeLogRow = useMemo(() => {
if (!logDriverType) {
@@ -1259,6 +1035,93 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
return `${rows.length} 个驱动`;
}, [filteredRows.length, normalizedSearchKeyword, rows.length]);
const statusSummary = useMemo(() => {
const optionalRows = rows.filter((row) => !row.builtIn);
return {
total: rows.length,
enabled: optionalRows.filter((row) => row.connectable).length,
needsUpdate: optionalRows.filter((row) => row.needsUpdate).length,
notEnabled: optionalRows.filter((row) => !row.connectable && !row.packageInstalled).length,
};
}, [rows]);
const renderDriverCard = (row: DriverStatusRow) => {
const progress = resolveDriverProgress(row);
const hasActiveProgress = !!progressMap[row.type] || row.connectable || row.packageInstalled;
const issueText = String(row.updateReason || row.message || '').trim();
const affectedText = row.affectedConnections && row.affectedConnections > 0
? `影响 ${row.affectedConnections} 个已保存连接`
: '';
return (
<div
key={row.type}
className={[
'driver-manager-card',
row.needsUpdate ? 'driver-manager-card-warning' : '',
row.connectable ? 'driver-manager-card-ready' : '',
].filter(Boolean).join(' ')}
style={{
border: row.needsUpdate
? driverManagerTheme.cardWarningBorder
: (row.connectable ? driverManagerTheme.cardReadyBorder : driverManagerTheme.cardBorder),
background: driverManagerTheme.cardBg,
}}
>
<div className="driver-manager-card-main">
<div className="driver-manager-card-info">
<div className="driver-manager-title-row">
<Text strong className="driver-manager-driver-name">{row.name}</Text>
<Tag>{row.type}</Tag>
{resolveDriverStatusTag(row)}
</div>
<div className="driver-manager-meta-row">
<Text type="secondary">{resolvePackageSizeText(row)}</Text>
<Text type="secondary">{row.installedVersion || row.pinnedVersion || '-'}</Text>
{affectedText ? <Text type="secondary">{affectedText}</Text> : null}
</div>
{row.needsUpdate && issueText ? (
<div className="driver-manager-update-note" style={managerUpdateNoteStyle}>
<Text strong type="warning"></Text>
<Paragraph
className="driver-manager-note-text"
ellipsis={{ rows: 2, expandable: true, symbol: '展开原因' }}
>
{issueText}
</Paragraph>
</div>
) : issueText ? (
<Paragraph
className="driver-manager-muted-message"
type="secondary"
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{issueText}
</Paragraph>
) : null}
</div>
<div className="driver-manager-card-controls">
<div className="driver-manager-control-block">
<Text type="secondary" className="driver-manager-control-label"></Text>
{renderVersionControl(row)}
</div>
<div className="driver-manager-control-block">
<Text type="secondary" className="driver-manager-control-label"></Text>
{row.builtIn ? (
<Text type="secondary"></Text>
) : hasActiveProgress ? (
<Progress percent={progress.percent} status={progress.status} size="small" />
) : (
<Progress percent={0} size="small" />
)}
</div>
{renderDriverActions(row)}
</div>
</div>
</div>
);
};
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
@@ -1286,44 +1149,54 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
title="驱动管理"
open={open}
onCancel={onClose}
width={980}
width={1120}
style={{ top: 24 }}
className="driver-manager-modal"
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
},
body: modalBodyStyle,
}}
destroyOnHidden
footer={(
<div className="driver-manager-footer">
<div
ref={externalHScrollRef}
className="driver-manager-hscroll"
aria-hidden={false}
onScroll={applyExternalScrollToTableTargets}
>
<div className="driver-manager-hscroll-inner" style={{ width: `${Math.max(horizontalScrollWidth, 1)}px` }} />
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
</div>
<Space className="driver-manager-footer-actions" size={8}>
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>
<Button key="close" type="primary" onClick={onClose}>
</Button>
</Space>
)}
>
<div ref={modalContentRef}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
<div className="driver-manager-shell" data-driver-theme={driverManagerTheme.isDark ? 'dark' : 'light'}>
<div className="driver-manager-header" style={managerSectionStyle}>
<div className="driver-manager-heading">
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
<Text type="secondary">GoNavi agent </Text>
</div>
<div className="driver-manager-stats">
<div className="driver-manager-stat" style={managerStatStyle}>
<span>{statusSummary.total}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat" style={managerStatStyle}>
<span>{statusSummary.enabled}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat driver-manager-stat-warning" style={managerStatStyle}>
<span style={{ color: driverManagerTheme.warningText }}>{statusSummary.needsUpdate}</span>
<Text type="secondary"></Text>
</div>
<div className="driver-manager-stat" style={managerStatStyle}>
<span>{statusSummary.notEnabled}</span>
<Text type="secondary"></Text>
</div>
</div>
</div>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{networkStatus ? (
networkUnreachable ? (
<Alert
@@ -1399,51 +1272,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
/>
)}
<Alert
type="info"
showIcon
icon={sharedInfoAlertIcon}
message="驱动目录与复用说明"
description={(
<Collapse
size="small"
items={[
{
key: 'driver-directory',
label: '查看驱动目录与复用说明',
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
<div className="driver-manager-directory-panel" style={managerSectionStyle}>
<Collapse
size="small"
ghost
items={[
{
key: 'driver-directory',
label: '驱动目录与手动导入说明',
children: (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_DIRECTORY_HELP}</Text>
<Text type="secondary">{DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP}</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
<Button icon={<FolderOpenOutlined />} onClick={() => void openDriverDirectory()}>
</Button>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
),
},
]}
/>
)}
/>
) : null}
</Space>
),
},
]}
/>
</div>
<div style={{ width: '100%', display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="driver-manager-toolbar">
<Input.Search
allowClear
placeholder="搜索驱动名称/类型(如 DuckDB、clickhouse"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
style={{ minWidth: 300, flex: '1 1 360px' }}
className="driver-manager-search"
/>
<Space size={8}>
<Space size={8} wrap className="driver-manager-toolbar-actions">
<Text type="secondary"></Text>
<Switch
checked={forceOverwriteInstalled}
@@ -1465,30 +1330,22 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
</Button>
</Space>
</div>
<Text type="secondary">{filterSummaryText}</Text>
<div
ref={tableContainerRef}
className="driver-manager-table-wrap driver-manager-table-wrap-external-active"
>
<Table
className="driver-manager-table"
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={filteredRows}
pagination={false}
size="middle"
sticky={false}
scroll={{ x: DRIVER_TABLE_SCROLL_X }}
locale={{
emptyText: normalizedSearchKeyword
? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动`
: '暂无驱动数据',
}}
/>
<div className="driver-manager-list-head">
<Text type="secondary">{filterSummaryText}</Text>
{loading ? <Text type="secondary">...</Text> : null}
</div>
</Space>
<div className="driver-manager-list">
{filteredRows.length > 0 ? (
filteredRows.map(renderDriverCard)
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={normalizedSearchKeyword ? `未找到匹配“${String(searchKeyword || '').trim()}”的驱动` : '暂无驱动数据'}
/>
)}
</div>
</Space>
</div>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}

View File

@@ -216,7 +216,7 @@ describe('QueryEditor external SQL save', () => {
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer: ReactTestRenderer;
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
@@ -240,7 +240,7 @@ describe('QueryEditor external SQL save', () => {
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer: ReactTestRenderer;
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
@@ -272,7 +272,7 @@ describe('QueryEditor external SQL save', () => {
},
];
let renderer: ReactTestRenderer;
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
@@ -412,6 +412,49 @@ describe('QueryEditor external SQL save', () => {
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('rewrites Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'WAFER_ID', key: '' }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT * FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG');
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG gonavi_query_source');
expect(executedSql).not.toContain('__gonavi_query_source__');
expect(executedSql).not.toContain('SELECT *, ROWID AS');
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer?.unmount();
});
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
@@ -494,12 +537,14 @@ describe('QueryEditor external SQL save', () => {
it.each([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',

View File

@@ -9,8 +9,8 @@ import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
@@ -178,8 +178,13 @@ const SQL_FUNCTIONS: { name: string; detail: string }[] = [
{ name: 'SLEEP', detail: '工具 - 延时' },
];
// 模块级标志:确保 SQL completion provider 全局只注册一次
let sqlCompletionRegistered = false;
// HMR 重载时释放旧注册避免补全项重复
const _g = globalThis as any;
if (!_g.__gonaviSqlCompletionState) {
_g.__gonaviSqlCompletionState = { registered: false, disposables: [] as any[] };
}
let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered;
let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
// 模块级共享变量completion provider 从这些变量读取当前活跃 Tab 的状态。
// 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。
@@ -203,6 +208,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
type SimpleSelectInfo = {
selectsAll: boolean;
selectsBareAll: boolean;
writableColumns: Record<string, string>;
};
@@ -282,6 +288,7 @@ const splitTopLevelComma = (text: string): string[] => {
const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/;
const QUERY_ALIAS_RESERVED = new Set([
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union',
'for', 'connect', 'start', 'window', 'sample', 'pivot', 'unpivot', 'qualify', 'model',
]);
const getLastIdentifierPart = (path: string): string => {
@@ -325,16 +332,21 @@ const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => {
const writableColumns: Record<string, string> = {};
let selectsAll = false;
let selectsBareAll = false;
for (const item of splitTopLevelComma(selectList)) {
const trimmedItem = String(item || '').trim();
const resolved = resolveSimpleSelectItemColumn(item);
if (!resolved) continue;
if (resolved === 'all') {
selectsAll = true;
if (trimmedItem === '*') {
selectsBareAll = true;
}
continue;
}
writableColumns[resolved.resultName] = resolved.sourceName;
}
return { selectsAll, writableColumns };
return { selectsAll, selectsBareAll, writableColumns };
};
const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => {
@@ -345,6 +357,89 @@ const appendQuerySelectExpressions = (sql: string, expressions: string[]): strin
);
};
const QUERY_LOCATOR_SOURCE_ALIAS = 'gonavi_query_source';
const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[]): string | undefined => {
if (expressions.length === 0) return undefined;
const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+)([\s\S]*)$/i);
if (!match) return undefined;
const prefix = match[1];
const selectList = match[2].trim();
const fromKeyword = match[3];
const fromTail = match[4];
const selectItems = splitTopLevelComma(selectList);
if (selectItems.length === 0) return undefined;
let selectAllFound = false;
for (const item of selectItems) {
if (String(item || '').trim() === '*') {
selectAllFound = true;
break;
}
}
if (!selectAllFound) return undefined;
const fromTrimmed = fromTail.trimStart();
const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/);
if (!tableMatch) return undefined;
const tableText = tableMatch[1];
const afterTable = tableMatch[2] || '';
const parseAlias = (tail: string): { alias: string; remainder: string } => {
const trimmedTail = String(tail || '').trimStart();
if (!trimmedTail) {
return { alias: '', remainder: tail };
}
const asMatch = trimmedTail.match(/^AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/i);
if (asMatch) {
const candidate = stripQueryIdentifierQuotes(asMatch[1]);
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
return { alias: candidate, remainder: asMatch[2] || '' };
}
}
const bareMatch = trimmedTail.match(/^([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/);
if (bareMatch) {
const candidate = stripQueryIdentifierQuotes(bareMatch[1]);
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
return { alias: candidate, remainder: bareMatch[2] || '' };
}
}
return { alias: '', remainder: tail };
};
const parsedAlias = parseAlias(afterTable);
const sourceAlias = parsedAlias.alias || QUERY_LOCATOR_SOURCE_ALIAS;
const qualifiedExpressions = expressions
.map((expression) => {
const trimmed = String(expression || '').trim();
if (!trimmed) return '';
if (/^ROWID\b/i.test(trimmed)) {
return trimmed.replace(/^(\s*)ROWID\b/i, `$1${sourceAlias}.ROWID`);
}
return trimmed;
})
.filter(Boolean);
if (qualifiedExpressions.length === 0) return undefined;
const rewrittenSelectItems = selectItems.map((item) => {
const trimmed = String(item || '').trim();
if (trimmed === '*') {
return `${sourceAlias}.*`;
}
return item.trimEnd();
});
const aliasClause = parsedAlias.alias ? ` ${parsedAlias.alias}` : ` ${sourceAlias}`;
const finalSelectItems = [...rewrittenSelectItems, ...qualifiedExpressions];
return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`;
};
const findWritableResultColumnForSource = (writableColumns: Record<string, string>, target: string): string | undefined => {
const normalizedTarget = String(target || '').trim().toLowerCase();
return Object.entries(writableColumns || {}).find(([, sourceColumn]) => (
@@ -361,8 +456,8 @@ const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias
`${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}`
);
const buildQueryRowIDExpression = (dbType: string): string => (
`ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string => (
`${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
);
const resolveQueryLocatorPlan = async ({
@@ -428,6 +523,7 @@ const resolveQueryLocatorPlan = async ({
});
const appendExpressions: string[] = [];
const hiddenColumns: string[] = [];
let needsOracleRowIDExpression = false;
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
const valueColumns = locatorColumns.map((column, index) => {
@@ -457,7 +553,7 @@ const resolveQueryLocatorPlan = async ({
if (uniqueKeyGroup) {
plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup);
} else if (isOracleLikeDialect(dbType)) {
appendExpressions.push(buildQueryRowIDExpression(dbType));
needsOracleRowIDExpression = true;
plan.editLocator = {
strategy: 'oracle-rowid',
columns: ['ROWID'],
@@ -475,7 +571,25 @@ const resolveQueryLocatorPlan = async ({
}
}
plan.executedSql = appendQuerySelectExpressions(statement, appendExpressions);
const executableAppendExpressions = [
...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []),
...appendExpressions,
];
if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) {
const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions);
if (rewritten) {
plan.executedSql = rewritten;
return plan;
}
const reason = 'Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions);
return plan;
} catch {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
@@ -522,6 +636,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const [editorHeight, setEditorHeight] = useState(300);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const runQueryActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(tab.query || '');
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
@@ -809,10 +924,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
// 全局只注册一次 SQL completion provider避免多 tab 重复注册导致补全项重复
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
const runBinding = shortcutOptions.runQuery;
if (runBinding?.enabled && runBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
runBinding.combo, monaco.KeyMod, monaco.KeyCode
);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
}
// HMR 重载时释放旧注册避免补全项重复
if (!sqlCompletionRegistered) {
sqlCompletionRegistered = true;
monaco.languages.registerCompletionItemProvider('sql', {
_g.__gonaviSqlCompletionState.registered = true;
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
sqlCompletionDisposables.length = 0;
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.'],
provideCompletionItems: async (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
@@ -826,6 +962,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
{ oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol },
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
@@ -1218,7 +1355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
];
return { suggestions };
}
});
}));
// 注册 / 斜杠命令 AI 快捷补全
const slashCmdDefs = [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
@@ -1233,7 +1370,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 全局变量存储命令定义,供 onDidChangeModelContent 使用
(window as any).__gonaviSlashCmdDefs = slashCmdDefs;
monaco.languages.registerCompletionItemProvider('sql', {
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['/'],
provideCompletionItems: (model: any, position: any) => {
const lineContent = model.getLineContent(position.lineNumber);
@@ -1260,6 +1397,42 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
})),
};
},
}));
// SQL snippet completion provider
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const prefix = word.word.toLowerCase();
if (!prefix) return { suggestions: [] };
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const allSnippets = useStore.getState().sqlSnippets;
const matched = allSnippets.filter(s =>
s.prefix.toLowerCase().startsWith(prefix) ||
s.name.toLowerCase().includes(prefix)
);
return {
suggestions: matched.map(s => ({
label: s.prefix,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: s.body,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: s.name,
documentation: s.description || s.body,
range,
sortText: '04' + s.prefix,
})),
};
},
});
} // end sqlCompletionRegistered guard
@@ -1352,6 +1525,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
},
{ type: 'divider' },
{
key: 'snippet-settings',
label: '代码片段管理...',
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
},
{
key: 'shortcut-settings',
label: '快捷键管理...',
@@ -1612,7 +1790,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const rpcConfig = buildRpcConnectionConfig(config) as any;
const dbType = String(rpcConfig.type || 'mysql');
const driver = String((config as any).driver || '');
const normalizedDbType = String(resolveSqlDialect(dbType, driver)).trim().toLowerCase();
const normalizedDbType = String(resolveSqlDialect(dbType, driver, {
oceanBaseProtocol: (config as any).oceanBaseProtocol,
})).trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
// MongoDB 仍走逐条执行的旧路径
@@ -2006,12 +2186,46 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
void handleRun();
};
window.addEventListener('keydown', handleRunShortcut);
window.addEventListener('keydown', handleRunShortcut, true);
return () => {
window.removeEventListener('keydown', handleRunShortcut);
window.removeEventListener('keydown', handleRunShortcut, true);
};
}, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]);
// Re-register Monaco internal keybinding when runQuery shortcut changes
useEffect(() => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = shortcutOptions.runQuery;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
return () => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
};
}, [shortcutOptions.runQuery]);
useEffect(() => {
const handleRunActiveQuery = () => {
if (activeTabId !== tab.id) {

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import Sidebar from './Sidebar';
const mocks = vi.hoisted(() => ({
noop: vi.fn(),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [],
savedQueries: [],
externalSQLDirectories: [],
deleteQuery: mocks.noop,
saveExternalSQLDirectory: mocks.noop,
deleteExternalSQLDirectory: mocks.noop,
addConnection: mocks.noop,
addTab: mocks.noop,
tabs: [{
id: 'conn-1-main-users',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
}],
activeTabId: 'conn-1-main-users',
setActiveContext: mocks.noop,
removeConnection: mocks.noop,
connectionTags: [],
addConnectionTag: mocks.noop,
updateConnectionTag: mocks.noop,
removeConnectionTag: mocks.noop,
moveConnectionToTag: mocks.noop,
reorderTags: mocks.noop,
closeTabsByConnection: mocks.noop,
closeTabsByDatabase: mocks.noop,
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
},
tableAccessCount: {},
tableSortPreference: {},
recordTableAccess: mocks.noop,
setTableSortPreference: mocks.noop,
addSqlLog: mocks.noop,
}),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
DBGetDatabases: mocks.noop,
DBGetTables: mocks.noop,
DBQuery: mocks.noop,
DBShowCreateTable: mocks.noop,
ExportTable: mocks.noop,
OpenSQLFile: mocks.noop,
ExecuteSQLFile: mocks.noop,
CancelSQLFileExecution: mocks.noop,
CreateDatabase: mocks.noop,
RenameDatabase: mocks.noop,
DropDatabase: mocks.noop,
RenameTable: mocks.noop,
DropTable: mocks.noop,
DropView: mocks.noop,
DropFunction: mocks.noop,
RenameView: mocks.noop,
SelectSQLDirectory: mocks.noop,
ListSQLDirectory: mocks.noop,
ReadSQLFile: mocks.noop,
JVMProbeCapabilities: mocks.noop,
GetDriverStatusList: mocks.noop,
}));
vi.mock('../../wailsjs/runtime/runtime', () => ({
EventsOn: mocks.noop,
}));
describe('Sidebar locate toolbar', () => {
it('renders the current table locate action in the sidebar toolbar', () => {
const markup = renderToStaticMarkup(<Sidebar />);
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
const locateActionIndex = markup.indexOf('data-sidebar-locate-current-tab-action="true"');
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
expect(markup).toContain('aria-label="定位当前打开表"');
expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex);
});
});

View File

@@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
CheckOutlined,
FilterOutlined,
DashboardOutlined,
WarningOutlined
WarningOutlined,
AimOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
@@ -48,6 +49,14 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import {
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
type SidebarLocateTreeNodeLike,
} from '../utils/sidebarLocate';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
@@ -91,12 +100,31 @@ type DriverStatusSnapshot = {
message?: string;
};
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;
const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql') return 'postgres';
if (normalized === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||
normalized === 'open-gauss' ||
normalized === 'opengauss'
) return 'opengauss';
return normalized;
};
@@ -156,6 +184,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const connectionTags = useStore(state => state.connectionTags);
@@ -179,6 +209,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const disableLocalBackdropFilter = isMacLikePlatform();
const autoFetchVisible = useAutoFetchVisibility();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]);
const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]);
const canLocateActiveTab = !!activeTabLocateRequest;
// Background Helper (Duplicate logic for now, ideally shared)
const getBg = (darkHex: string) => {
@@ -238,16 +271,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
const [treeHeight, setTreeHeight] = useState(500);
const treeContainerRef = useRef<HTMLDivElement>(null);
const treeRef = useRef<any>(null);
const treeDataRef = useRef<TreeNode[]>([]);
useEffect(() => {
treeDataRef.current = treeData;
}, [treeData]);
useEffect(() => {
if (!treeContainerRef.current) return;
@@ -370,6 +409,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}, [autoFetchVisible, externalSQLDirectories, savedQueries]);
useEffect(() => {
const previousSignatures = connectionReloadSignaturesRef.current;
const nextSignatures: Record<string, string> = {};
const staleConnectionIds = new Set<string>();
connections.forEach((conn) => {
const signature = buildConnectionReloadSignature(conn);
nextSignatures[conn.id] = signature;
if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) {
staleConnectionIds.add(conn.id);
}
});
connectionReloadSignaturesRef.current = nextSignatures;
if (staleConnectionIds.size > 0) {
const staleIds = Array.from(staleConnectionIds);
setLoadedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setExpandedKeys((prev) =>
prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))),
);
setConnectionStates((prev) => {
const next = { ...prev };
staleIds.forEach((id) => {
Object.keys(next).forEach((key) => {
if (isConnectionTreeKey(key, id)) {
delete next[key];
}
});
});
return next;
});
staleIds.forEach((id) => {
Array.from(loadingNodesRef.current).forEach((key) => {
if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) {
loadingNodesRef.current.delete(key);
}
});
});
}
setTreeData((prev) => {
const prevMap = new Map<string, TreeNode>();
@@ -390,6 +470,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
const preserveChildren = existing && !staleConnectionIds.has(conn.id);
return {
title: conn.name,
key: conn.id,
@@ -397,7 +478,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
type: 'connection',
dataRef: conn,
isLeaf: false,
children: existing?.children,
children: preserveChildren ? existing.children : undefined,
} as TreeNode;
};
@@ -473,6 +554,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return null;
};
const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
const nextTreeData = updateTreeData(treeDataRef.current, key, children);
treeDataRef.current = nextTreeData;
setTreeData(nextTreeData);
return nextTreeData;
};
const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => {
setExpandedKeys(prev => {
const merged = [...prev];
requiredKeys.forEach(key => {
if (!merged.includes(key)) merged.push(key);
});
return merged;
});
setAutoExpandParent(true);
};
const scrollSidebarTreeToKey = (key: React.Key) => {
const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0);
runAfterFrame(() => {
treeRef.current?.scrollTo?.({ key, align: 'auto' });
runAfterFrame(() => {
const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null;
selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
});
});
};
const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
const icon = (() => {
switch (node.type) {
@@ -525,6 +638,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
'kingbase',
'highgo',
'vastbase',
'opengauss',
'open_gauss',
'open-gauss',
'sqlserver',
'oracle',
'dameng',
@@ -535,6 +651,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
'kingbase',
'highgo',
'vastbase',
'opengauss',
'open_gauss',
'open-gauss',
'sqlserver',
'oracle',
'dm',
@@ -563,9 +682,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -730,7 +852,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'kingbase':
case 'highgo':
case 'vastbase':
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_%' ORDER BY schemaname, viewname` }];
case 'opengauss':
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` }];
@@ -774,7 +897,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'kingbase':
case 'highgo':
case 'vastbase':
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_%' ORDER BY event_object_schema, event_object_table, trigger_name` }];
case 'opengauss':
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` }];
@@ -821,18 +945,19 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
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_%' ORDER BY n.nspname, routine_type, p.proname`,
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_%' ORDER BY r.routine_schema, routine_type, r.routine_name`,
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_%' ORDER BY n.nspname, p.proname`,
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': {
@@ -1095,12 +1220,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
isLeaf: true,
}));
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setTreeData(origin => updateTreeData(origin, node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]));
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
} else {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
@@ -1111,7 +1236,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
@@ -1143,7 +1268,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
replaceTreeNodeChildren(node.key, dbs);
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
@@ -1177,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
if (dbs.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, dbs));
replaceTreeNodeChildren(node.key, dbs);
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
@@ -1231,7 +1356,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
isLeaf: item.hasChildren !== true,
}));
setTreeData(origin => updateTreeData(origin, node.key, resourceNodes));
replaceTreeNodeChildren(node.key, resourceNodes);
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
@@ -1542,7 +1667,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]);
} else {
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
@@ -1551,7 +1676,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
];
setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...groupedNodes]);
}
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
@@ -1565,6 +1690,102 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
const waitForSidebarLoadKey = async (loadKey: string) => {
for (let attempt = 0; attempt < 30 && loadingNodesRef.current.has(loadKey); attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 50));
}
};
const locateObjectInSidebar = async (detail: unknown) => {
const request = normalizeSidebarLocateObjectRequest(detail);
if (!request) {
message.warning('当前标签页没有可定位的表上下文');
return;
}
const conn = connections.find(item => item.id === request.connectionId);
if (!conn) {
message.warning('未找到当前表对应的连接');
return;
}
const target = resolveSidebarLocateTarget(request, {
groupBySchema: shouldHideSchemaPrefix(conn),
});
const objectLabel = request.objectGroup === 'views' ? '视图' : '表';
let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
const dbLoadKey = `dbs-${request.connectionId}`;
const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`;
if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) {
const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey);
if (!connectionNode) {
message.warning('未在左侧树找到当前连接');
return;
}
if (loadingNodesRef.current.has(dbLoadKey)) {
await waitForSidebarLoadKey(dbLoadKey);
} else {
await loadDatabases(connectionNode);
}
}
const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey);
if (!dbNode) {
message.warning(`未在左侧树找到数据库:${request.dbName}`);
return;
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
if (!path) {
if (loadingNodesRef.current.has(tableLoadKey)) {
await waitForSidebarLoadKey(tableLoadKey);
} else {
await loadTables(dbNode);
}
path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target);
}
if (!path) {
message.warning(`${objectLabel}未在左侧树中找到:${request.tableName},请刷新数据库节点后重试`);
return;
}
const targetKey = path[path.length - 1];
const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey);
setSearchValue('');
mergeExpandedTreeKeys(path.slice(0, -1));
setSelectedKeys([targetKey]);
selectedNodesRef.current = targetNode ? [targetNode] : [];
setActiveContext({ connectionId: request.connectionId, dbName: request.dbName });
scrollSidebarTreeToKey(targetKey);
};
const handleLocateActiveTabInSidebar = () => {
if (!activeTabLocateRequest) {
message.warning('当前标签页没有可定位的表上下文');
return;
}
void locateObjectInSidebar(activeTabLocateRequest);
};
useEffect(() => {
locateObjectInSidebarRef.current = locateObjectInSidebar;
});
useEffect(() => {
const handleLocateSidebarObject = (event: Event) => {
void locateObjectInSidebarRef.current((event as CustomEvent).detail);
};
window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
return () => {
window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener);
};
}, []);
const onLoadData = async ({ key, children, dataRef, type }: any) => {
if (type === 'tag') return;
if (children) return;
@@ -1614,7 +1835,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
];
setTreeData(origin => updateTreeData(origin, key, folders));
replaceTreeNodeChildren(key, folders);
}
};
@@ -2605,6 +2826,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
conn?.config?.database,
overrideDatabase,
clearDatabase,
conn?.config?.oceanBaseProtocol,
),
});
};
@@ -2921,7 +3143,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'mysql':
query = `SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
const parts = viewName.split('.');
const schema = parts.length > 1 ? parts[0] : 'public';
const name = parts.length > 1 ? parts[1] : viewName;
@@ -2977,7 +3199,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'mysql':
template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase':
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
break;
case 'sqlserver':
@@ -3088,7 +3310,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
case 'mysql':
query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``;
break;
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': {
case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': {
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;
@@ -3158,7 +3380,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
? `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 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss':
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$$;`;
@@ -3633,7 +3855,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
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}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
replaceTreeNodeChildren(node.key, undefined);
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
@@ -3783,7 +4005,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
// Reset loaded state recursively
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Clear children (undefined to trigger reload)
setTreeData(origin => updateTreeData(origin, node.key, undefined));
replaceTreeNodeChildren(node.key, undefined);
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
@@ -3931,7 +4153,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
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}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
replaceTreeNodeChildren(node.key, undefined);
if (dbConnId && dbName) {
closeTabsByDatabase(dbConnId, dbName);
}
@@ -4180,13 +4402,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onOk: () => {
deleteQuery(q.id);
// 从树中移除节点
setTreeData(origin => {
const removeNode = (list: TreeNode[]): TreeNode[] =>
list
.filter(n => n.key !== node.key)
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
return removeNode(origin);
});
const removeNode = (list: TreeNode[]): TreeNode[] =>
list
.filter(n => n.key !== node.key)
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
const nextTreeData = removeNode(treeDataRef.current);
treeDataRef.current = nextTreeData;
setTreeData(nextTreeData);
message.success('查询已删除');
}
});
@@ -4477,13 +4699,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="运行外部SQL文件">
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
<Button
size="small"
type="text"
icon={<FileAddOutlined />}
data-sidebar-open-external-sql-file-action="true"
onClick={handleOpenSQLFileFromToolbar}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</Tooltip>
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
<span>
<Button
size="small"
type="text"
icon={<AimOutlined />}
aria-label="定位当前打开表"
data-sidebar-locate-current-tab-action="true"
disabled={!canLocateActiveTab}
onClick={handleLocateActiveTabInSidebar}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</span>
</Tooltip>
</div>
<div ref={treeContainerRef} className="sidebar-tree-scroll-shell" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<div className="sidebar-tree-scroll-content">
<Tree
ref={treeRef}
showIcon
draggable={{
icon: false,

View File

@@ -0,0 +1,422 @@
import { useState, useMemo, useCallback } from 'react';
import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd';
import {
PlusOutlined,
DeleteOutlined,
UndoOutlined,
SaveOutlined,
CodeOutlined,
} from '@ant-design/icons';
import { v4 as uuidv4 } from 'uuid';
import type { SqlSnippet } from '../types';
import { useStore } from '../store';
import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface SnippetSettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
type DraftSnippet = Omit<SqlSnippet, 'createdAt'> & { createdAt?: number };
const emptyDraft = (): DraftSnippet => ({
id: uuidv4(),
prefix: '',
name: '',
description: '',
body: '',
isBuiltin: false,
});
export default function SnippetSettingsModal({
open,
onClose,
darkMode,
overlayTheme,
}: SnippetSettingsModalProps) {
const sqlSnippets = useStore((s) => s.sqlSnippets);
const saveSqlSnippet = useStore((s) => s.saveSqlSnippet);
const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet);
const resetBuiltinSqlSnippet = useStore((s) => s.resetBuiltinSqlSnippet);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftSnippet>(emptyDraft());
const [isCreating, setIsCreating] = useState(false);
const shellStyle = useMemo(
() => ({
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}),
[overlayTheme],
);
const panelStyle = useMemo(
() => ({
padding: 16,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}),
[overlayTheme],
);
const textColor = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(16,24,40,0.9)';
const mutedColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const sortedSnippets = useMemo(
() => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)),
[sqlSnippets],
);
const selectedSnippet = useMemo(
() => sqlSnippets.find((s) => s.id === selectedId) ?? null,
[sqlSnippets, selectedId],
);
const handleSelect = useCallback(
(snippet: SqlSnippet) => {
setIsCreating(false);
setSelectedId(snippet.id);
setDraft({ ...snippet });
},
[],
);
const handleNew = useCallback(() => {
setIsCreating(true);
setSelectedId(null);
setDraft(emptyDraft());
}, []);
const handleSave = useCallback(() => {
const prefix = draft.prefix.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20);
if (!prefix) {
void message.warning('前缀不能为空');
return;
}
if (!draft.name.trim()) {
void message.warning('名称不能为空');
return;
}
if (!draft.body.trim()) {
void message.warning('片段内容不能为空');
return;
}
const duplicate = sqlSnippets.find(
(s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id,
);
if (duplicate) {
void message.warning(`前缀 "${prefix}" 已被其他片段使用`);
return;
}
const toSave: SqlSnippet = {
id: draft.id,
prefix,
name: draft.name.trim(),
description: draft.description?.trim() || undefined,
body: draft.body,
isBuiltin: draft.isBuiltin,
createdAt: draft.createdAt ?? Date.now(),
};
saveSqlSnippet(toSave);
setSelectedId(toSave.id);
setIsCreating(false);
void message.success('片段已保存');
}, [draft, sqlSnippets, saveSqlSnippet]);
const handleDelete = useCallback(
(id: string) => {
deleteSqlSnippet(id);
if (selectedId === id) {
setSelectedId(null);
setDraft(emptyDraft());
}
void message.success('片段已删除');
},
[deleteSqlSnippet, selectedId],
);
const handleReset = useCallback(
(id: string) => {
resetBuiltinSqlSnippet(id);
const original = BUILTIN_SNIPPET_MAP[id];
if (original && selectedId === id) {
setDraft({ ...original });
}
void message.success('已重置为默认');
},
[resetBuiltinSqlSnippet, selectedId],
);
const syntaxHelpItems = [
{
key: 'syntax',
label: '片段语法说明',
children: (
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'monospace' }}>
<div>{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}</div>
<div>{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}</div>
<div>{'$0 最终光标位置'}</div>
<div>{'${1:表名} 同一数字在多处出现时会同步编辑'}</div>
<div style={{ marginTop: 6, fontWeight: 600, color: textColor }}>{'内置变量(展开时自动替换为实际值):'}</div>
<div>{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}</div>
<div>{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}</div>
<div>{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}</div>
<div>{'${UUID} 随机 UUID'}</div>
<div>{'${RANDOM} 6 位随机数'}</div>
<div style={{ marginTop: 8, fontFamily: 'inherit', color: textColor }}>
{'示例SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'}
</div>
</div>
),
},
];
const showEditor = isCreating || selectedSnippet;
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
}}
>
<CodeOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: textColor }}></div>
<div style={{ fontSize: 12, color: mutedColor, lineHeight: 1.5 }}>
SQL Tab
</div>
</div>
</div>
}
open={open}
onCancel={onClose}
width={820}
styles={{
content: shellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 40 },
}}
footer={[
<Button key="close" type="primary" onClick={onClose}>
</Button>,
]}
>
<div style={{ display: 'flex', gap: 16, minHeight: 420 }}>
{/* Left: snippet list */}
<div
style={{
width: 220,
flexShrink: 0,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ padding: '8px 12px 4px', fontSize: 12, color: mutedColor, fontWeight: 600 }}>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
<List
size="small"
dataSource={sortedSnippets}
renderItem={(snippet) => (
<List.Item
onClick={() => handleSelect(snippet)}
style={{
cursor: 'pointer',
padding: '6px 12px',
background: selectedId === snippet.id ? selectedBg : 'transparent',
borderLeft:
selectedId === snippet.id
? `3px solid ${overlayTheme.iconBg}`
: '3px solid transparent',
transition: 'all 0.15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<Typography.Text
code
style={{ fontSize: 12, flexShrink: 0, color: textColor }}
>
{snippet.prefix}
</Typography.Text>
<span
style={{
fontSize: 12,
color: textColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{snippet.name}
</span>
{snippet.isBuiltin && (
<Tag
style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 4px',
margin: 0,
borderRadius: 4,
}}
color="blue"
>
</Tag>
)}
</div>
</List.Item>
)}
/>
</div>
<div style={{ padding: 8 }}>
<Button type="dashed" icon={<PlusOutlined />} block size="small" onClick={handleNew}>
</Button>
</div>
</div>
{/* Right: editor */}
<div style={{ flex: 1, minWidth: 0 }}>
{showEditor ? (
<div
style={{
...panelStyle,
display: 'flex',
flexDirection: 'column',
gap: 12,
height: '100%',
}}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 0.4 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.prefix}
onChange={(e) =>
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() }))
}
placeholder="如 sel, ins"
maxLength={20}
size="small"
/>
</div>
<div style={{ flex: 0.6 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="片段显示名称"
maxLength={60}
size="small"
/>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.description || ''}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
placeholder="补全详情中的描述文字"
maxLength={200}
size="small"
/>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input.TextArea
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
placeholder={'SELECT ${1:columns} FROM ${2:table_name}$0;'}
style={{
flex: 1,
minHeight: 120,
fontFamily: 'monospace',
fontSize: 13,
resize: 'none',
}}
/>
<Collapse
size="small"
items={syntaxHelpItems}
style={{ marginTop: 8, background: 'transparent' }}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
{draft.isBuiltin && draft.createdAt && (
<Popconfirm
title="重置为默认"
description="将恢复此内置片段的原始内容"
onConfirm={() => handleReset(draft.id)}
>
<Button icon={<UndoOutlined />} size="small">
</Button>
</Popconfirm>
)}
{!draft.isBuiltin && !isCreating && (
<Popconfirm
title="删除片段"
description="确定要删除此片段吗?"
onConfirm={() => handleDelete(draft.id)}
>
<Button danger icon={<DeleteOutlined />} size="small">
</Button>
</Popconfirm>
)}
<Button type="primary" icon={<SaveOutlined />} size="small" onClick={handleSave}>
</Button>
</div>
</div>
) : (
<div
style={{
...panelStyle,
display: 'grid',
placeItems: 'center',
height: '100%',
color: mutedColor,
fontSize: 13,
}}
>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -836,6 +836,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
if (normalized === 'postgresql' || normalized === 'pg') return 'postgres';
if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver';
if (normalized === 'doris') return 'diros';
if (normalized === 'open_gauss' || normalized === 'open-gauss') return 'opengauss';
return normalized;
};
@@ -861,7 +862,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const conn = connections.find(c => c.id === tab.connectionId);
const rawType = String(conn?.config?.type || '').trim();
if (!rawType) return '';
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''));
return resolveSqlDialect(rawType, String(conn?.config?.driver || ''), {
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
});
};
const generateTriggerTemplate = (): string => {
@@ -871,6 +874,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'diros':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
@@ -882,6 +886,7 @@ END;`;
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return `CREATE OR REPLACE FUNCTION trigger_function_name()
RETURNS TRIGGER AS $$
BEGIN
@@ -931,12 +936,14 @@ END;`;
switch (dbType) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'diros':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;

View File

@@ -52,14 +52,17 @@ const formatRows = (count: number): string => {
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const getMetadataDialect = (connType: string, driver?: string, oceanBaseProtocol?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
if (d === 'oceanbase') return 'mysql';
if (d === 'opengauss' || d === 'open_gauss' || d === 'open-gauss') return 'opengauss';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -85,7 +88,8 @@ ORDER BY table_name`;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo': {
case 'highgo':
case 'opengauss': {
const schema = schemaName || 'public';
return `
SELECT
@@ -180,8 +184,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
[connection?.config?.driver, connection?.config?.type]
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver, connection?.config?.oceanBaseProtocol),
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
);
const autoFetchVisible = useAutoFetchVisibility();

View File

@@ -29,9 +29,12 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
if (driver === 'oceanbase') return 'mysql';
if (driver === 'opengauss' || driver === 'open_gauss' || driver === 'open-gauss') return 'opengauss';
return driver;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'oceanbase' && String(conn?.config?.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') return 'oracle';
if (type === 'mariadb' || type === 'oceanbase' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
@@ -62,6 +65,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
@@ -179,7 +183,8 @@ LIMIT 1`];
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
case 'vastbase':
case 'opengauss': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'sqlserver': {

View File

@@ -1,6 +1,6 @@
const AUTO_FIT_DEFAULT_MIN_WIDTH = 80;
const AUTO_FIT_DEFAULT_MAX_WIDTH = 720;
const AUTO_FIT_DEFAULT_PADDING = 40;
const AUTO_FIT_DEFAULT_PADDING = 20;
const AUTO_FIT_DEFAULT_SAMPLE_LIMIT = 200;
const AUTO_FIT_MAX_PREVIEW_CHARS = 120;

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
buildClipboardCsv,
buildClipboardJson,
buildClipboardMarkdown,
pickRowsForClipboard,
} from './dataGridClipboardExport';
describe('dataGridClipboardExport', () => {
it('copies aggregate query rows without treating aggregate columns as table fields', () => {
const rows = pickRowsForClipboard({
rows: [
{ __gonavi_row_key__: 0, 'COUNT(*)': 12, 'sum(price)': 99.5 },
],
selectedRowKeys: [],
columnNames: ['COUNT(*)', 'sum(price)'],
rowKeyField: '__gonavi_row_key__',
});
expect(rows).toEqual([{ 'COUNT(*)': 12, 'sum(price)': 99.5 }]);
expect(buildClipboardCsv(rows, ['COUNT(*)', 'sum(price)'])).toBe('"COUNT(*)","sum(price)"\n"12","99.5"');
expect(buildClipboardMarkdown(rows, ['COUNT(*)', 'sum(price)'])).toBe('| COUNT(*) | sum(price) |\n| --- | --- |\n| 12 | 99.5 |');
expect(buildClipboardJson(rows)).toBe('[\n {\n "COUNT(*)": 12,\n "sum(price)": 99.5\n }\n]');
});
it('copies only selected rows when row selection exists', () => {
const rows = pickRowsForClipboard({
rows: [
{ __gonavi_row_key__: 'row-1', total: 1 },
{ __gonavi_row_key__: 'row-2', total: 2 },
],
selectedRowKeys: ['row-2'],
columnNames: ['total'],
rowKeyField: '__gonavi_row_key__',
});
expect(rows).toEqual([{ total: 2 }]);
});
it('keeps copied row fields in the provided display column order', () => {
const rows = pickRowsForClipboard({
rows: [
{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
],
selectedRowKeys: [],
columnNames: ['name', 'id'],
rowKeyField: '__gonavi_row_key__',
});
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
expect(buildClipboardCsv(rows, ['name', 'id'])).toBe('"name","id"\n"alpha","1"');
expect(buildClipboardJson(rows)).toBe('[\n {\n "name": "alpha",\n "id": 1\n }\n]');
});
});

View File

@@ -0,0 +1,83 @@
type RowKeyToString = (key: any) => string;
const defaultRowKeyToString: RowKeyToString = (key: unknown) => String(key);
const normalizeClipboardValue = (value: unknown): string => {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'string') return value;
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
};
const escapeCsvCell = (value: unknown): string => {
const text = normalizeClipboardValue(value).replace(/"/g, '""');
return `"${text}"`;
};
const escapeMarkdownCell = (value: unknown): string => (
normalizeClipboardValue(value)
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
);
export const pickRowsForClipboard = ({
rows,
selectedRowKeys = [],
columnNames,
rowKeyField,
rowKeyToString = defaultRowKeyToString,
}: {
rows: Array<Record<string, unknown>>;
selectedRowKeys?: unknown[];
columnNames: string[];
rowKeyField: string;
rowKeyToString?: RowKeyToString;
}): Array<Record<string, unknown>> => {
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
return [];
}
const selected = new Set((selectedRowKeys || []).map(rowKeyToString));
const sourceRows = selected.size > 0
? rows.filter((row) => selected.has(rowKeyToString(row?.[rowKeyField])))
: rows;
return sourceRows.map((row) => {
const next: Record<string, unknown> = {};
columnNames.forEach((columnName) => {
if (!columnName || columnName === rowKeyField) return;
next[columnName] = row?.[columnName];
});
return next;
});
};
export const buildClipboardCsv = (rows: Array<Record<string, unknown>>, columnNames: string[]): string => {
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
return '';
}
const header = columnNames.map(escapeCsvCell).join(',');
const lines = rows.map((row) => columnNames.map((columnName) => escapeCsvCell(row?.[columnName])).join(','));
return [header, ...lines].join('\n');
};
export const buildClipboardMarkdown = (rows: Array<Record<string, unknown>>, columnNames: string[]): string => {
if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) {
return '';
}
const header = `| ${columnNames.map(escapeMarkdownCell).join(' | ')} |`;
const separator = `| ${columnNames.map(() => '---').join(' | ')} |`;
const lines = rows.map((row) => `| ${columnNames.map((columnName) => escapeMarkdownCell(row?.[columnName])).join(' | ')} |`);
return [header, separator, ...lines].join('\n');
};
export const buildClipboardJson = (rows: Array<Record<string, unknown>>): string => {
if (!Array.isArray(rows) || rows.length === 0) return '';
return JSON.stringify(rows, null, 2);
};

View File

@@ -46,6 +46,38 @@ describe('buildCopyInsertSQL', () => {
);
});
it('preserves fractional seconds for MySQL datetime precision columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'mysql',
tableName: 'events',
orderedCols: ['created_at'],
record: {
created_at: '2026-05-10T09:12:33.456+08:00',
},
columnTypesByLowerName: {
created_at: 'datetime(3)',
},
});
expect(sql).toBe(
"INSERT INTO `events` (`created_at`) VALUES ('2026-05-10 09:12:33.456');",
);
});
it('uses ordered columns for copy-as-insert output', () => {
const sql = buildCopyInsertSQL({
dbType: 'mysql',
tableName: 'users',
orderedCols: ['name', 'id'],
record: {
id: 7,
name: 'Ada',
},
});
expect(sql).toBe("INSERT INTO `users` (`name`, `id`) VALUES ('Ada', '7');");
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',

View File

@@ -51,9 +51,9 @@ const normalizeDateTimeString = (val: string): string => {
}
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_\/+-]+)?)?$/
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
return match ? `${match[1]} ${match[2]}${match[3] || ''}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
@@ -66,13 +66,14 @@ const normalizeTimezoneAwareDateTimeString = (val: string): string => {
}
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_\/+-]+)?)?$/
/^(\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 val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
const fractional = match[3] || '';
const suffix = match[4] || '';
return `${match[1]} ${match[2]}${fractional}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
@@ -165,22 +166,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => {
return String(value);
};
const hasFractionalSeconds = (value: string): boolean => /\d{2}:\d{2}:\d{2}\.\d+/.test(value);
const stripFractionalSeconds = (value: string): string => (
value.replace(/(\d{2}:\d{2}:\d{2})\.\d+/, '$1')
);
const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => {
if (!isTemporalColumnType(columnType)) {
return null;
}
const normalized = toNormalizedLiteralText(value, columnType);
const escaped = escapeLiteral(normalized);
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
const rawType = String(columnType || '').toLowerCase();
const isTimestamp = rawType.includes('timestamp');
const oracleValue = isTimestamp ? normalized : stripFractionalSeconds(normalized);
const escaped = escapeLiteral(oracleValue);
if (/^\d{4}-\d{2}-\d{2}$/.test(oracleValue)) {
return `TO_DATE('${escaped}', 'YYYY-MM-DD')`;
}
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) {
const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`;
if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(oracleValue)) {
const compactOffset = oracleValue.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2');
const temporalFormat = hasFractionalSeconds(oracleValue)
? 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM'
: 'YYYY-MM-DD HH24:MI:SSTZH:TZM';
return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', '${temporalFormat}')`;
}
const rawType = String(columnType || '').toLowerCase();
if (rawType.includes('timestamp')) {
return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
if (isTimestamp) {
const temporalFormat = hasFractionalSeconds(oracleValue)
? 'YYYY-MM-DD HH24:MI:SS.FF'
: 'YYYY-MM-DD HH24:MI:SS';
return `TO_TIMESTAMP('${escaped}', '${temporalFormat}')`;
}
return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`;
};

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import {
buildDataGridSelectBaseSql,
pickDataGridOutputRows,
resolveDataGridOutputColumnNames,
} from './dataGridOutput';
const rowKeyField = '__gonavi_row_key__';
describe('dataGridOutput helpers', () => {
it('resolves exportable columns in display order without the internal row key', () => {
expect(resolveDataGridOutputColumnNames(['name', rowKeyField, 'id'], rowKeyField)).toEqual(['name', 'id']);
});
it('keeps exact column names when resolving output order', () => {
expect(resolveDataGridOutputColumnNames([' full name ', 'id'], rowKeyField)).toEqual([' full name ', 'id']);
});
it('picks row values in display column order', () => {
const rows = pickDataGridOutputRows([
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
], ['name', 'id']);
expect(Object.keys(rows[0])).toEqual(['name', 'id']);
expect(rows[0]).toEqual({ name: 'alpha', id: 1 });
});
it('builds table SELECT SQL with explicit display columns', () => {
expect(buildDataGridSelectBaseSql({
dbType: 'mysql',
tableName: 'users',
columnNames: ['name', 'id'],
whereSql: "WHERE `id` = '7'",
})).toBe("SELECT `name`, `id` FROM `users` WHERE `id` = '7'");
});
});

View File

@@ -0,0 +1,41 @@
import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
export const resolveDataGridOutputColumnNames = (
displayColumnNames: string[],
rowKeyField: string,
): string[] => (
(displayColumnNames || [])
.map((columnName) => String(columnName ?? ''))
.filter((columnName) => columnName && columnName !== rowKeyField)
);
export const pickDataGridOutputRows = (
rows: Array<Record<string, any>>,
columnNames: string[],
): Array<Record<string, any>> => (
(rows || []).map((row) => {
const next: Record<string, any> = {};
(columnNames || []).forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
})
);
export const buildDataGridSelectBaseSql = ({
dbType,
tableName,
columnNames,
whereSql = '',
}: {
dbType: string;
tableName: string;
columnNames: string[];
whereSql?: string;
}): string => {
const selectList = columnNames.length > 0
? columnNames.map((columnName) => quoteIdentPart(dbType, columnName)).join(', ')
: '*';
const wherePart = String(whereSql || '').trim();
return `SELECT ${selectList} FROM ${quoteQualifiedIdent(dbType, tableName)}${wherePart ? ` ${wherePart}` : ''}`;
};

View File

@@ -22,6 +22,20 @@ describe('dataGridRowClipboard', () => {
]);
});
it('copies row fields in display column order', () => {
const copiedRows = buildCopiedRowsForPaste({
rows: [
{ [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' },
],
selectedRowKeys: ['row-1'],
columnNames: ['name', 'id'],
rowKeyField,
});
expect(Object.keys(copiedRows[0])).toEqual(['name', 'id']);
expect(copiedRows[0]).toEqual({ name: 'alpha', id: 1 });
});
it('builds pasted rows as new rows with fresh internal keys', () => {
const pastedRows = buildPastedRowsFromCopiedRows({
rows: [

View File

@@ -5,7 +5,9 @@ import { supportsTableTruncateAction } from './tableDataDangerActions';
describe('tableDataDangerActions', () => {
it('supports native truncate for known relational dialects', () => {
expect(supportsTableTruncateAction('mysql')).toBe(true);
expect(supportsTableTruncateAction('oceanbase')).toBe(true);
expect(supportsTableTruncateAction('postgres')).toBe(true);
expect(supportsTableTruncateAction('opengauss')).toBe(true);
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
});

View File

@@ -9,6 +9,10 @@ const resolveCustomDriverDialect = (driver: string): string => {
case 'pq':
case 'pgx':
return 'postgres';
case 'opengauss':
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'dm':
case 'dameng':
case 'dm8':
@@ -21,6 +25,8 @@ const resolveCustomDriverDialect = (driver: string): string => {
case 'diros':
case 'doris':
return 'diros';
case 'oceanbase':
return 'oceanbase';
case 'kingbase':
case 'kingbase8':
case 'kingbasees':
@@ -34,7 +40,9 @@ const resolveCustomDriverDialect = (driver: string): string => {
break;
}
if (normalized.includes('opengauss') || normalized.includes('open_gauss') || normalized.includes('open-gauss')) return 'opengauss';
if (normalized.includes('postgres')) return 'postgres';
if (normalized.includes('oceanbase')) return 'oceanbase';
if (normalized.includes('kingbase')) return 'kingbase';
if (normalized.includes('highgo')) return 'highgo';
if (normalized.includes('vastbase')) return 'vastbase';
@@ -56,10 +64,12 @@ export const supportsTableTruncateAction = (type: string, driver?: string): bool
switch (resolveTableDataActionDBType(type, driver)) {
case 'mysql':
case 'mariadb':
case 'oceanbase':
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
case 'sqlserver':
case 'oracle':
case 'dameng':

View File

@@ -162,6 +162,40 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).not.toContain('MODIFY COLUMN');
});
it('builds doris alter preview without mysql-only syntax or metadata extra', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'doris',
tableName: 'sales.orders',
originalColumns: [
baseColumn({
_key: 'carrier',
name: 'carrier_id',
type: 'bigint',
nullable: 'YES',
extra: 'NONE',
comment: '承运商id',
}),
],
columns: [
baseColumn({
_key: 'carrier',
name: 'carrier_code',
type: 'bigint',
nullable: 'YES',
extra: 'NONE',
comment: '承运商id1',
}),
],
}));
expect(sql).toContain('ALTER TABLE `sales`.`orders`\nRENAME COLUMN `carrier_id` `carrier_code`;');
expect(sql).toContain("ALTER TABLE `sales`.`orders`\nMODIFY COLUMN `carrier_code` bigint NULL COMMENT '承运商id1';");
expect(sql).not.toContain('CHANGE COLUMN');
expect(sql).not.toContain('AFTER');
expect(sql).not.toContain(' FIRST');
expect(sql).not.toContain('NONE');
});
it('uses native limited alter syntax for clickhouse and tdengine instead of mysql syntax', () => {
const clickhouseSql = buildAlterTablePreviewSql(buildInput({
dbType: 'clickhouse',
@@ -184,8 +218,8 @@ describe('tableDesignerSchemaSql', () => {
expect(tdengineSql).not.toContain('AFTER');
});
it('treats mariadb doris and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'diros', 'sphinx']) {
it('treats mariadb and sphinx as mysql-family only where mysql syntax is intended', () => {
for (const dbType of ['mariadb', 'sphinx']) {
const sql = buildAlterTablePreviewSql(buildInput({ dbType }));
expect(sql).toContain('ALTER TABLE `users`');
expect(sql).toContain('ADD COLUMN `age` int NULL');

View File

@@ -125,6 +125,37 @@ const buildMySqlColumnDefinition = (column: EditableColumnSnapshot, dbType: stri
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const DORIS_AGG_TYPES = new Set([
'SUM',
'MIN',
'MAX',
'REPLACE',
'REPLACE_IF_NOT_NULL',
'HLL_UNION',
'BITMAP_UNION',
'QUANTILE_UNION',
'GENERIC',
]);
const buildDorisColumnDefinition = (column: EditableColumnSnapshot, dbType: string): string => {
const defaultSql = buildDefaultSql(column.default, dbType);
const autoIncrementSql = column.isAutoIncrement ? 'AUTO_INCREMENT' : '';
const keyText = String(column.key || '').trim().toUpperCase();
const extraText = String(column.extra || '').trim().toUpperCase();
const keyOrAggSql = ['PRI', 'KEY', 'TRUE'].includes(keyText)
? 'KEY'
: (DORIS_AGG_TYPES.has(extraText) ? extraText : '');
return [
quoteIdentifierPart(column.name, dbType),
String(column.type || '').trim(),
keyOrAggSql,
column.nullable === 'NO' ? 'NOT NULL' : 'NULL',
defaultSql,
autoIncrementSql,
`COMMENT '${escapeSqlString(column.comment || '')}'`,
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
};
const buildStandardColumnDefinition = (
column: EditableColumnSnapshot,
dbType: string,
@@ -226,6 +257,44 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: s
return alters.length === 0 ? '' : `ALTER TABLE ${tableName}\n${alters.join(',\n')};`;
};
const buildDorisAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableName = quoteIdentifierPath(input.tableName, dbType);
const statements: string[] = [];
input.originalColumns.forEach((orig) => {
if (!input.columns.find((col) => col._key === orig._key)) {
statements.push(`ALTER TABLE ${tableName}\nDROP COLUMN ${quoteIdentifierPart(orig.name, dbType)};`);
}
});
input.columns.forEach((curr) => {
const orig = input.originalColumns.find((col) => col._key === curr._key);
if (!orig) {
statements.push(`ALTER TABLE ${tableName}\nADD COLUMN ${buildDorisColumnDefinition(curr, dbType)};`);
return;
}
let currentName = orig.name;
if (curr.name !== orig.name) {
statements.push(`ALTER TABLE ${tableName}\nRENAME COLUMN ${quoteIdentifierPart(orig.name, dbType)} ${quoteIdentifierPart(curr.name, dbType)};`);
currentName = curr.name;
}
if (definitionChanged(curr, orig)) {
statements.push(`ALTER TABLE ${tableName}\nMODIFY COLUMN ${buildDorisColumnDefinition({ ...curr, name: currentName }, dbType)};`);
}
});
const origPKKeys = input.originalColumns.filter((col) => col.key === 'PRI').map((col) => col._key);
const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key));
if (keysChanged) {
statements.push('-- Doris 修改主键/Key 模型需要按表模型手工迁移,已避免生成 MySQL 专属的 DROP/ADD PRIMARY KEY。');
}
return statements.join('\n');
};
const buildPgLikeAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: string): string => {
const tableParts = splitQualifiedName(input.tableName);
const baseTableName = tableParts.objectName || stripIdentifierQuotes(input.tableName);
@@ -537,6 +606,7 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
if (isSqlServerDialect(dbType)) return buildSqlServerAlterPreviewSql({ ...input, dbType });
if (dbType === 'sqlite') return buildSqliteAlterPreviewSql({ ...input, dbType });
if (dbType === 'duckdb') return buildDuckDbAlterPreviewSql({ ...input, dbType });
if (dbType === 'diros') return buildDorisAlterPreviewSql({ ...input, dbType }, dbType);
if (dbType === 'clickhouse') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'ClickHouse');
if (dbType === 'tdengine') return buildLimitedBacktickAlterPreviewSql({ ...input, dbType }, dbType, 'TDengine');
if (isMysqlFamilyDialect(dbType)) return buildMySqlAlterPreviewSql({ ...input, dbType }, dbType);

View File

@@ -18,6 +18,9 @@ describe('sidebar tree horizontal scroll css', () => {
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*width:\s*auto\s*!important;[^}]*min-width:\s*0;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-node-content-wrapper\s*\{[^}]*max-content/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-switcher\s*\{[^}]*width:\s*24px;[^}]*min-width:\s*24px;/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-iconEle\s*\{[^}]*width:\s*16px;[^}]*min-width:\s*16px;/s);
expect(appCss).toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*visible;/s);
expect(appCss).not.toMatch(/\.sidebar-tree-scroll-shell\s+\.ant-tree\s+\.ant-tree-title\s*\{[^}]*max-content/s);
});

View File

@@ -69,7 +69,7 @@ describe('store appearance persistence', () => {
expect(appearance.blur).toBe(6);
expect(appearance.useNativeMacWindowControls).toBe(true);
expect(appearance.showDataTableVerticalBorders).toBe(false);
expect(appearance.dataTableColumnWidthMode).toBe('standard');
expect(appearance.dataTableDensity).toBe('comfortable');
});
it('persists DataGrid appearance settings and restores them after reload', async () => {
@@ -77,19 +77,19 @@ describe('store appearance persistence', () => {
useStore.getState().setAppearance({
showDataTableVerticalBorders: true,
dataTableColumnWidthMode: 'compact',
dataTableDensity: 'compact',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
expect(persisted.state.appearance.dataTableColumnWidthMode).toBe('compact');
expect(persisted.state.appearance.dataTableDensity).toBe('compact');
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableColumnWidthMode).toBe('compact');
expect(appearance.dataTableDensity).toBe('compact');
});
it('does not clear persisted legacy connections during hydration migration', async () => {
@@ -210,6 +210,126 @@ describe('store appearance persistence', () => {
);
});
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-oracle',
name: 'OceanBase Oracle',
config: {
id: 'oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
oceanBaseProtocol: 'oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('restores OceanBase protocol from saved URI or connection params', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-uri-oracle',
name: 'OceanBase URI Oracle',
config: {
id: 'oceanbase-uri-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/OBORCL?protocol=oracle',
},
},
{
id: 'oceanbase-param-oracle',
name: 'OceanBase Param Oracle',
config: {
id: 'oceanbase-param-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'tenantMode=oracle&PREFETCH_ROWS=5000',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
expect(useStore.getState().connections[1]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('prefers OceanBase protocol query key over legacy aliases when restoring saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-conflict',
name: 'OceanBase Conflict',
config: {
id: 'oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql&tenantMode=oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'mysql',
);
});
it('normalizes OceanBase protocol when updating a saved connection', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql',
},
},
]);
useStore.getState().updateConnection({
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'protocol=oracle',
},
});
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -13,6 +13,7 @@ import {
ExternalSQLDirectory,
JVMDiagnosticCommandDraft,
JVMDiagnosticEventChunk,
SqlSnippet,
} from "./types";
import {
ShortcutAction,
@@ -23,6 +24,10 @@ import {
sanitizeShortcutOptions,
} from "./utils/shortcuts";
import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree";
import {
DEFAULT_SQL_SNIPPETS,
BUILTIN_SNIPPET_MAP,
} from "./utils/sqlSnippetDefaults";
import { toPersistedGlobalProxy } from "./utils/globalProxyDraft";
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
@@ -60,7 +65,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 = 8;
const PERSIST_VERSION = 9;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
@@ -73,9 +78,73 @@ const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
password: "",
hasPassword: false,
};
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
"protocol",
"oceanBaseProtocol",
"oceanbaseProtocol",
"tenantMode",
"compatMode",
"mode",
];
const normalizeOceanBaseProtocol = (
value: unknown,
): "mysql" | "oracle" | undefined => {
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized === "oracle" ||
normalized === "oracle-mode" ||
normalized === "oracle_mode" ||
normalized === "oboracle"
? "oracle"
: "mysql";
};
const resolveOceanBaseProtocolFromQueryText = (
value: unknown,
): "mysql" | "oracle" | undefined => {
let text = String(value ?? "").trim();
if (!text) {
return undefined;
}
const queryIndex = text.indexOf("?");
if (queryIndex >= 0) {
text = text.slice(queryIndex + 1);
}
const hashIndex = text.indexOf("#");
if (hashIndex >= 0) {
text = text.slice(0, hashIndex);
}
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
const protocol = normalizeOceanBaseProtocol(params.get(key));
if (protocol) {
return protocol;
}
}
return undefined;
};
const resolveOceanBaseProtocol = (
raw: Record<string, unknown>,
normalizedConnectionParams: string,
normalizedUri: string,
): "mysql" | "oracle" => {
if (Object.prototype.hasOwnProperty.call(raw, "oceanBaseProtocol")) {
const explicitProtocol = normalizeOceanBaseProtocol(raw.oceanBaseProtocol);
if (explicitProtocol) {
return explicitProtocol;
}
}
return (
resolveOceanBaseProtocolFromQueryText(normalizedConnectionParams) ||
resolveOceanBaseProtocolFromQueryText(normalizedUri) ||
"mysql"
);
};
const SUPPORTED_CONNECTION_TYPES = new Set([
"mysql",
"mariadb",
"oceanbase",
"doris",
"diros",
"sphinx",
@@ -90,6 +159,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
"mongodb",
"highgo",
"vastbase",
"opengauss",
"jvm",
"sqlite",
"duckdb",
@@ -98,6 +168,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
"mysql",
"mariadb",
"oceanbase",
"diros",
"sphinx",
"dameng",
@@ -108,6 +179,7 @@ const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
"kingbase",
"highgo",
"vastbase",
"opengauss",
"mongodb",
"redis",
"tdengine",
@@ -120,6 +192,8 @@ const getDefaultPortByType = (type: string): number => {
case "mysql":
case "mariadb":
return 3306;
case "oceanbase":
return 2881;
case "doris":
case "diros":
return 9030;
@@ -131,6 +205,7 @@ const getDefaultPortByType = (type: string): number => {
return 9000;
case "postgres":
case "vastbase":
case "opengauss":
return 5432;
case "redis":
return 6379;
@@ -270,6 +345,13 @@ const normalizeConnectionType = (value: unknown): string => {
if (type === "doris") {
return "diros";
}
if (
type === "open_gauss" ||
type === "open-gauss" ||
type === "opengauss"
) {
return "opengauss";
}
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
};
@@ -490,6 +572,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
useHttpTunnel,
httpTunnel,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
connectionParams: toTrimmedString(raw.connectionParams).slice(
0,
MAX_URI_LENGTH,
),
hosts: sanitizeAddressList(raw.hosts),
topology:
raw.topology === "replica"
@@ -528,6 +614,14 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
);
}
if (type === "oceanbase") {
safeConfig.oceanBaseProtocol = resolveOceanBaseProtocol(
raw,
safeConfig.connectionParams || "",
safeConfig.uri || "",
);
}
if (type === "custom") {
safeConfig.driver = toTrimmedString(raw.driver);
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);
@@ -699,6 +793,7 @@ interface AppState {
sqlFormatOptions: { keywordCase: "upper" | "lower" };
queryOptions: QueryOptions;
shortcutOptions: ShortcutOptions;
sqlSnippets: SqlSnippet[];
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, "name" | "frequency">;
@@ -786,6 +881,9 @@ interface AppState {
binding: Partial<ShortcutBinding>,
) => void;
resetShortcutOptions: () => void;
saveSqlSnippet: (snippet: SqlSnippet) => void;
deleteSqlSnippet: (id: string) => void;
resetBuiltinSqlSnippet: (id: string) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
@@ -878,6 +976,37 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
return result;
};
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
const result: SqlSnippet[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
const prefix = toTrimmedString(raw.prefix)
.toLowerCase()
.replace(/[^a-z0-9_]/g, "")
.slice(0, 20);
const body = toTrimmedString(raw.body);
if (!prefix || !body) return;
const id = toTrimmedString(raw.id, `snippet-${index + 1}`) || `snippet-${index + 1}`;
if (seenIds.has(id)) return;
seenIds.add(id);
result.push({
id,
prefix,
name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`,
description: toTrimmedString(raw.description) || undefined,
body,
isBuiltin: raw.isBuiltin === true,
createdAt: Number.isFinite(Number(raw.createdAt))
? Number(raw.createdAt)
: Date.now(),
});
});
return result;
};
const sanitizeExternalSQLDirectories = (
value: unknown,
): ExternalSQLDirectory[] => {
@@ -1064,7 +1193,7 @@ const sanitizeAppearance = (
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
showDataTableVerticalBorders:
dataGridDisplaySettings.showDataTableVerticalBorders,
dataTableColumnWidthMode: dataGridDisplaySettings.dataTableColumnWidthMode,
dataTableDensity: dataGridDisplaySettings.dataTableDensity,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
@@ -1313,6 +1442,7 @@ export const useStore = create<AppState>()(
showColumnType: true,
},
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
sqlSnippets: DEFAULT_SQL_SNIPPETS,
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
@@ -1334,13 +1464,31 @@ export const useStore = create<AppState>()(
jvmDiagnosticOutputs: {},
addConnection: (conn) =>
set((state) => ({ connections: [...state.connections, conn] })),
set((state) => {
const sanitized = sanitizeSavedConnection(
conn,
state.connections.length,
);
if (!sanitized) {
return { connections: state.connections };
}
return { connections: [...state.connections, sanitized] };
}),
updateConnection: (conn) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === conn.id ? conn : c,
),
})),
set((state) => {
const sanitized = sanitizeSavedConnection(
conn,
state.connections.length,
);
if (!sanitized) {
return { connections: state.connections };
}
return {
connections: state.connections.map((c) =>
c.id === conn.id ? sanitized : c,
),
};
}),
removeConnection: (id) =>
set((state) => ({
connections: state.connections.filter((c) => c.id !== id),
@@ -1698,6 +1846,33 @@ export const useStore = create<AppState>()(
});
},
saveSqlSnippet: (snippet) =>
set((state) => {
const existing = state.sqlSnippets.findIndex((s) => s.id === snippet.id);
if (existing >= 0) {
const updated = [...state.sqlSnippets];
updated[existing] = snippet;
return { sqlSnippets: updated };
}
return { sqlSnippets: [...state.sqlSnippets, snippet] };
}),
deleteSqlSnippet: (id) =>
set((state) => ({
sqlSnippets: state.sqlSnippets.filter(
(s) => s.id !== id || s.isBuiltin,
),
})),
resetBuiltinSqlSnippet: (id) =>
set((state) => {
const original = BUILTIN_SNIPPET_MAP[id];
if (!original) return state;
return {
sqlSnippets: state.sqlSnippets.map((s) =>
s.id === id ? { ...original } : s,
),
};
}),
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
clearSqlLogs: () => set({ sqlLogs: [] }),
@@ -2033,6 +2208,15 @@ export const useStore = create<AppState>()(
nextState.shortcutOptions = sanitizeShortcutOptions(
state.shortcutOptions,
);
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
(d) => !existingSnippetIds.has(d.id),
);
nextState.sqlSnippets =
missingSnippets.length > 0
? [...existingSnippets, ...missingSnippets]
: existingSnippets;
nextState.tableAccessCount = sanitizeTableAccessCount(
state.tableAccessCount,
);
@@ -2097,6 +2281,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
@@ -2121,6 +2306,7 @@ export const useStore = create<AppState>()(
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,

View File

@@ -294,10 +294,12 @@ export interface ConnectionConfig {
httpTunnel?: HTTPTunnelConfig;
driver?: string;
dsn?: string;
connectionParams?: string;
timeout?: number;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
oceanBaseProtocol?: "mysql" | "oracle"; // OceanBase tenant protocol
hosts?: string[]; // Multi-host addresses: host:port
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;
@@ -452,6 +454,16 @@ export interface SavedQuery {
createdAt: number;
}
export interface SqlSnippet {
id: string;
prefix: string;
name: string;
description?: string;
body: string;
isBuiltin: boolean;
createdAt: number;
}
export interface ExternalSQLDirectory {
id: string;
name: string;
@@ -552,6 +564,7 @@ export interface AIChatMessage {
phase?: ChatPhase;
content: string;
thinking?: string;
reasoning_content?: string;
timestamp: number;
loading?: boolean;
images?: string[]; // base64 encoded images with data URI prefix

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import type { AIChatMessage, AIToolCall } from '../types';
import { toAIRequestMessage } from './aiMessagePayload';
const toolCall: AIToolCall = {
id: 'call_schema',
type: 'function',
function: {
name: 'inspect_table_schema',
arguments: '{"table":"orders"}',
},
};
const message = (overrides: Partial<AIChatMessage>): AIChatMessage => ({
id: 'msg-1',
role: 'assistant',
content: '',
timestamp: 1,
...overrides,
});
describe('toAIRequestMessage', () => {
it('keeps reasoning_content on assistant tool-call messages', () => {
const payload = toAIRequestMessage(message({
tool_calls: [toolCall],
reasoning_content: '需要先检查表结构',
}));
expect(payload).toMatchObject({
role: 'assistant',
tool_calls: [toolCall],
reasoning_content: '需要先检查表结构',
});
});
it('keeps reasoning_content on assistant messages without tool calls', () => {
const payload = toAIRequestMessage(message({
content: '最终分析',
reasoning_content: '工具调用轮次的最终思考也需要保留',
}));
expect(payload).toMatchObject({
role: 'assistant',
content: '最终分析',
reasoning_content: '工具调用轮次的最终思考也需要保留',
});
});
it('omits reasoning_content from tool result messages while keeping tool_call_id', () => {
const payload = toAIRequestMessage(message({
role: 'tool',
content: '{"ok":true}',
tool_call_id: 'call_schema',
reasoning_content: '不应回传',
}));
expect(payload).toMatchObject({
role: 'tool',
content: '{"ok":true}',
tool_call_id: 'call_schema',
});
expect(payload).not.toHaveProperty('reasoning_content');
});
it('keeps user images without adding empty tool fields', () => {
const payload = toAIRequestMessage(message({
role: 'user',
content: '看图',
images: ['data:image/png;base64,abc'],
}));
expect(payload).toEqual({
role: 'user',
content: '看图',
images: ['data:image/png;base64,abc'],
});
});
});

View File

@@ -0,0 +1,32 @@
import type { AIChatMessage, AIToolCall } from '../types';
export interface AIRequestMessage {
role: AIChatMessage['role'];
content: string;
images?: string[];
tool_calls?: AIToolCall[];
tool_call_id?: string;
reasoning_content?: string;
}
export const toAIRequestMessage = (message: AIChatMessage): AIRequestMessage => {
const payload: AIRequestMessage = {
role: message.role,
content: message.content,
};
if (message.images && message.images.length > 0) {
payload.images = message.images;
}
if (message.tool_calls && message.tool_calls.length > 0) {
payload.tool_calls = message.tool_calls;
}
if (message.tool_call_id) {
payload.tool_call_id = message.tool_call_id;
}
if (message.role === 'assistant' && message.reasoning_content) {
payload.reasoning_content = message.reasoning_content;
}
return payload;
};

View File

@@ -68,6 +68,7 @@ describe('connectionModalPresentation', () => {
const allTypes = [
'mysql',
'mariadb',
'oceanbase',
'doris',
'diros',
'sphinx',
@@ -81,6 +82,7 @@ describe('connectionModalPresentation', () => {
'kingbase',
'highgo',
'vastbase',
'opengauss',
'mongodb',
'redis',
'tdengine',

View File

@@ -21,6 +21,7 @@ export type ConnectionConfigSectionKey =
| 'target'
| 'fileTarget'
| 'connectionMode'
| 'oceanBaseProtocol'
| 'mongoDiscovery'
| 'replica'
| 'service'
@@ -55,6 +56,7 @@ type ConnectionConfigSectionCopy = {
const mysqlCompatibleTypes = new Set([
'mysql',
'mariadb',
'oceanbase',
'doris',
'diros',
'sphinx',
@@ -64,6 +66,7 @@ const postgresCompatibleTypes = new Set([
'kingbase',
'highgo',
'vastbase',
'opengauss',
]);
const fileDatabaseTypes = new Set(['sqlite', 'duckdb']);
@@ -91,6 +94,10 @@ const CONNECTION_CONFIG_SECTION_COPY: Record<
title: '连接模式',
description: '选择单机、主从、副本集或集群等拓扑模式。',
},
oceanBaseProtocol: {
title: 'OceanBase 协议',
description: '明确选择 MySQL 租户协议或 Oracle 租户协议。',
},
mongoDiscovery: {
title: 'MongoDB 寻址',
description: '选择标准 host:port 或 mongodb+srv DNS 发现方式。',

View File

@@ -52,6 +52,77 @@ describe('buildRpcConnectionConfig', () => {
expect(result.clickHouseProtocol).toBe('http');
});
it('injects OceanBase protocol override into RPC connection params', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
database: 'ORCL',
oceanBaseProtocol: 'oracle',
} as any);
expect(result.connectionParams).toBe('protocol=oracle');
expect((result as any).oceanBaseProtocol).toBeUndefined();
});
it('keeps OceanBase URI protocol when no form override exists', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-uri',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
database: 'ORCL',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/ORCL?protocol=oracle',
} as any);
expect(result.connectionParams).toBe('protocol=oracle');
});
it('lets OceanBase form protocol override legacy connection param aliases', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-mysql',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
database: 'app',
oceanBaseProtocol: 'mysql',
connectionParams: 'tenantMode=oracle&connectTimeout=10',
} as any);
expect(result.connectionParams).toBe('connectTimeout=10&protocol=mysql');
});
it('keeps OceanBase protocol query key ahead of compatibility aliases', () => {
const result = buildRpcConnectionConfig({
id: 'conn-oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
database: 'app',
connectionParams: 'protocol=mysql&tenantMode=oracle',
} as any);
expect(result.connectionParams).toBe('protocol=mysql');
});
it('preserves extra connection params for RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-mysql',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
connectionParams: 'characterEncoding=utf8&useSSL=false',
} as any);
expect(result.connectionParams).toBe('characterEncoding=utf8&useSSL=false');
});
it('fills default nested config blocks needed by RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis',

View File

@@ -11,6 +11,15 @@ type ConnectionConfigInput = {
type SSHConfigInput = Record<string, any>;
type ProxyConfigInput = Record<string, any>;
type HttpTunnelConfigInput = Record<string, any>;
type OceanBaseProtocol = 'mysql' | 'oracle';
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
'protocol',
'oceanBaseProtocol',
'oceanbaseProtocol',
'tenantMode',
'compatMode',
'mode',
];
const toStringValue = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
@@ -70,6 +79,70 @@ const normalizeHttpTunnelConfig = (value: unknown): connection.HTTPTunnelConfig
});
};
const normalizeOceanBaseProtocol = (value: unknown): OceanBaseProtocol | undefined => {
const normalized = toStringValue(value).trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized === 'oracle' || normalized === 'oracle-mode' || normalized === 'oracle_mode' || normalized === 'oboracle'
? 'oracle'
: 'mysql';
};
const resolveOceanBaseProtocolFromQueryText = (raw: unknown): OceanBaseProtocol | undefined => {
let text = toStringValue(raw).trim();
if (!text) {
return undefined;
}
const queryStart = text.indexOf('?');
if (queryStart >= 0) {
text = text.slice(queryStart + 1);
}
const hashStart = text.indexOf('#');
if (hashStart >= 0) {
text = text.slice(0, hashStart);
}
const params = new URLSearchParams(text.replace(/^[?&]+/, ''));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
const protocol = normalizeOceanBaseProtocol(params.get(key));
if (protocol) {
return protocol;
}
}
return undefined;
};
const resolveOceanBaseProtocol = (config: ConnectionConfigInput): OceanBaseProtocol => {
if (Object.prototype.hasOwnProperty.call(config, 'oceanBaseProtocol')) {
const explicitProtocol = normalizeOceanBaseProtocol(config.oceanBaseProtocol);
if (explicitProtocol) {
return explicitProtocol;
}
}
return (
resolveOceanBaseProtocolFromQueryText(config.connectionParams) ||
resolveOceanBaseProtocolFromQueryText(config.uri) ||
'mysql'
);
};
const withOceanBaseProtocolParam = (config: ConnectionConfigInput): ConnectionConfigInput => {
const type = toStringValue(config.type).trim().toLowerCase();
if (type !== 'oceanbase') {
return config;
}
const selectedProtocol = resolveOceanBaseProtocol(config);
const params = new URLSearchParams(toStringValue(config.connectionParams));
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
params.delete(key);
}
params.set('protocol', selectedProtocol);
return {
...config,
connectionParams: params.toString(),
};
};
export function buildRpcConnectionConfig(
config: ConnectionConfigInput,
overrides: ConnectionConfigInput = {},
@@ -93,25 +166,27 @@ export function buildRpcConnectionConfig(
proxy: mergedProxy,
httpTunnel: mergedHttpTunnel,
};
const rpcMerged = withOceanBaseProtocolParam(merged);
const { oceanBaseProtocol: _oceanBaseProtocol, ...rpcPayload } = rpcMerged;
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
const timeout = toOptionalInteger(merged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(merged.redisDB, toOptionalInteger(config.redisDB));
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
const rpcConfig = new connection.ConnectionConfig({
...merged,
type: toStringValue(merged.type),
host: toStringValue(merged.host),
port: toOptionalInteger(merged.port, toOptionalInteger(config.port, 0)) ?? 0,
user: toStringValue(merged.user),
password: toStringValue(merged.password),
database: toStringValue(merged.database),
useSSH: merged.useSSH === true,
ssh: normalizeSSHConfig(merged.ssh),
useProxy: merged.useProxy === true,
proxy: normalizeProxyConfig(merged.proxy),
useHttpTunnel: merged.useHttpTunnel === true,
httpTunnel: normalizeHttpTunnelConfig(merged.httpTunnel),
...rpcPayload,
type: toStringValue(rpcMerged.type),
host: toStringValue(rpcMerged.host),
port: toOptionalInteger(rpcMerged.port, toOptionalInteger(config.port, 0)) ?? 0,
user: toStringValue(rpcMerged.user),
password: toStringValue(rpcMerged.password),
database: toStringValue(rpcMerged.database),
useSSH: rpcMerged.useSSH === true,
ssh: normalizeSSHConfig(rpcMerged.ssh),
useProxy: rpcMerged.useProxy === true,
proxy: normalizeProxyConfig(rpcMerged.proxy),
useHttpTunnel: rpcMerged.useHttpTunnel === true,
httpTunnel: normalizeHttpTunnelConfig(rpcMerged.httpTunnel),
timeout,
redisDB,
}) as RpcConnectionConfig;
@@ -119,4 +194,3 @@ export function buildRpcConnectionConfig(
rpcConfig.id = baseId;
return rpcConfig;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { mergeParsedUriValuesForForm } from "./connectionUriMerge";
describe("mergeParsedUriValuesForForm", () => {
it("keeps saved credentials when parsed URI has no auth section", () => {
const result = mergeParsedUriValuesForForm(
{
user: "root",
password: "saved-password",
host: "192.168.1.10",
port: 3306,
database: "old_db",
connectionParams: "application_name=GoNavi",
timeout: 30,
},
{
host: "192.168.1.240",
port: 3306,
user: "",
password: "",
database: "mkefu_location_dev_local",
connectionParams: "",
timeout: undefined,
useSSL: false,
},
"jdbc:mysql://192.168.1.240:3306/mkefu_location_dev_local?characterEncoding=UTF-8",
);
expect(result).toMatchObject({
uri: "jdbc:mysql://192.168.1.240:3306/mkefu_location_dev_local?characterEncoding=UTF-8",
host: "192.168.1.240",
port: 3306,
database: "mkefu_location_dev_local",
useSSL: false,
});
expect(result).not.toHaveProperty("user");
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("connectionParams");
expect(result).not.toHaveProperty("timeout");
});
it("allows URI credentials to replace existing credentials when provided", () => {
const result = mergeParsedUriValuesForForm(
{
user: "root",
password: "old-password",
},
{
user: "uri_user",
password: "uri-password",
},
"mysql://uri_user:uri-password@127.0.0.1:3306/app",
);
expect(result).toMatchObject({
user: "uri_user",
password: "uri-password",
});
});
it("keeps existing database when URI omits a database path", () => {
const result = mergeParsedUriValuesForForm(
{
database: "saved_db",
},
{
host: "127.0.0.1",
database: "",
},
"mysql://127.0.0.1:3306",
);
expect(result.database).toBeUndefined();
expect(result.host).toBe("127.0.0.1");
});
});

View File

@@ -0,0 +1,36 @@
const EMPTY_PRESERVED_URI_FIELDS = new Set([
"user",
"password",
"database",
"connectionParams",
]);
const isEmptyParsedValue = (value: unknown): boolean =>
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0);
export const mergeParsedUriValuesForForm = (
currentValues: Record<string, unknown>,
parsedValues: Record<string, unknown>,
uriText: string,
): Record<string, unknown> => {
const nextValues: Record<string, unknown> = { uri: uriText };
Object.entries(parsedValues).forEach(([key, value]) => {
if (value === undefined) {
return;
}
if (
EMPTY_PRESERVED_URI_FIELDS.has(key) &&
isEmptyParsedValue(value) &&
!isEmptyParsedValue(currentValues[key])
) {
return;
}
nextValues[key] = value;
});
return nextValues;
};

View File

@@ -11,17 +11,18 @@ import {
describe('dataGridDisplay helpers', () => {
it('sanitizes missing display settings to safe defaults', () => {
expect(sanitizeDataGridDisplaySettings(undefined)).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
expect(sanitizeDataGridDisplaySettings({ dataTableColumnWidthMode: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
expect(sanitizeDataGridDisplaySettings({ dataTableDensity: 'invalid' as never })).toEqual(DEFAULT_DATA_GRID_DISPLAY_SETTINGS);
});
it('resolves standard and compact default column widths', () => {
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(200);
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(140);
it('resolves density-based default column widths', () => {
expect(resolveDataTableDefaultColumnWidth('comfortable')).toBe(180);
expect(resolveDataTableDefaultColumnWidth('standard')).toBe(140);
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(100);
});
it('keeps manual column widths ahead of mode defaults', () => {
expect(resolveDataTableColumnWidth({ manualWidth: 320, widthMode: 'compact' })).toBe(320);
expect(resolveDataTableColumnWidth({ manualWidth: undefined, widthMode: 'compact' })).toBe(140);
it('keeps manual column widths ahead of density defaults', () => {
expect(resolveDataTableColumnWidth({ manualWidth: 320, density: 'compact' })).toBe(320);
expect(resolveDataTableColumnWidth({ manualWidth: undefined, density: 'compact' })).toBe(100);
});
it('uses subtle themed vertical border colors and transparent when disabled', () => {

View File

@@ -1,25 +1,64 @@
export type DataTableColumnWidthMode = 'standard' | 'compact';
export type DataTableDensity = 'comfortable' | 'standard' | 'compact';
export interface DataGridDisplaySettings {
showDataTableVerticalBorders: boolean;
dataTableColumnWidthMode: DataTableColumnWidthMode;
dataTableDensity: DataTableDensity;
}
export const DEFAULT_DATA_GRID_DISPLAY_SETTINGS: DataGridDisplaySettings = {
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
dataTableDensity: 'comfortable',
};
export const DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS = [
{ label: '标准 200px', value: 'standard' as const },
{ label: '紧凑 140px', value: 'compact' as const },
interface DensityParams {
defaultColumnWidth: number;
cellPadding: string;
inputCellPadding: string;
headerMinHeight: number;
dataFontSize: number;
metaFontSize: number;
}
const DENSITY_PARAMS: Record<DataTableDensity, DensityParams> = {
comfortable: {
defaultColumnWidth: 180,
cellPadding: '8px',
inputCellPadding: '0px 4px',
headerMinHeight: 40,
dataFontSize: 13,
metaFontSize: 11,
},
standard: {
defaultColumnWidth: 140,
cellPadding: '5px 8px',
inputCellPadding: '0px 3px',
headerMinHeight: 34,
dataFontSize: 13,
metaFontSize: 10,
},
compact: {
defaultColumnWidth: 100,
cellPadding: '2px 6px',
inputCellPadding: '0px 2px',
headerMinHeight: 28,
dataFontSize: 12,
metaFontSize: 10,
},
};
export const DENSITY_OPTIONS = [
{ label: '舒适', value: 'comfortable' as const },
{ label: '标准', value: 'standard' as const },
{ label: '紧凑', value: 'compact' as const },
];
const STANDARD_DATA_TABLE_COLUMN_WIDTH = 200;
const COMPACT_DATA_TABLE_COLUMN_WIDTH = 140;
export const sanitizeDataTableDensity = (value: unknown): DataTableDensity => {
if (value === 'standard' || value === 'compact') return value;
return 'comfortable';
};
export const sanitizeDataTableColumnWidthMode = (value: unknown): DataTableColumnWidthMode => {
return value === 'compact' ? 'compact' : 'standard';
export const getDensityParams = (density: DataTableDensity): DensityParams => {
return DENSITY_PARAMS[density] || DENSITY_PARAMS.comfortable;
};
export const sanitizeDataGridDisplaySettings = (
@@ -31,30 +70,28 @@ export const sanitizeDataGridDisplaySettings = (
return {
showDataTableVerticalBorders: value.showDataTableVerticalBorders === true,
dataTableColumnWidthMode: sanitizeDataTableColumnWidthMode(value.dataTableColumnWidthMode),
dataTableDensity: sanitizeDataTableDensity(value.dataTableDensity),
};
};
export const resolveDataTableDefaultColumnWidth = (
widthMode: DataTableColumnWidthMode | null | undefined
density: DataTableDensity | null | undefined
): number => {
return sanitizeDataTableColumnWidthMode(widthMode) === 'compact'
? COMPACT_DATA_TABLE_COLUMN_WIDTH
: STANDARD_DATA_TABLE_COLUMN_WIDTH;
return getDensityParams(sanitizeDataTableDensity(density)).defaultColumnWidth;
};
export const resolveDataTableColumnWidth = ({
manualWidth,
widthMode,
density,
}: {
manualWidth: number | null | undefined;
widthMode: DataTableColumnWidthMode | null | undefined;
density: DataTableDensity | null | undefined;
}): number => {
if (typeof manualWidth === 'number' && Number.isFinite(manualWidth) && manualWidth > 0) {
return manualWidth;
}
return resolveDataTableDefaultColumnWidth(widthMode);
return resolveDataTableDefaultColumnWidth(density);
};
export const resolveDataTableVerticalBorderColor = ({

View File

@@ -7,6 +7,7 @@ import {
normalizeQuickWhereCondition,
resolveWhereConditionSuggestions,
resolveWhereConditionSelectedValue,
shouldApplyQuickWhereOnEnter,
validateQuickWhereCondition,
} from './dataGridWhereFilter';
@@ -110,4 +111,30 @@ describe('dataGridWhereFilter', () => {
}),
).toBe('`username` = ');
});
it('lets autocomplete consume enter while quick where suggestions are open', () => {
expect(shouldApplyQuickWhereOnEnter({
key: 'Enter',
suggestionsOpen: true,
suggestionCount: 1,
activeSuggestionId: 'quick-where-list-0',
})).toBe(false);
expect(shouldApplyQuickWhereOnEnter({
key: 'Enter',
suggestionsOpen: true,
suggestionCount: 1,
})).toBe(true);
expect(shouldApplyQuickWhereOnEnter({
key: 'Enter',
suggestionsOpen: false,
suggestionCount: 1,
activeSuggestionId: 'quick-where-list-0',
})).toBe(true);
expect(shouldApplyQuickWhereOnEnter({
key: 'Enter',
shiftKey: true,
suggestionsOpen: false,
suggestionCount: 0,
})).toBe(false);
});
});

View File

@@ -182,6 +182,26 @@ export const resolveWhereConditionSelectedValue = ({
return applyWhereConditionSuggestion(String(currentInput ?? ''), insertTextValue);
};
export const shouldApplyQuickWhereOnEnter = ({
key,
shiftKey = false,
isComposing = false,
suggestionsOpen = false,
suggestionCount = 0,
activeSuggestionId = '',
}: {
key: unknown;
shiftKey?: boolean;
isComposing?: boolean;
suggestionsOpen?: boolean;
suggestionCount?: number;
activeSuggestionId?: unknown;
}): boolean => {
if (String(key || '') !== 'Enter') return false;
if (shiftKey || isComposing) return false;
return !(suggestionsOpen && suggestionCount > 0 && String(activeSuggestionId ?? '').trim());
};
export const resolveWhereConditionSuggestions = ({
input,
columnNames,

View File

@@ -29,4 +29,15 @@ describe('dataSourceCapabilities', () => {
supportsApproximateTotalPages: false,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',
oceanBaseProtocol: 'oracle',
})).toMatchObject({
type: 'oracle',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
});
});
});

View File

@@ -1,6 +1,6 @@
import type { ConnectionConfig } from '../types';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver'> | null | undefined;
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol'> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
@@ -9,6 +9,10 @@ const normalizeDataSourceToken = (raw: string): string => {
return 'diros';
case 'postgresql':
return 'postgres';
case 'opengauss':
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'dm':
return 'dameng';
default:
@@ -23,18 +27,23 @@ export const resolveDataSourceType = (config: ConnectionLike): string => {
const driver = normalizeDataSourceToken(String(config.driver || ''));
return driver || 'custom';
}
if (type === 'oceanbase' && String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle') {
return 'oracle';
}
return type;
};
const SQL_QUERY_EXPORT_TYPES = new Set([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',
@@ -47,12 +56,14 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
const COPY_INSERT_TYPES = new Set([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',

View File

@@ -17,6 +17,8 @@ describe('driver import guidance', () => {
it('documents custom driver aliases for kingbase and related fallbacks', () => {
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('kingbase8');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('pgx');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('open_gauss');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('oceanbase');
expect(CUSTOM_CONNECTION_DRIVER_HELP).toContain('JDBC Jar');
});
});

View File

@@ -7,4 +7,4 @@ export const DRIVER_LOCAL_IMPORT_SINGLE_FILE_HELP =
'行内“导入驱动包”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`),不支持直接导入 JDBC Jar批量导入请使用上方“导入驱动目录”。';
export const CUSTOM_CONNECTION_DRIVER_HELP =
'已支持: mysql, postgres, sqlite, oracle, dm, kingbase别名支持 postgresql/pgx、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';
'已支持: mysql, oceanbase, postgres, opengauss, sqlite, oracle, dm, kingbase别名支持 postgresql/pgx、open_gauss/open-gauss、dm8、kingbase8/kingbasees/kingbasev8。当前不支持通过 JDBC Jar 扩展驱动。';

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { buildDriverManagerWorkbenchTheme } from './driverManagerWorkbenchTheme';
describe('driverManagerWorkbenchTheme', () => {
it('builds a dark driver manager theme with dark surfaces', () => {
const theme = buildDriverManagerWorkbenchTheme(true, 0.72);
expect(theme.isDark).toBe(true);
expect(theme.pageBg).toBe('rgb(31, 31, 31)');
expect(theme.sectionBg).toBe('rgb(31, 31, 31)');
expect(theme.cardBg).toBe('rgb(31, 31, 31)');
expect(theme.statBg).toBe('rgb(31, 31, 31)');
expect(theme.updateNoteBg).toBe('rgb(31, 31, 31)');
expect(theme.titleText).toBe('#f5f7ff');
expect(theme.warningText).toBe('#f6c453');
});
it('builds a light driver manager theme with light surfaces', () => {
const theme = buildDriverManagerWorkbenchTheme(false, 0.92);
expect(theme.isDark).toBe(false);
expect(theme.pageBg).toBe('rgb(255, 255, 255)');
expect(theme.sectionBg).toBe('rgb(255, 255, 255)');
expect(theme.cardBg).toBe('rgb(255, 255, 255)');
expect(theme.statBg).toBe('rgb(255, 255, 255)');
expect(theme.updateNoteBg).toBe('rgb(255, 255, 255)');
expect(theme.titleText).toBe('rgba(5, 5, 5, 0.92)');
expect(theme.warningText).toBe('#d48806');
});
});

View File

@@ -0,0 +1,61 @@
export type DriverManagerWorkbenchTheme = {
isDark: boolean;
pageBg: string;
sectionBg: string;
sectionBorder: string;
cardBg: string;
cardBorder: string;
cardWarningBorder: string;
cardReadyBorder: string;
statBg: string;
statBorder: string;
updateNoteBg: string;
updateNoteBorder: string;
mutedText: string;
titleText: string;
warningText: string;
};
export const buildDriverManagerWorkbenchTheme = (darkMode: boolean, _opacity: number): DriverManagerWorkbenchTheme => {
if (darkMode) {
const darkSurface = 'rgb(31, 31, 31)';
return {
isDark: true,
pageBg: darkSurface,
sectionBg: darkSurface,
sectionBorder: '1px solid rgba(255, 255, 255, 0.08)',
cardBg: darkSurface,
cardBorder: '1px solid rgba(255, 255, 255, 0.08)',
cardWarningBorder: '1px solid rgba(250, 173, 20, 0.35)',
cardReadyBorder: '1px solid rgba(82, 196, 26, 0.22)',
statBg: darkSurface,
statBorder: '1px solid rgba(255, 255, 255, 0.08)',
updateNoteBg: darkSurface,
updateNoteBorder: '1px solid rgba(250, 173, 20, 0.24)',
mutedText: 'rgba(255, 255, 255, 0.62)',
titleText: '#f5f7ff',
warningText: '#f6c453',
};
}
const lightSurface = 'rgb(255, 255, 255)';
return {
isDark: false,
pageBg: lightSurface,
sectionBg: lightSurface,
sectionBorder: '1px solid rgba(5, 5, 5, 0.08)',
cardBg: lightSurface,
cardBorder: '1px solid rgba(5, 5, 5, 0.08)',
cardWarningBorder: '1px solid rgba(250, 173, 20, 0.35)',
cardReadyBorder: '1px solid rgba(82, 196, 26, 0.22)',
statBg: lightSurface,
statBorder: '1px solid rgba(5, 5, 5, 0.08)',
updateNoteBg: lightSurface,
updateNoteBorder: '1px solid rgba(250, 173, 20, 0.24)',
mutedText: 'rgba(5, 5, 5, 0.62)',
titleText: 'rgba(5, 5, 5, 0.92)',
warningText: '#d48806',
};
};

View File

@@ -44,4 +44,14 @@ describe('macWindow helpers', () => {
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
});
it('does not suppress Escape for editable targets so editor widgets can close', () => {
expect(shouldSuppressMacNativeEscapeExit(
true,
true,
true,
{ key: 'Escape', defaultPrevented: false },
{ isEditableTarget: true },
)).toBe(false);
});
});

View File

@@ -31,10 +31,14 @@ export const shouldSuppressMacNativeEscapeExit = (
useNativeMacWindowControls: boolean,
isFullscreen: boolean,
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
options?: { isEditableTarget?: boolean },
): boolean => {
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
return false;
}
if (options?.isEditableTarget) {
return false;
}
if (event.defaultPrevented) {
return false;
}

View File

@@ -6,6 +6,7 @@ describe('applyQueryAutoLimit', () => {
const limitDialects = [
'mysql',
'mariadb',
'oceanbase',
'diros',
'doris',
'sphinx',
@@ -15,6 +16,7 @@ describe('applyQueryAutoLimit', () => {
'kingbase8',
'highgo',
'vastbase',
'opengauss',
'sqlite',
'sqlite3',
'duckdb',
@@ -32,9 +34,9 @@ describe('applyQueryAutoLimit', () => {
['dameng'],
['dm'],
['dm8'],
])('adds FETCH FIRST limit for %s connections', (dbType) => {
])('adds ROWNUM limit for %s connections', (dbType) => {
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG', dbType, 500).sql)
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY');
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500');
});
it.each([
@@ -53,8 +55,8 @@ describe('applyQueryAutoLimit', () => {
});
it.each([
['oracle', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
['dm8', 'SELECT * FROM users FETCH FIRST 500 ROWS ONLY'],
['oracle', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
['dm8', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
['mssql', 'SELECT TOP 500 * FROM users'],
['postgresql', 'SELECT * FROM users LIMIT 500'],
['doris', 'SELECT * FROM users LIMIT 500'],
@@ -66,7 +68,12 @@ describe('applyQueryAutoLimit', () => {
it('keeps trailing semicolon and comments after injected Oracle limit', () => {
expect(applyQueryAutoLimit('SELECT * FROM MYCIMLED.EDC_LOG; -- preview', 'oracle', 500).sql)
.toBe('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY; -- preview');
.toBe('SELECT * FROM (SELECT * FROM MYCIMLED.EDC_LOG) WHERE ROWNUM <= 500; -- preview');
});
it('uses Oracle 11g compatible ROWNUM limit for simple table queries', () => {
expect(applyQueryAutoLimit('select 1 from xxx', 'oracle', 500).sql)
.toBe('SELECT * FROM (select 1 from xxx) WHERE ROWNUM <= 500');
});
it('does not add another generic limit when SQL already limits rows', () => {
@@ -88,6 +95,11 @@ describe('applyQueryAutoLimit', () => {
.toBe(false);
});
it('does not wrap Oracle FOR UPDATE queries', () => {
expect(applyQueryAutoLimit('SELECT * FROM users FOR UPDATE', 'oracle', 500).applied)
.toBe(false);
});
it('does not add another SQL Server limit when SQL already uses TOP', () => {
expect(applyQueryAutoLimit('SELECT TOP 10 * FROM users', 'sqlserver', 500).applied)
.toBe(false);

View File

@@ -320,7 +320,9 @@ export const applyQueryAutoLimit = (
if (rownumPos >= 0) return { sql, applied: false, maxRows };
const offsetPos = findTopLevelKeyword(main, 'offset');
if (offsetPos >= 0 && (fromPos < 0 || offsetPos > fromPos)) return { sql, applied: false, maxRows };
return { sql: `${main.trimEnd()} FETCH FIRST ${maxRows} ROWS ONLY${tail}`, applied: true, maxRows };
const forPos = findTopLevelKeyword(main, 'for');
if (forPos >= 0 && (fromPos < 0 || forPos > fromPos)) return { sql, applied: false, maxRows };
return { sql: `SELECT * FROM (${main.trimEnd()}) WHERE ROWNUM <= ${maxRows}${tail}`, applied: true, maxRows };
}
const offsetPos = findTopLevelKeyword(main, 'offset');

View File

@@ -0,0 +1,210 @@
import { describe, expect, it } from 'vitest';
import {
findReservedConflict,
findReservedConflicts,
describeConflictContext,
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
} from './shortcuts';
import type { ConflictInfo } from './shortcuts';
// ─── findReservedConflict ────────────────────────────────────────────
describe('findReservedConflict', () => {
it('finds Ctrl+F conflict (Monaco Find)', () => {
const result = findReservedConflict('Ctrl+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
expect(result!.monacoCommandId).toBe('actions.find');
});
it('finds Ctrl+S conflict (browser save)', () => {
const result = findReservedConflict('Ctrl+S');
expect(result).not.toBeNull();
expect(result!.label).toBe('浏览器保存');
expect(result!.context).toBe('global');
});
it('returns null for non-reserved combo', () => {
expect(findReservedConflict('Ctrl+Shift+R')).toBeNull();
});
it('returns null for empty string', () => {
expect(findReservedConflict('')).toBeNull();
});
it('finds Meta+F (macOS variant)', () => {
const result = findReservedConflict('Meta+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
});
it('matches after normalization (ctrl+f → Ctrl+F)', () => {
const result = findReservedConflict(normalizeShortcutCombo('ctrl+f'));
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
});
it('finds F2 conflict', () => {
const result = findReservedConflict('F2');
expect(result).not.toBeNull();
expect(result!.context).toBe('monaco');
});
});
// ─── findReservedConflicts ───────────────────────────────────────────
describe('findReservedConflicts', () => {
it('returns multiple conflicts for Ctrl+Enter', () => {
const results = findReservedConflicts('Ctrl+Enter');
expect(results.length).toBeGreaterThanOrEqual(1);
const labels = results.map(r => r.label);
expect(labels).toContain('编辑器在下方插入行');
});
it('returns empty array for non-reserved combo', () => {
expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]);
});
it('preserves monacoCommandId in results', () => {
const results = findReservedConflicts('Ctrl+F');
expect(results[0].monacoCommandId).toBe('actions.find');
});
});
// ─── describeConflictContext ─────────────────────────────────────────
describe('describeConflictContext', () => {
it('describes global context', () => {
expect(describeConflictContext('global')).toBe('浏览器');
});
it('describes monaco context', () => {
expect(describeConflictContext('monaco')).toBe('编辑器');
});
it('describes datagrid context', () => {
expect(describeConflictContext('datagrid')).toBe('数据表格');
});
});
// ─── RESERVED_SHORTCUTS sanity ───────────────────────────────────────
describe('RESERVED_SHORTCUTS', () => {
it('all combos are already normalized', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.combo).toBe(normalizeShortcutCombo(entry.combo));
}
});
it('has at least 10 entries', () => {
expect(RESERVED_SHORTCUTS.length).toBeGreaterThanOrEqual(10);
});
it('every entry has a label and context', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.label).toBeTruthy();
expect(['global', 'monaco', 'datagrid']).toContain(entry.context);
}
});
});
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────
describe('comboToMonacoKeyBinding', () => {
const mockKeyMod = {
CtrlCmd: 2048,
WinCtrl: 256,
Alt: 512,
Shift: 1024,
};
const mockKeyCode = {
Enter: 3,
Tab: 2,
Escape: 9,
Space: 10,
Backspace: 1,
Delete: 20,
Home: 14,
End: 13,
PageUp: 11,
PageDown: 12,
UpArrow: 16,
DownArrow: 17,
LeftArrow: 15,
RightArrow: 18,
Insert: 19,
KeyA: 31, KeyB: 32, KeyC: 33, KeyD: 34, KeyE: 35,
KeyF: 41,
KeyG: 42, KeyH: 43, KeyK: 47, KeyN: 50, KeyP: 52, KeyR: 54, KeyS: 55,
Digit0: 21, Digit1: 22, Digit2: 23, Digit3: 24, Digit4: 25,
Digit5: 26, Digit6: 27, Digit7: 28, Digit8: 29, Digit9: 30,
F1: 61, F2: 62, F3: 63, F4: 64, F5: 65, F6: 66,
F7: 67, F8: 68, F9: 69, F10: 70, F11: 71, F12: 72,
Oem1: 80, Oem2: 81, Oem3: 82, Oem4: 83, Oem5: 84,
Oem6: 85, Oem7: 86, OemComma: 87, OemMinus: 88,
OemPlus: 89, OemPeriod: 90,
};
it('maps Ctrl+Enter correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Enter,
});
});
it('maps Ctrl+Shift+R correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift,
keyCode: mockKeyCode.KeyR,
});
});
it('maps Meta+Enter (macOS variant)', () => {
expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.WinCtrl,
keyCode: mockKeyCode.Enter,
});
});
it('maps F2 key', () => {
expect(comboToMonacoKeyBinding('F2', mockKeyMod, mockKeyCode)).toEqual({
keyMod: 0,
keyCode: mockKeyCode.F2,
});
});
it('maps Ctrl+, (comma)', () => {
expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.OemComma,
});
});
it('returns null for empty combo', () => {
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
});
it('returns null for combo with only modifiers', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift', mockKeyMod, mockKeyCode)).toBeNull();
});
it('maps Ctrl+Digit1', () => {
expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Digit1,
});
});
it('maps Ctrl+Alt+Delete', () => {
expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt,
keyCode: mockKeyCode.Delete,
});
});
});

View File

@@ -312,3 +312,179 @@ export const getShortcutDisplay = (combo: string): string => {
return normalized || '-';
};
export type ConflictContext = 'global' | 'monaco' | 'datagrid';
export interface ReservedShortcut {
combo: string;
label: string;
context: ConflictContext;
monacoCommandId?: string;
}
export interface ConflictInfo {
label: string;
context: ConflictContext;
monacoCommandId?: string;
}
export const RESERVED_SHORTCUTS: ReservedShortcut[] = [
// Browser / WebView built-in shortcuts
{ combo: 'Ctrl+S', label: '浏览器保存', context: 'global' },
{ combo: 'Ctrl+P', label: '浏览器打印', context: 'global' },
{ combo: 'Ctrl+W', label: '浏览器关闭标签页', context: 'global' },
{ combo: 'Ctrl+T', label: '浏览器新建标签页', context: 'global' },
{ combo: 'Ctrl+N', label: '浏览器新建窗口', context: 'global' },
{ combo: 'Ctrl+Shift+N', label: '浏览器新建隐身窗口', context: 'global' },
// Monaco editor built-in shortcuts
{ combo: 'Ctrl+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
{ combo: 'Meta+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' },
{ combo: 'Ctrl+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
{ combo: 'Meta+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' },
{ combo: 'Ctrl+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
{ combo: 'Meta+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' },
{ combo: 'Ctrl+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
{ combo: 'Meta+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' },
{ combo: 'Ctrl+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
{ combo: 'Meta+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' },
{ combo: 'Ctrl+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
{ combo: 'Meta+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' },
{ combo: 'Ctrl+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
{ combo: 'Meta+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' },
{ combo: 'Ctrl+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
{ combo: 'Meta+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' },
{ combo: 'Ctrl+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
{ combo: 'Meta+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' },
{ combo: 'F2', label: '编辑器重命名符号', context: 'monaco', monacoCommandId: 'editor.action.rename' },
// DataGrid shortcuts
{ combo: 'Ctrl+C', label: '数据表格复制', context: 'datagrid' },
{ combo: 'Meta+C', label: '数据表格复制', context: 'datagrid' },
];
const CONTEXT_DESCRIPTION: Record<ConflictContext, string> = {
global: '浏览器',
monaco: '编辑器',
datagrid: '数据表格',
};
export const describeConflictContext = (context: ConflictContext): string => {
return CONTEXT_DESCRIPTION[context] || context;
};
export const splitConflictsByContext = (conflicts: ConflictInfo[]) => {
const monaco = conflicts.filter(c => c.context === 'monaco');
const other = conflicts.filter(c => c.context !== 'monaco');
const dedupe = (items: ConflictInfo[], fn: (c: ConflictInfo) => string) =>
[...new Set(items.map(fn))].join('、');
return {
monacoLabels: dedupe(monaco, c => c.label),
otherLabels: dedupe(other, c => c.label),
otherContexts: dedupe(other, c => describeConflictContext(c.context)),
hasMonaco: monaco.length > 0,
hasOther: other.length > 0,
};
};
export const findReservedConflict = (normalizedCombo: string): ConflictInfo | null => {
const conflict = RESERVED_SHORTCUTS.find((r) => r.combo === normalizedCombo);
if (!conflict) return null;
return { label: conflict.label, context: conflict.context, monacoCommandId: conflict.monacoCommandId };
};
export const findReservedConflicts = (normalizedCombo: string): ConflictInfo[] => {
return RESERVED_SHORTCUTS
.filter((r) => r.combo === normalizedCombo)
.map((r) => ({ label: r.label, context: r.context, monacoCommandId: r.monacoCommandId }));
};
export interface MonacoKeyBinding {
keyMod: number;
keyCode: number;
}
/** Map key token (after normalization) to a function that returns KeyCode.
* The function receives the KeyCode enum to avoid importing monaco at module level. */
type KeyCodeResolver = (kc: Record<string, number>) => number;
const MONACO_KEY_MAP: Record<string, KeyCodeResolver> = {
Enter: (kc) => kc.Enter,
Tab: (kc) => kc.Tab,
Esc: (kc) => kc.Escape,
Space: (kc) => kc.Space,
Backspace: (kc) => kc.Backspace,
Delete: (kc) => kc.Delete,
Home: (kc) => kc.Home,
End: (kc) => kc.End,
PageUp: (kc) => kc.PageUp,
PageDown: (kc) => kc.PageDown,
Up: (kc) => kc.UpArrow,
Down: (kc) => kc.DownArrow,
Left: (kc) => kc.LeftArrow,
Right: (kc) => kc.RightArrow,
Insert: (kc) => kc.Insert,
'/': (kc) => kc.Oem2,
',': (kc) => kc.OemComma,
'-': (kc) => kc.OemMinus,
'=': (kc) => kc.OemPlus,
'.': (kc) => kc.OemPeriod,
';': (kc) => kc.Oem1,
"'": (kc) => kc.Oem7,
'[': (kc) => kc.Oem4,
']': (kc) => kc.Oem6,
'\\': (kc) => kc.Oem5,
'`': (kc) => kc.Oem3,
};
function resolveKeyCode(token: string, kc: Record<string, number>): number | null {
// F1-F12
const fMatch = token.match(/^F([1-9]|1[0-2])$/);
if (fMatch) {
return kc['F' + fMatch[1]] ?? null;
}
// A-Z
if (/^[A-Z]$/.test(token)) {
return kc['Key' + token] ?? null;
}
// 0-9
if (/^[0-9]$/.test(token)) {
return kc['Digit' + token] ?? null;
}
// Special keys map
const resolver = MONACO_KEY_MAP[token];
if (resolver) {
return resolver(kc);
}
return null;
}
export const comboToMonacoKeyBinding = (
combo: string,
keyModEnum: Record<string, number>,
keyCodeEnum: Record<string, number>,
): MonacoKeyBinding | null => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return null;
const pieces = normalized.split('+');
let keyMod = 0;
let keyCode: number | null = null;
for (const piece of pieces) {
if (piece === 'Ctrl') {
keyMod |= keyModEnum.CtrlCmd ?? 0;
} else if (piece === 'Meta') {
keyMod |= keyModEnum.WinCtrl ?? 0;
} else if (piece === 'Alt') {
keyMod |= keyModEnum.Alt ?? 0;
} else if (piece === 'Shift') {
keyMod |= keyModEnum.Shift ?? 0;
} else {
keyCode = resolveKeyCode(piece, keyCodeEnum);
}
}
if (keyCode == null) return null;
return { keyMod, keyCode };
};

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import {
findSidebarNodePathByKey,
findSidebarNodePathForLocate,
normalizeSidebarLocateObjectRequest,
normalizeSidebarLocateObjectRequestFromTab,
resolveSidebarLocateTarget,
} from './sidebarLocate';
describe('sidebarLocate', () => {
it('normalizes a table locate request and builds the direct tree path', () => {
const request = normalizeSidebarLocateObjectRequest({
tabId: 'conn-1-main-users',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
});
expect(request).toMatchObject({
tabId: 'conn-1-main-users',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
schemaName: '',
objectGroup: 'tables',
});
expect(resolveSidebarLocateTarget(request!, { groupBySchema: false })).toMatchObject({
targetKey: 'conn-1-main-users',
expectedAncestorKeys: ['conn-1', 'conn-1-main', 'conn-1-main-tables'],
});
});
it('keeps view tabs on the views branch and includes schema ancestors', () => {
const request = normalizeSidebarLocateObjectRequest({
tabId: 'conn-1-main-view-public.orders_view',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'public.orders_view',
});
expect(request).toMatchObject({
objectGroup: 'views',
schemaName: 'public',
});
expect(resolveSidebarLocateTarget(request!, { groupBySchema: true })).toMatchObject({
targetKey: 'conn-1-main-view-public.orders_view',
schemaKey: 'conn-1-main-schema-public',
objectGroupKey: 'conn-1-main-schema-public-views',
expectedAncestorKeys: [
'conn-1',
'conn-1-main',
'conn-1-main-schema-public',
'conn-1-main-schema-public-views',
],
});
});
it('builds a locate request from the active table tab', () => {
expect(normalizeSidebarLocateObjectRequestFromTab({
id: 'conn-1-main-public.users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'public.users',
})).toMatchObject({
tabId: 'conn-1-main-public.users',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'public.users',
schemaName: 'public',
objectGroup: 'tables',
});
});
it('builds a view locate request from view tabs and rejects non-object tabs', () => {
expect(normalizeSidebarLocateObjectRequestFromTab({
id: 'view-def-conn-1-main-public.orders_view',
type: 'view-def',
connectionId: 'conn-1',
dbName: 'main',
viewName: 'public.orders_view',
})).toMatchObject({
tableName: 'public.orders_view',
schemaName: 'public',
objectGroup: 'views',
});
expect(normalizeSidebarLocateObjectRequestFromTab({
id: 'query-1',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
})).toBeNull();
});
it('finds a locate path from loaded tree data even when the target key is absent', () => {
const target = resolveSidebarLocateTarget(
{
tabId: 'stale-tab-id',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'public.users',
schemaName: 'public',
objectGroup: 'tables',
},
{ groupBySchema: true },
);
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-main',
dataRef: { id: 'conn-1', dbName: 'main' },
children: [
{
key: 'conn-1-main-schema-public',
dataRef: { id: 'conn-1', dbName: 'main', schemaName: 'public' },
children: [
{
key: 'conn-1-main-schema-public-tables',
dataRef: { id: 'conn-1', dbName: 'main', groupKey: 'tables', schemaName: 'public' },
children: [
{
key: 'conn-1-main-public.users',
type: 'table',
dataRef: {
id: 'conn-1',
dbName: 'main',
tableName: 'public.users',
schemaName: 'public',
},
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathByKey(tree, 'conn-1-main-public.users')).toEqual([
'conn-1',
'conn-1-main',
'conn-1-main-schema-public',
'conn-1-main-schema-public-tables',
'conn-1-main-public.users',
]);
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-main',
'conn-1-main-schema-public',
'conn-1-main-schema-public-tables',
'conn-1-main-public.users',
]);
});
});

View File

@@ -0,0 +1,221 @@
export type SidebarLocateObjectGroup = 'tables' | 'views';
export interface SidebarLocateObjectRequest {
tabId?: string;
connectionId: string;
dbName: string;
tableName: string;
schemaName?: string;
objectGroup: SidebarLocateObjectGroup;
}
export interface SidebarLocateTarget {
connectionKey: string;
databaseKey: string;
targetKey: string;
objectGroup: SidebarLocateObjectGroup;
objectGroupKey: string;
schemaKey?: string;
expectedAncestorKeys: string[];
connectionId: string;
dbName: string;
tableName: string;
schemaName: string;
}
export interface SidebarLocateTreeNodeLike {
key: string | number;
type?: string;
dataRef?: Record<string, any>;
children?: SidebarLocateTreeNodeLike[];
}
export interface SidebarLocateTabLike {
id?: string;
type?: string;
connectionId?: string;
dbName?: string;
tableName?: string;
viewName?: string;
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = toTrimmedString(qualifiedName);
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
return {
schemaName: raw.substring(0, idx).trim(),
objectName: raw.substring(idx + 1).trim(),
};
};
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateObjectGroup => {
const explicitGroup = toTrimmedString(detail.objectGroup);
if (explicitGroup === 'views' || explicitGroup === 'view') return 'views';
const explicitType = toTrimmedString(detail.objectType);
if (explicitType === 'view' || explicitType === 'views') return 'views';
const tabId = toTrimmedString(detail.tabId);
const dbNodeKey = `${connectionId}-${dbName}`;
if (tabId.startsWith(`${dbNodeKey}-view-`)) return 'views';
return 'tables';
};
export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => {
const raw = (detail || {}) as Record<string, unknown>;
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName);
if (!connectionId || !dbName || !tableName) {
return null;
}
const parsed = splitSidebarQualifiedName(tableName);
const schemaName = toTrimmedString(raw.schemaName) || parsed.schemaName;
return {
tabId: toTrimmedString(raw.tabId) || undefined,
connectionId,
dbName,
tableName,
schemaName,
objectGroup: inferObjectGroup(raw, connectionId, dbName),
};
};
export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => {
if (!tab) return null;
const objectName = tab.type === 'view-def'
? toTrimmedString(tab.viewName || tab.tableName)
: toTrimmedString(tab.tableName || tab.viewName);
if (tab.type !== 'table' && tab.type !== 'view-def') {
return null;
}
return normalizeSidebarLocateObjectRequest({
tabId: tab.id,
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName: objectName,
objectGroup: tab.type === 'view-def' ? 'views' : undefined,
});
};
export const resolveSidebarLocateTarget = (
request: SidebarLocateObjectRequest,
options: { groupBySchema: boolean },
): SidebarLocateTarget => {
const connectionKey = request.connectionId;
const databaseKey = `${request.connectionId}-${request.dbName}`;
const fallbackTargetKey = request.objectGroup === 'views'
? `${databaseKey}-view-${request.tableName}`
: `${databaseKey}-${request.tableName}`;
const targetKey = request.tabId || fallbackTargetKey;
const schemaSegment = request.schemaName || 'default';
const schemaKey = options.groupBySchema ? `${databaseKey}-schema-${schemaSegment}` : undefined;
const objectGroupKey = options.groupBySchema
? `${schemaKey}-${request.objectGroup}`
: `${databaseKey}-${request.objectGroup}`;
const expectedAncestorKeys = [
connectionKey,
databaseKey,
...(schemaKey ? [schemaKey] : []),
objectGroupKey,
];
return {
connectionKey,
databaseKey,
targetKey,
objectGroup: request.objectGroup,
objectGroupKey,
schemaKey,
expectedAncestorKeys,
connectionId: request.connectionId,
dbName: request.dbName,
tableName: request.tableName,
schemaName: request.schemaName || '',
};
};
export const findSidebarNodePathByKey = (
nodes: SidebarLocateTreeNodeLike[],
targetKey: string,
): string[] | null => {
for (const node of nodes) {
const nodeKey = String(node.key);
if (nodeKey === targetKey) {
return [nodeKey];
}
if (node.children) {
const childPath = findSidebarNodePathByKey(node.children, targetKey);
if (childPath) {
return [nodeKey, ...childPath];
}
}
}
return null;
};
const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => {
const normalizedNodeName = toTrimmedString(nodeObjectName);
if (!normalizedNodeName) return false;
if (normalizedNodeName === target.tableName) return true;
if (!target.schemaName) return false;
const nodeParsed = splitSidebarQualifiedName(normalizedNodeName);
const targetParsed = splitSidebarQualifiedName(target.tableName);
const nodeObject = nodeParsed.objectName || normalizedNodeName;
const targetObject = targetParsed.objectName || target.tableName;
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
return resolvedNodeSchema === target.schemaName && nodeObject === targetObject;
};
const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => {
const dataRef = node.dataRef || {};
const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId);
const nodeDbName = toTrimmedString(dataRef.dbName);
if (nodeConnectionId !== target.connectionId || nodeDbName !== target.dbName) {
return false;
}
if (target.objectGroup === 'views') {
if (node.type !== 'view') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
}
if (node.type !== 'table') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName));
};
export const findSidebarNodePathForLocate = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
): string[] | null => {
const exactPath = findSidebarNodePathByKey(nodes, target.targetKey);
if (exactPath) return exactPath;
for (const node of nodes) {
const nodeKey = String(node.key);
if (matchesLocateObjectNode(node, target)) {
return [nodeKey];
}
if (node.children) {
const childPath = findSidebarNodePathForLocate(node.children, target);
if (childPath) {
return [nodeKey, ...childPath];
}
}
}
return null;
};

View File

@@ -11,15 +11,21 @@ const splitQualifiedName = (qualifiedName: string): { schemaName: string; object
};
};
const normalizeSidebarConnectionDialect = (type: string, driver: string): string => {
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
if (normalizedType === 'custom') {
const normalizedDriver = String(driver || '').trim().toLowerCase();
if (normalizedDriver === 'postgresql' || normalizedDriver === 'postgres' || normalizedDriver === 'pg') return 'postgres';
if (normalizedDriver === 'opengauss' || normalizedDriver === 'open_gauss' || normalizedDriver === 'open-gauss') return 'opengauss';
if (normalizedDriver === 'dameng' || normalizedDriver === 'dm' || normalizedDriver === 'dm8') return 'dm';
if (normalizedDriver === 'oceanbase') return 'mysql';
if (normalizedDriver.includes('oracle')) return 'oracle';
return normalizedDriver;
}
if (normalizedType === 'oceanbase') {
return String(oceanBaseProtocol || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql';
}
if (normalizedType === 'open_gauss' || normalizedType === 'open-gauss') return 'opengauss';
if (normalizedType === 'dameng') return 'dm';
return normalizedType;
};
@@ -55,6 +61,7 @@ export const resolveSidebarRuntimeDatabase = (
savedDatabase: string,
overrideDatabase?: string,
clearDatabase: boolean = false,
oceanBaseProtocol?: string,
): string => {
if (clearDatabase) return '';
@@ -64,7 +71,7 @@ export const resolveSidebarRuntimeDatabase = (
return normalizedSavedDatabase;
}
const dialect = normalizeSidebarConnectionDialect(type, driver);
const dialect = normalizeSidebarConnectionDialect(type, driver, oceanBaseProtocol);
if (dialect === 'oracle' || dialect === 'dm') {
return normalizedSavedDatabase || normalizedOverrideDatabase;
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { buildOrderBySQL } from './sql';
describe('buildOrderBySQL', () => {
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
expect(buildOrderBySQL('duckdb', [], ['ID'])).toBe('');
});
it('keeps explicit DuckDB sort', () => {
expect(buildOrderBySQL('duckdb', { columnKey: 'ID', order: 'descend' }, ['NAME'])).toBe(' ORDER BY "ID" DESC');
});
});

View File

@@ -37,12 +37,12 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
return `\`${raw.replace(/`/g, '``')}\``;
}
// 对于 KingBase/PostgreSQL只在必要时加引号
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres') {
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres' || dbTypeLower === 'opengauss') {
if (needsQuote(raw)) {
return `"${raw.replace(/"/g, '""')}"`;
}
@@ -150,10 +150,10 @@ export const buildOrderBySQL = (
return ` ORDER BY ${sortParts.join(', ')}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
// 导致 `Error 1038 (HY001): Out of sort memory`
// 部分数据源在无显式排序需求时强制 ORDER BY即使按主键会显著放大大表预览成本:
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误DuckDB 大文件可能被排序拖到连接超时
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'duckdb') {
return '';
}

View File

@@ -14,12 +14,17 @@ const names = (items: Array<{ name: string }>) => items.map((item) => item.name)
describe('sqlDialect', () => {
it('normalizes datasource aliases without collapsing all dialects to mysql', () => {
expect(resolveSqlDialect('postgresql')).toBe('postgres');
expect(resolveSqlDialect('OpenGauss')).toBe('opengauss');
expect(resolveSqlDialect('OceanBase')).toBe('oceanbase');
expect(resolveSqlDialect('doris')).toBe('diros');
expect(resolveSqlDialect('dameng')).toBe('dameng');
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
expect(resolveSqlDialect('custom', 'open_gauss')).toBe('opengauss');
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
expect(isMysqlFamilyDialect('oceanbase')).toBe(true);
expect(isMysqlFamilyDialect('oracle')).toBe(false);
});
@@ -28,6 +33,8 @@ describe('sqlDialect', () => {
expect(values(resolveColumnTypeOptions('oracle'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('dameng'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).toContain('integer');
expect(values(resolveColumnTypeOptions('opengauss'))).toContain('integer');
expect(values(resolveColumnTypeOptions('oceanbase'))).toContain('varchar(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
expect(values(resolveColumnTypeOptions('sphinx'))).toContain('text');

View File

@@ -8,12 +8,14 @@ export type SqlFunctionCompletion = {
export type SqlDialect =
| 'mysql'
| 'mariadb'
| 'oceanbase'
| 'diros'
| 'sphinx'
| 'postgres'
| 'kingbase'
| 'highgo'
| 'vastbase'
| 'opengauss'
| 'oracle'
| 'dameng'
| 'sqlserver'
@@ -32,12 +34,23 @@ const optionValues = (values: string[]): ColumnTypeOption[] => values.map((value
const normalizeRawDialect = (value: string): string => String(value || '').trim().toLowerCase();
export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect => {
export const normalizeOceanBaseSqlProtocol = (value: unknown): 'mysql' | 'oracle' => (
String(value || '').trim().toLowerCase() === 'oracle' ? 'oracle' : 'mysql'
);
export const resolveSqlDialect = (
rawType: string,
rawDriver = '',
options?: { oceanBaseProtocol?: unknown },
): SqlDialect => {
const normalized = normalizeRawDialect(rawType);
const driver = normalizeRawDialect(rawDriver);
const source = normalized === 'custom' ? driver : normalized;
if (!source) return 'unknown';
if (source === 'oceanbase' && normalizeOceanBaseSqlProtocol(options?.oceanBaseProtocol) === 'oracle') {
return 'oracle';
}
switch (source) {
case 'postgresql':
@@ -46,6 +59,10 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
case 'pq':
case 'pgx':
return 'postgres';
case 'opengauss':
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'mssql':
case 'sql_server':
case 'sql-server':
@@ -67,6 +84,7 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
case 'kingbasev8':
return 'kingbase';
case 'mariadb':
case 'oceanbase':
case 'mysql':
case 'sphinx':
case 'kingbase':
@@ -83,7 +101,9 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
break;
}
if (source.includes('opengauss') || source.includes('open_gauss') || source.includes('open-gauss')) return 'opengauss';
if (source.includes('postgres')) return 'postgres';
if (source.includes('oceanbase')) return 'oceanbase';
if (source.includes('mariadb')) return 'mariadb';
if (source.includes('mysql')) return 'mysql';
if (source.includes('doris') || source.includes('diros')) return 'diros';
@@ -103,11 +123,11 @@ export const resolveSqlDialect = (rawType: string, rawDriver = ''): SqlDialect =
};
export const isMysqlFamilyDialect = (dbType: string): boolean => (
['mysql', 'mariadb', 'diros', 'sphinx', 'tidb', 'oceanbase', 'starrocks'].includes(resolveSqlDialect(dbType))
['mysql', 'mariadb', 'oceanbase', 'diros', 'sphinx', 'tidb', 'starrocks'].includes(resolveSqlDialect(dbType))
);
export const isPgLikeDialect = (dbType: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase'].includes(resolveSqlDialect(dbType))
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(resolveSqlDialect(dbType))
);
export const isOracleLikeDialect = (dbType: string): boolean => (
@@ -423,9 +443,9 @@ const COMMON_TYPES = optionValues(['int', 'varchar(255)', 'text', 'datetime', 'd
export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'mariadb' || dialect === 'mysql') return MYSQL_TYPES;
if (dialect === 'diros') return DORIS_TYPES;
if (dialect === 'sphinx') return SPHINX_TYPES;
if (isMysqlFamilyDialect(dialect)) return MYSQL_TYPES;
if (isPgLikeDialect(dialect)) return PG_TYPES;
if (dialect === 'oracle') return ORACLE_TYPES;
if (dialect === 'dameng') return DAMENG_TYPES;

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP } from './sqlSnippetDefaults';
import type { SqlSnippet } from '../types';
describe('sqlSnippetDefaults', () => {
it('DEFAULT_SQL_SNIPPETS should be a non-empty array', () => {
expect(Array.isArray(DEFAULT_SQL_SNIPPETS)).toBe(true);
expect(DEFAULT_SQL_SNIPPETS.length).toBeGreaterThan(0);
});
it('every default snippet should have required fields', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(s.id).toBeTruthy();
expect(s.prefix).toBeTruthy();
expect(s.name).toBeTruthy();
expect(s.body).toBeTruthy();
expect(s.isBuiltin).toBe(true);
expect(typeof s.createdAt).toBe('number');
}
});
it('every prefix should be lowercase alphanumeric/underscore', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(s.prefix).toMatch(/^[a-z0-9_]+$/);
}
});
it('prefixes should be unique', () => {
const prefixes = DEFAULT_SQL_SNIPPETS.map((s) => s.prefix);
expect(new Set(prefixes).size).toBe(prefixes.length);
});
it('ids should be unique', () => {
const ids = DEFAULT_SQL_SNIPPETS.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all default snippets should have snippet syntax in body', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
const hasTabStopOrVariable = /\$\d|\$\{|CURRENT_/.test(s.body);
expect(hasTabStopOrVariable).toBe(true);
}
});
it('time-variable snippets should contain CURRENT_ markers', () => {
const seld = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'seld');
expect(seld).toBeDefined();
expect(seld!.body).toContain('CURRENT_YEAR');
expect(seld!.body).toContain('CURRENT_MONTH');
expect(seld!.body).toContain('CURRENT_DATE');
const inst = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'inst');
expect(inst).toBeDefined();
expect(inst!.body).toContain('CURRENT_HOUR');
expect(inst!.body).toContain('CURRENT_MINUTE');
expect(inst!.body).toContain('CURRENT_SECOND');
});
it('BUILTIN_SNIPPET_MAP should contain all default snippet ids', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
expect(BUILTIN_SNIPPET_MAP[s.id]).toBeDefined();
expect(BUILTIN_SNIPPET_MAP[s.id].prefix).toBe(s.prefix);
expect(BUILTIN_SNIPPET_MAP[s.id].body).toBe(s.body);
}
});
it('BUILTIN_SNIPPET_MAP entries should be independent copies', () => {
for (const s of DEFAULT_SQL_SNIPPETS) {
const mapped = BUILTIN_SNIPPET_MAP[s.id];
expect(mapped).not.toBe(s);
}
});
});

View File

@@ -0,0 +1,154 @@
import type { SqlSnippet } from "../types";
const builtinSnippets: Omit<SqlSnippet, "createdAt">[] = [
{
id: "builtin-sel",
prefix: "sel",
name: "SELECT 基本查询",
description: "基本 SELECT 查询模板",
body: "SELECT ${1:column_list} FROM ${2:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-selw",
prefix: "selw",
name: "SELECT WHERE",
description: "带 WHERE 条件的 SELECT 查询",
body: "SELECT ${1:columns} FROM ${2:table_name} WHERE ${3:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-selj",
prefix: "selj",
name: "SELECT JOIN",
description: "带 INNER JOIN 的 SELECT 查询",
body: "SELECT ${1:columns}\nFROM ${2:t1}\nINNER JOIN ${3:t2} ON ${4:t1.id} = ${5:t2.id}\nWHERE ${6:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ins",
prefix: "ins",
name: "INSERT",
description: "INSERT 插入数据模板",
body: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})$0;",
isBuiltin: true,
},
{
id: "builtin-upd",
prefix: "upd",
name: "UPDATE",
description: "UPDATE 更新数据模板",
body: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-del",
prefix: "del",
name: "DELETE",
description: "DELETE 删除数据模板",
body: "DELETE FROM ${1:table_name}\nWHERE ${2:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ct",
prefix: "ct",
name: "CREATE TABLE",
description: "CREATE TABLE 建表模板",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)} NOT NULL\n)$0;",
isBuiltin: true,
},
{
id: "builtin-alt",
prefix: "alt",
name: "ALTER TABLE",
description: "ALTER TABLE 添加列模板",
body: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:col} ${3:VARCHAR(255)}$0;",
isBuiltin: true,
},
{
id: "builtin-dro",
prefix: "dro",
name: "DROP TABLE",
description: "DROP TABLE 删表模板",
body: "DROP TABLE IF EXISTS ${1:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-grp",
prefix: "grp",
name: "GROUP BY",
description: "带 GROUP BY 的聚合查询模板",
body: "SELECT ${1:col}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:col}$0;",
isBuiltin: true,
},
{
id: "builtin-ljo",
prefix: "ljo",
name: "LEFT JOIN",
description: "LEFT JOIN 左连接模板",
body: "LEFT JOIN ${1:t} ON ${2:left.col} = ${3:right.col}$0",
isBuiltin: true,
},
{
id: "builtin-sub",
prefix: "sub",
name: "子查询",
description: "IN 子查询模板",
body: "SELECT ${1:cols}\nFROM ${2:t1}\nWHERE ${3:col} IN (\n SELECT ${4:col} FROM ${5:t2} WHERE ${6:cond}\n)$0;",
isBuiltin: true,
},
{
id: "builtin-lim",
prefix: "lim",
name: "LIMIT 查询",
description: "带 LIMIT 的分页查询模板",
body: "SELECT ${1:cols} FROM ${2:table_name} LIMIT ${3:10}$0;",
isBuiltin: true,
},
{
id: "builtin-ord",
prefix: "ord",
name: "ORDER BY",
description: "带排序的查询模板",
body: "SELECT ${1:cols} FROM ${2:table_name} ORDER BY ${3:col} ${4|ASC,DESC|}$0;",
isBuiltin: true,
},
{
id: "builtin-seld",
prefix: "seld",
name: "SELECT 按日期查询",
description: "按日期条件过滤的 SELECT 查询,自动填入当天日期",
body: "SELECT ${1:cols} FROM ${2:table_name}\nWHERE ${3:date_col} >= '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}'$0;",
isBuiltin: true,
},
{
id: "builtin-ctt",
prefix: "ctt",
name: "CREATE TABLE含时间列",
description: "建表模板,含 created_at / updated_at 时间列",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)},\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n)$0;",
isBuiltin: true,
},
{
id: "builtin-inst",
prefix: "inst",
name: "INSERT含时间戳",
description: "INSERT 模板,自动填入当前时间戳",
body: "INSERT INTO ${1:table_name} (${2:columns}, created_at)\nVALUES (${3:values}, '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}')$0;",
isBuiltin: true,
},
];
const now = Date.now();
export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = builtinSnippets.map(
(s, i) => ({
...s,
createdAt: now + i,
})
);
export const BUILTIN_SNIPPET_MAP: Record<string, SqlSnippet> = {};
for (const s of DEFAULT_SQL_SNIPPETS) {
BUILTIN_SNIPPET_MAP[s.id] = { ...s };
}

View File

@@ -1,12 +1,28 @@
import { describe, expect, it } from 'vitest';
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
import {
resolveTitleBarToggleIconKey,
resolveWindowsScaleCheckDelayMs,
shouldApplyWindowsScaleFix,
shouldToggleMaximisedWindowForScaleFix,
} from './windowStateUi';
describe('windowStateUi', () => {
it('does not re-toggle a maximized window on activation when focus returns', () => {
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
});
it('only applies the Windows scale fix on real ratio drift', () => {
expect(shouldApplyWindowsScaleFix('activation', true)).toBe(false);
expect(shouldApplyWindowsScaleFix('ratio-change', true)).toBe(true);
});
it('debounces resize-triggered Windows scale checks until window transitions settle', () => {
expect(resolveWindowsScaleCheckDelayMs('resize')).toBeGreaterThan(0);
expect(resolveWindowsScaleCheckDelayMs('focus')).toBe(0);
expect(resolveWindowsScaleCheckDelayMs('poll')).toBe(0);
});
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
});

View File

@@ -1,11 +1,17 @@
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
export type WindowScaleFixReason = 'activation' | 'ratio-change';
export type WindowsScaleCheckTrigger = 'focus' | 'pageshow' | 'poll' | 'resize' | 'visibilitychange';
export type TitleBarToggleIconKey = 'maximize' | 'restore';
export const shouldToggleMaximisedWindowForScaleFix = (
export const shouldApplyWindowsScaleFix = (
reason: WindowScaleFixReason,
hasViewportScaleDrift: boolean,
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
export const shouldToggleMaximisedWindowForScaleFix = shouldApplyWindowsScaleFix;
export const resolveWindowsScaleCheckDelayMs = (trigger: WindowsScaleCheckTrigger): number =>
trigger === 'resize' ? 240 : 0;
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
windowState === 'maximized' ? 'restore' : 'maximize';

View File

@@ -180,6 +180,8 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;

View File

@@ -350,6 +350,10 @@ export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function PreviewChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['PreviewChanges'](arg1, arg2, arg3, arg4);
}
export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}

View File

@@ -1,10 +1,23 @@
export namespace ai {
export class ToolCallFunction {
name: string;
arguments: string;
static createFrom(source: any = {}) {
return new ToolCallFunction(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.arguments = source["arguments"];
}
}
export class ToolCall {
id: string;
type: string;
// Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" }
function: any;
function: ToolCallFunction;
static createFrom(source: any = {}) {
return new ToolCall(source);
@@ -14,7 +27,7 @@ export namespace ai {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.function = this.convertValues(source["function"], Object);
this.function = this.convertValues(source["function"], ToolCallFunction);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -41,6 +54,7 @@ export namespace ai {
images?: string[];
tool_call_id?: string;
tool_calls?: ToolCall[];
reasoning_content?: string;
static createFrom(source: any = {}) {
return new Message(source);
@@ -53,6 +67,7 @@ export namespace ai {
this.images = source["images"];
this.tool_call_id = source["tool_call_id"];
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
this.reasoning_content = source["reasoning_content"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -176,6 +191,7 @@ export namespace ai {
}
}
}
@@ -667,10 +683,12 @@ export namespace connection {
httpTunnel?: HTTPTunnelConfig;
driver?: string;
dsn?: string;
connectionParams?: string;
timeout?: number;
redisDB?: number;
uri?: string;
clickHouseProtocol?: string;
oceanBaseProtocol?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
@@ -710,10 +728,12 @@ export namespace connection {
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
this.driver = source["driver"];
this.dsn = source["dsn"];
this.connectionParams = source["connectionParams"];
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.clickHouseProtocol = source["clickHouseProtocol"];
this.oceanBaseProtocol = source["oceanBaseProtocol"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];

View File

@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

View File

@@ -108,13 +108,13 @@ func (p *AnthropicProvider) Validate() error {
// --- 请求体类型 ---
type anthropicRequest struct {
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
}
// anthropicTool Anthropic 格式的工具定义
@@ -321,10 +321,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
toolCalls = append(toolCalls, ai.ToolCall{
ID: block.ID,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Function: ai.ToolCallFunction{
Name: block.Name,
Arguments: argsStr,
},
@@ -388,9 +385,9 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
// 跟踪当前活跃的 tool_use blocks
type activeToolUse struct {
id string
name string
argsJSON strings.Builder
id string
name string
argsJSON strings.Builder
}
activeBlocks := make(map[int]*activeToolUse) // index -> block
@@ -443,10 +440,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
{
ID: block.id,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Function: ai.ToolCallFunction{
Name: block.name,
Arguments: argsStr,
},

View File

@@ -84,21 +84,25 @@ type openAIChatRequest struct {
}
type openAIChatMessage struct {
Role string `json:"role"`
Content interface{} `json:"content,omitempty"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Role string `json:"role"`
Content interface{} `json:"content,omitempty"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {
messages := make([]openAIChatMessage, len(reqMessages))
replayReasoningContent := shouldReplayReasoningContent(modelName, baseURL)
for i, m := range reqMessages {
if m.Role == "tool" {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID}
continue
}
if len(m.ToolCalls) > 0 {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
msg := openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
attachReasoningContent(&msg, m, replayReasoningContent)
messages[i] = msg
continue
}
@@ -127,20 +131,37 @@ func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL str
},
})
}
messages[i] = openAIChatMessage{Role: m.Role, Content: contentParts}
msg := openAIChatMessage{Role: m.Role, Content: contentParts}
attachReasoningContent(&msg, m, replayReasoningContent)
messages[i] = msg
} else {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
msg := openAIChatMessage{Role: m.Role, Content: m.Content}
attachReasoningContent(&msg, m, replayReasoningContent)
messages[i] = msg
}
}
return messages
}
func attachReasoningContent(msg *openAIChatMessage, source ai.Message, enabled bool) {
if enabled && source.Role == "assistant" && source.ReasoningContent != "" {
msg.ReasoningContent = source.ReasoningContent
}
}
func shouldReplayReasoningContent(modelName string, baseURL string) bool {
model := strings.ToLower(strings.TrimSpace(modelName))
base := strings.ToLower(strings.TrimSpace(baseURL))
return strings.Contains(model, "deepseek") || strings.Contains(base, "deepseek")
}
// openAIChatResponse OpenAI API 响应体
type openAIChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
@@ -227,7 +248,8 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
}
return &ai.ChatResponse{
Content: result.Choices[0].Message.Content,
Content: result.Choices[0].Message.Content,
ReasoningContent: result.Choices[0].Message.ReasoningContent,
TokensUsed: ai.TokenUsage{
PromptTokens: result.Usage.PromptTokens,
CompletionTokens: result.Usage.CompletionTokens,
@@ -342,7 +364,10 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
// 支持 DeepSeek/千问等模型的 reasoning_content 字段
if choice.Delta.ReasoningContent != "" {
receivedContent = true
callback(ai.StreamChunk{Thinking: choice.Delta.ReasoningContent})
callback(ai.StreamChunk{
Thinking: choice.Delta.ReasoningContent,
ReasoningContent: choice.Delta.ReasoningContent,
})
}
if choice.FinishReason != nil {

View File

@@ -2,6 +2,8 @@ package provider
import (
"GoNavi-Wails/internal/ai"
"encoding/json"
"strings"
"testing"
)
@@ -165,3 +167,80 @@ func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
}
}
func TestBuildOpenAIMessages_ReplaysDeepSeekReasoningContentForToolCalls(t *testing.T) {
toolCall := testOpenAIToolCall()
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "",
ToolCalls: []ai.ToolCall{toolCall},
ReasoningContent: "需要先检查表结构",
},
{
Role: "tool",
Content: `{"ok":true}`,
ToolCallID: toolCall.ID,
},
}, "deepseek-v4", "https://api.deepseek.com/v1")
if got[0].ReasoningContent != "需要先检查表结构" {
t.Fatalf("expected reasoning_content to be replayed for DeepSeek tool call, got %q", got[0].ReasoningContent)
}
if got[1].ReasoningContent != "" {
t.Fatalf("expected tool result message not to carry reasoning_content, got %q", got[1].ReasoningContent)
}
body, err := json.Marshal(got[0])
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if !strings.Contains(string(body), `"reasoning_content":"需要先检查表结构"`) {
t.Fatalf("expected JSON payload to include reasoning_content, got %s", body)
}
}
func TestBuildOpenAIMessages_OmitsReasoningContentForNonDeepSeekProviders(t *testing.T) {
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "",
ToolCalls: []ai.ToolCall{testOpenAIToolCall()},
ReasoningContent: "reasoning should stay local",
},
}, "gpt-4o", "https://api.openai.com/v1")
if got[0].ReasoningContent != "" {
t.Fatalf("expected non-DeepSeek provider to omit reasoning_content, got %q", got[0].ReasoningContent)
}
body, err := json.Marshal(got[0])
if err != nil {
t.Fatalf("marshal message: %v", err)
}
if strings.Contains(string(body), "reasoning_content") {
t.Fatalf("expected JSON payload to omit reasoning_content for non-DeepSeek provider, got %s", body)
}
}
func TestBuildOpenAIMessages_ReplaysDeepSeekAssistantReasoningContentWithoutToolCalls(t *testing.T) {
got := buildOpenAIMessages([]ai.Message{
{
Role: "assistant",
Content: "最终分析",
ReasoningContent: "工具调用轮次的最终思考也需要保留",
},
}, "deepseek-v4", "https://api.deepseek.com/v1")
if got[0].ReasoningContent != "工具调用轮次的最终思考也需要保留" {
t.Fatalf("expected DeepSeek assistant reasoning_content to be replayed, got %q", got[0].ReasoningContent)
}
}
func testOpenAIToolCall() ai.ToolCall {
var toolCall ai.ToolCall
toolCall.ID = "call_schema"
toolCall.Type = "function"
toolCall.Function.Name = "inspect_table_schema"
toolCall.Function.Arguments = `{"table":"orders"}`
return toolCall
}

View File

@@ -866,9 +866,10 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string]
}
return map[string]interface{}{
"success": true,
"content": resp.Content,
"tool_calls": resp.ToolCalls,
"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,
@@ -903,11 +904,12 @@ func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools []
err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) {
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
"content": chunk.Content,
"thinking": chunk.Thinking,
"tool_calls": chunk.ToolCalls,
"done": chunk.Done,
"error": chunk.Error,
"content": chunk.Content,
"thinking": chunk.Thinking,
"reasoning_content": chunk.ReasoningContent,
"tool_calls": chunk.ToolCalls,
"done": chunk.Done,
"error": chunk.Error,
})
})

View File

@@ -2,12 +2,15 @@ package ai
// ToolCall 表示 AI 发出的工具调用
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
ID string `json:"id"`
Type string `json:"type"` // "function"
Function ToolCallFunction `json:"function"`
}
// ToolCallFunction 表示单次工具调用的函数信息
type ToolCallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ToolFunction 表示可使用的函数定义
@@ -25,11 +28,12 @@ type Tool struct {
// Message 表示一条对话消息
type Message struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"`
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"`
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode 工具调用链路要求原样回传
}
// ChatRequest AI 对话请求
@@ -42,9 +46,10 @@ type ChatRequest struct {
// ChatResponse AI 对话响应
type ChatResponse struct {
Content string `json:"content"`
TokensUsed TokenUsage `json:"tokensUsed"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
TokensUsed TokenUsage `json:"tokensUsed"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// TokenUsage token 用量统计
@@ -56,11 +61,12 @@ type TokenUsage struct {
// StreamChunk 流式响应片段
type StreamChunk struct {
Content string `json:"content"`
Thinking string `json:"thinking,omitempty"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
Content string `json:"content"`
Thinking string `json:"thinking,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ProviderConfig AI Provider 配置

View File

@@ -179,6 +179,11 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
normalized := config
normalized.ID = ""
normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type))
if normalized.Type == "oceanbase" {
protocol := resolveOceanBaseProtocolForApp(normalized)
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCacheWithProtocol(normalized.ConnectionParams, protocol)
normalized.OceanBaseProtocol = ""
}
// timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。
normalized.Timeout = 0
normalized.SavePassword = false
@@ -209,6 +214,7 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
normalized.User = ""
normalized.Password = ""
normalized.URI = ""
normalized.ConnectionParams = ""
normalized.Hosts = nil
normalized.Topology = ""
normalized.MySQLReplicaUser = ""
@@ -450,6 +456,9 @@ func formatConnSummary(config connection.ConnectionConfig) string {
if strings.TrimSpace(config.URI) != "" {
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
}
if strings.TrimSpace(config.ConnectionParams) != "" {
b.WriteString(fmt.Sprintf(" 连接参数=已配置(长度=%d)", len(config.ConnectionParams)))
}
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
b.WriteString(" MySQL从库凭据=已配置")
}
@@ -474,6 +483,13 @@ func formatConnSummary(config connection.ConnectionConfig) string {
}
b.WriteString(fmt.Sprintf(" ClickHouse协议=%s", protocol))
}
if strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") {
protocol := "mysql"
if isOceanBaseOracleProtocol(config) {
protocol = "oracle"
}
b.WriteString(fmt.Sprintf(" OceanBase协议=%s", protocol))
}
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))

View File

@@ -81,6 +81,26 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
}
}
func TestGetCacheKey_KeepConnectionParamsIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "mysql",
Host: "127.0.0.1",
Port: 3306,
User: "root",
Password: "root",
Database: "app",
ConnectionParams: "charset=utf8",
}
modified := base
modified.ConnectionParams = "charset=utf8mb4"
left := getCacheKey(base)
right := getCacheKey(modified)
if left == right {
t.Fatalf("expected different cache key for different connection params")
}
}
func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "clickhouse",
@@ -99,3 +119,95 @@ func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
t.Fatalf("expected different cache key for different ClickHouse protocols")
}
}
func TestGetCacheKey_KeepOceanBaseProtocolIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "sys@oracle001",
Database: "ORCL",
ConnectionParams: "protocol=mysql",
}
modified := base
modified.ConnectionParams = "protocol=oracle"
left := getCacheKey(base)
right := getCacheKey(modified)
if left == right {
t.Fatalf("expected different cache key for different OceanBase protocols")
}
}
func TestGetCacheKey_KeepOceanBaseExplicitProtocolIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "sys@oracle001",
Database: "ORCL",
}
modified := base
modified.OceanBaseProtocol = "oracle"
left := getCacheKey(base)
right := getCacheKey(modified)
if left == right {
t.Fatalf("expected different cache key for explicit OceanBase Oracle protocol")
}
}
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToMySQL(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "root@test",
Database: "app",
}
modified := base
modified.ConnectionParams = "protocol=mysql"
left := getCacheKey(base)
right := getCacheKey(modified)
if left != right {
t.Fatalf("expected default OceanBase protocol to equal mysql, got %s vs %s", left, right)
}
}
func TestGetCacheKey_KeepOceanBaseDefaultProtocolEquivalentToExplicitMySQL(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "root@test",
Database: "app",
}
modified := base
modified.OceanBaseProtocol = "mysql"
left := getCacheKey(base)
right := getCacheKey(modified)
if left != right {
t.Fatalf("expected default OceanBase protocol to equal explicit mysql, got %s vs %s", left, right)
}
}
func TestGetCacheKey_OceanBaseProtocolParamWinsOverAliases(t *testing.T) {
base := connection.ConnectionConfig{
Type: "oceanbase",
Host: "ob.local",
Port: 2881,
User: "root@test",
Database: "app",
ConnectionParams: "protocol=mysql",
}
modified := base
modified.ConnectionParams = "protocol=mysql&tenantMode=oracle"
left := getCacheKey(base)
right := getCacheKey(modified)
if left != right {
t.Fatalf("expected explicit protocol=mysql to win over alias, got %s vs %s", left, right)
}
}

View File

@@ -15,7 +15,11 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
}
switch strings.ToLower(strings.TrimSpace(config.Type)) {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine", "clickhouse":
case "oceanbase":
if !isOceanBaseOracleProtocol(config) {
runConfig.Database = name
}
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "opengauss", "sqlserver", "mongodb", "tdengine", "clickhouse":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":
@@ -42,7 +46,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
return rawDB, rawTable
}
dbType := strings.ToLower(strings.TrimSpace(config.Type))
dbType := resolveDDLDBType(config)
if dbType == "sqlserver" {
// SQL Server 的 DB 接口约定第一个参数是数据库名schema 由 tableName(如 dbo.users) 自行解析。
// 不能把 schema(dbo) 传到第一个参数,否则会拼出 dbo.sys.columns 等无效对象名。
@@ -62,7 +66,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
}
switch dbType {
case "postgres", "kingbase", "highgo", "vastbase":
case "postgres", "kingbase", "highgo", "vastbase", "opengauss":
// PG/金仓/瀚高/海量dbName 在 UI 里是"数据库"schema 需从 tableName 或使用默认 public。
return "public", rawTable
default:

View File

@@ -50,6 +50,34 @@ func TestNormalizeSchemaAndTable_PostgresStillSplitsQualifiedName(t *testing.T)
}
}
func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) {
t.Parallel()
config := connection.ConnectionConfig{
Type: "oceanbase",
Database: "OBORCL",
OceanBaseProtocol: "oracle",
}
runConfig := normalizeRunConfig(config, "SYS")
if runConfig.Database != "OBORCL" {
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
}
}
func TestNormalizeSchemaAndTable_OceanBaseOracleUsesSchemaFromDatabaseTree(t *testing.T) {
t.Parallel()
schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{
Type: "oceanbase",
OceanBaseProtocol: "oracle",
}, "SYS", "ORDERS")
if schema != "SYS" || table != "ORDERS" {
t.Fatalf("expected OceanBase Oracle schema/table SYS.ORDERS, got %q.%q", schema, table)
}
}
func TestQuoteTableIdentByType_KingbaseNormalizesQuotedQualifiedTable(t *testing.T) {
t.Parallel()

Some files were not shown because too many files have changed in this diff Show More