Compare commits

..

449 Commits

Author SHA1 Message Date
Syngnat
836b8258f7 ♻️ refactor(settings): 重构设置中心布局与交互规范
- 扁平化设置中心与 AI/MCP 信息层级,移除冗余卡片容器
- 统一字体、按钮、控件密度和嵌入式设置样式
- 完善键盘导航、焦点恢复、状态保留和多语言回归测试
2026-07-22 23:32:39 +08:00
Syngnat
caf450d4f0 🐛 fix(import): 修复导入进度与任务停止
- 修正 SQL 文件执行进度提前完成及终态竞态
- 支持停止导入并保留已提交的部分统计
- 完善取消审计、多语言提示与回归测试

Refs #677
Refs #678
2026-07-22 22:36:19 +08:00
Syngnat
e11706f9a9 feat(release): 接入 R2 镜像并限制发布保留量
- 应用与驱动更新优先使用 R2,并保留 GitHub 回退
- stable/dev 各保留当前版本并实施 9GB 发布预算
- 增加指针校验、安全清理和发布回归测试
2026-07-22 21:43:57 +08:00
Syngnat
e30c2d8c62 🐛 fix(ui): 修复拖拽与缩放残留导致的交互锁死
- 统一管理 pointer capture、全局监听器与选择状态生命周期
- 在失焦、捕获丢失、按键释放和组件卸载时自动清理交互状态
- 覆盖 AI、查询结果、工作台与结果比对浮窗拖拽缩放
- 修复 Redis、表设计、通用弹窗、数据网格和侧栏的残留监听
- 增加异常释放、重复终止和卸载清理等交互回归测试
2026-07-22 18:56:54 +08:00
Syngnat
c19fcdd183 🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复
- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件
- 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡
- 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点
- 拒绝复用终态子进程,并恢复失败的附加与关闭操作
- 串行展示、隐藏和终态退出,消除关闭门闩并发竞态
- 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
2026-07-22 18:45:55 +08:00
Syngnat
75749d2465 feat(table-overview): 新增紧凑表格视图并持久化视图偏好 2026-07-22 17:56:33 +08:00
Syngnat
0ea807c3cb feat(query-editor): 新增自动换行并持久化编辑器设置 2026-07-22 17:53:12 +08:00
Syngnat
0fb9857378 🐛 fix(query-editor): 修复查找框全选被编辑器快捷键拦截
- 在可编辑目标内放行 Ctrl/Cmd+A 原生全选
- 保留文档级快捷键对 Monaco 编辑器的兜底全选
- 补充查找输入框与源码范围回归测试
2026-07-22 17:45:51 +08:00
Syngnat
c067ad8a62 🐛 fix(shortcuts): 迁移侧边栏搜索默认快捷键
- 将旧版 Meta/Ctrl+F 一次性迁移为各平台 Meta/Ctrl+K
- 升级持久化版本并避免启动刷新重新读回旧配置
- 保留禁用状态、自定义组合及版本 18 后的 F 配置
- 补充 macOS、Windows/Linux 与旧结构回归测试
2026-07-22 16:55:44 +08:00
Syngnat
8ca2c23d18 🐛 fix(ci): 补全前端 statSync 类型声明
- 补齐 node:fs 测试 shim 的 statSync 返回类型
- 修复干净 CI 环境下前端 TypeScript 构建失败
2026-07-22 16:53:49 +08:00
Syngnat
bf060f0082 feat(about): 关于页自动检查更新开关 + 自定义检查间隔 2026-07-22 15:40:46 +08:00
Syngnat
93bb164004 🐛 fix(modal): 统一浮层层级并修复设置窗口遮挡
- 统一主窗口、原生分离窗口和静态 API 的 Ant Design 浮层基线
- 提升更新、导出、安全更新与连接代理子弹窗层级
- 将业务 Modal 接入统一封装并修复命令面板 stacking context
- 增加浮层策略、设置流程和 Modal 导入守卫测试
2026-07-22 15:37:20 +08:00
virgosgod-maker
d4d3ac1f34 Merge branch 'Syngnat:dev' into dev 2026-07-22 15:26:24 +08:00
mango
d774aa741e feat(about): 关于页自动检查更新开关 + 自定义检查间隔
- 新增 autoCheckForUpdates 开关(默认开启)
- 新增 autoCheckForUpdatesIntervalMinutes(默认30分钟,可选15/30/60/120/360/720/1440)
- hook timer 按开关和间隔动态注册
- 关于页版本与更新卡片增加 Switch + Select 控件
- 6语种 i18n 文案
- store / hook / settings-center 测试补充
2026-07-22 15:24:55 +08:00
Syngnat
82af1e365e feat(data-grid): 支持只读单元格查看完整内容
- 只读查询结果和不可写字段支持双击打开只读 Monaco 查看器
- 保留 MongoDB 原始值与待提交值,并兼容空别名和空值
- 隔离数据源、权限与虚拟编辑会话,阻止旧回调误保存
- 补齐六种语言文案及 DataGrid 回归测试
2026-07-22 14:42:16 +08:00
Syngnat
ece61d097f feat(data-grid): 支持只读查询结果拖选复制
- 允许聚合与只读结果拖选单元格并复制 TSV
- 增加选区工具栏操作和 Ctrl/Cmd+C 快捷键
- 隔离后台结果页与编辑操作,避免快捷键抢占

Fixes #673
2026-07-22 12:30:30 +08:00
Syngnat
b272c2e39c ️ perf(jvm-monitor): 回收停止的监控会话 2026-07-22 09:36:57 +08:00
Syngnat
23d95f2771 ️ perf(store): 限制表访问频次状态增长 2026-07-22 09:35:41 +08:00
Syngnat
78880cfce5 test(i18n): 更新性能重构后的源码守卫 2026-07-22 09:06:02 +08:00
Syngnat
89b65cfb2d ️ perf(result-diff): 主动回收过期比对会话 2026-07-22 09:00:33 +08:00
Syngnat
5424f22677 ️ perf(native-window): 稳定结果同步回调避免全量序列化 2026-07-22 08:54:24 +08:00
Syngnat
92dd0cc650 ️ perf(startup): 延迟加载结果窗口数据网格 2026-07-22 08:53:56 +08:00
Syngnat
1fe1e51791 ️ perf(redis-monitor): 暂停后台页签轮询 2026-07-22 08:45:11 +08:00
Syngnat
62b7ea9aa9 ️ perf(startup): 延迟加载 AI 重型模块 2026-07-22 07:52:04 +08:00
Syngnat
375b472143 ️ perf(db-cache): 防止释放竞态复活数据库连接 2026-07-22 07:47:34 +08:00
Syngnat
c6ca44aa44 ️ perf(workbench): 隔离 SQL 输入引发的全局重绘 2026-07-22 07:28:02 +08:00
Syngnat
d864a83527 ️ perf(native-window): 避免热路径序列化 AI 上下文 2026-07-22 01:51:33 +08:00
Syngnat
ea7c05f28f ️ perf(query-completion): 限制大元数据候选构建 2026-07-22 01:47:14 +08:00
Syngnat
f9b685f5e7 ️ perf(build): 启用生产依赖分块配置 2026-07-22 01:41:07 +08:00
Syngnat
2015676ece ️ perf(er-diagram): 限制元数据缓存长期增长 2026-07-22 01:30:27 +08:00
Syngnat
813e9cde2c ️ perf(driver-agent): 限制长期运行错误日志缓存 2026-07-22 01:29:15 +08:00
Syngnat
cd55228e1e ️ perf(store): 合并持久化写入并复用状态投影 2026-07-22 01:14:26 +08:00
Syngnat
074f695e15 ️ perf(window): 降低空闲状态原生轮询频率 2026-07-22 01:10:56 +08:00
Syngnat
f64c4a4b98 ️ perf(db-cache): 合并并发冷连接并缩短缓存锁
按连接缓存键合并并发建连,避免同一数据源瞬间创建重复物理连接。

将慢速 Close 移出全局缓存锁,并保持每个调用的连接保活策略,补充并发回归测试。
2026-07-22 00:49:18 +08:00
Syngnat
7c7ed0ca81 ️ perf(ai-chat): 隔离流式消息重绘并复用工具结果索引
将消息行按引用和相关工具结果精确 memo,避免流式输出重绘全部历史消息。

复用单次工具结果索引,并为最新流式消息增加 O(1) 定位热路径与 500 条消息回归测试。
2026-07-22 00:45:27 +08:00
Syngnat
26e980917f ️ perf(workbench): 隔离后台页签日志订阅
仅让活跃查询编辑器订阅 SQL 日志,并在侧栏命令搜索关闭时保持稳定空快照。

补充隐藏页签重绘、激活切换和最近日志顺序回归测试。
2026-07-22 00:34:40 +08:00
Syngnat
76e74a02fd ️ perf(data-grid): 合并当前页查找扫描
将匹配坐标与命中统计合并为单次单元格遍历,避免重复格式化整页数据。

使用延迟值调度大结果集查找,同时保留清空输入时立即移除高亮的行为。
2026-07-22 00:20:17 +08:00
Syngnat
c0b1b0873e ️ perf(query-draft): 将大草稿持久化移出输入热路径
将防抖后的序列化与 localStorage 写入转移到空闲任务,并合并重复调度。

保留 pagehide、beforeunload 与显式同步刷新,补充大草稿和调度边界回归测试。
2026-07-22 00:13:00 +08:00
Syngnat
419480800c ️ perf(logger): 批量同步日志以消除全局 I/O 阻塞
- 移除每条日志在全局锁内执行的磁盘同步
- 使用脏标记和单槽队列合并后台刷盘请求
- 保证错误日志快速触发同步并在关闭时完整落盘
- 增加慢磁盘、并发关闭、空闲刷盘与日志顺序回归测试
2026-07-22 00:05:07 +08:00
Syngnat
7916538a19 ️ perf(query-editor): 避免后台查询页重复加载元数据
- 仅允许活动查询页拉取数据库列表和自动补全元数据
- 标签切换后忽略旧数据库列表请求的延迟回包
- 增加多查询页切换回归测试,限制后台 RPC 扇出
2026-07-21 23:54:58 +08:00
Syngnat
fda0733f11 ️ perf(native-window): 优化 AI 独立窗口启动与交互
- 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态
- 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销
- 修复设置中心层级、Windows 前台切换及独立窗口关闭交互
- 补齐状态同步、断线重放与窗口生命周期回归测试
2026-07-21 23:40:17 +08:00
Syngnat
62a045b39d 🐛 fix(native-window): 修复 Intel macOS 构建类型转换错误
将 Cocoa BOOL 桥接结果统一转换为整数值。

兼容 darwin/amd64 与 darwin/arm64 的 cgo 类型映射。
2026-07-21 19:45:33 +08:00
Syngnat
5c3535c85d feat(native-window): 支持 macOS Dock 管理独立窗口
- 在主程序 Dock 菜单动态列出并聚焦已就绪的独立窗口
- 在 Wails 启动前固定子进程为 Accessory,消除临时 Dock 图标闪现
- 按多显示器可见区域修正越界窗口并回写实际坐标
- 将自定义 AI 面板快捷键同步到独立窗口并转交主窗口处理
- 补齐 Go、Objective-C、前端及 Wails 绑定回归测试
2026-07-21 18:52:54 +08:00
Syngnat
d7b60891c5 feat(mcp): 支持 OpenCode 客户端接入
- 识别用户目录与 XDG 路径下的 OpenCode JSON/JSONC 配置
- 保留注释、自定义字段与符号链接并原子写入 MCP 配置
- 增加 CLI 检测、安装状态、自动接入及旧路径修复
- 在客户端选择、浏览器模拟、AI 检查提示和六语言文案中接入 OpenCode
- 引入 hujson、补充许可声明及前后端回归测试
2026-07-21 18:52:19 +08:00
Syngnat
3c8692b133 🐛 fix(connection/elasticsearch): 修复索引加载超时与测试按钮卡顿
- 使用 CAT Indices 轻量枚举索引并兼容 ES 7.3 参数
- CAT 失败时在统一超时预算内回退 Alias API
- 分离连接测试与保存状态并后台加载数据库列表
- 隔离过期测试请求,避免弹窗重开后状态污染
- 补充索引回退、超时预算与按钮状态回归测试
2026-07-21 18:41:58 +08:00
Syngnat
446707698f feat(table-copy): 新增整表复制与自动副本命名
- 在表概览及新旧侧栏右键菜单接入复制整表入口与确认、进度和刷新反馈
- 按 source_copyN 原子创建目标表并复制列、索引、默认值及全部数据
- 处理 PostgreSQL 生成列、identity/serial 序列校准与失败清理
- 限定安全数据源与连接保护策略,阻止分区表、RLS 及引用型存储引擎
- 加固 MySQL/PostgreSQL 元数据标识符处理并补齐六语种文案
- 增加后端、能力矩阵、菜单接线和国际化回归测试
2026-07-21 13:27:24 +08:00
Syngnat
49e566e01e 📝 docs(readme): 将体积表述从 ~10MB 更新为 ~30MB 量级
与当前发行包体量及仓库 About 描述对齐。
2026-07-21 12:29:27 +08:00
Syngnat
37e196d122 📝 docs(readme): 将反馈联系方式写入 README 并下线 #536 置顶依赖
新增「联系与反馈」章节(GitHub Issues + 微信),便于用户直接在文档中找到交流入口。
2026-07-21 12:19:47 +08:00
Syngnat
af2922a937 📝 docs(readme): 补充 Windows 内网 WebView2 Runtime 安装与排查
覆盖在线 Bootstrapper、离线 Standalone 静默安装、安装校验与 Web Server 绕过,并关联 #672。
2026-07-21 12:13:18 +08:00
Syngnat
8c4014c931 feat(ci): 评论「我想做」自动认领 Issue
在 Issue 下评论认领关键词时,优先写入 Assignees;无写权限则标记 claimed 并记录认领状态,避免双人撞车。
2026-07-21 11:59:23 +08:00
Syngnat
60d172254d 🐛 fix(update): 安装前确认并安全关闭当前安装的全部实例
- 安装更新前先确认关闭当前安装目录下的 GoNavi 实例,并继续执行未保存 SQL 退出保护
- 按可执行文件路径枚举并复核进程身份,优雅关闭超时后再强制终止,避免 PID 复用误杀
- 通过全局维护事件阻止更新期间启动新实例,并由 PowerShell 更新器完成跨权限无空窗交接
- 同步更新前端交互、多语言提示、Wails 绑定及 Windows 发布资产校验
2026-07-21 10:55:08 +08:00
Syngnat
2597d087ad feat(windows): 支持 MSI 主程序严格单实例启动
- 仅对带 MSI 安装标识的 Windows 主程序启用单实例门禁,Portable 与特殊运行模式保持原行为
- 重复启动通过命名事件唤醒首个窗口,并兼容 Wails 运行时尚未就绪的激活请求
- 新增并发抢占、窗口激活与安装模式测试,覆盖同一安装仅保留一个主实例
2026-07-21 10:49:52 +08:00
Syngnat
b75fd37b05 🐛 fix(update): 修复下载进度弹窗层级
- 隐藏下载进度时销毁旧弹窗 Portal,重新查看时恢复到设置中心上层
- 保留后台下载进度与完成状态,支持隐藏后反复查看
- 补充弹窗挂载策略及更新状态回归测试
2026-07-21 09:01:22 +08:00
Syngnat
0f474e4918 🐛 fix(ci): 修复标题栏资源测试的类型错误
- 为 PNG 资源读取显式指定 base64 编码
- 恢复前端及 Docker 镜像构建的 TypeScript 校验
2026-07-20 22:19:57 +08:00
Syngnat
118da179f6 🐛 fix(titlebar): 修复暗色主题标题栏图标白底
- 为默认品牌图标增加透明紧凑标题栏素材
- 增加独立标题栏资源解析并保留其他品牌图标现有展示
- 移除标题栏图标强制白色背景
- 补充默认图标、未知图标回退及透明素材测试
2026-07-20 20:23:18 +08:00
Syngnat
b2819db495 feat(shortcuts): 支持快捷键关闭当前标签页
- 新增可自定义的关闭当前标签页动作,macOS 默认 ⌘W,Windows/Linux 默认 Ctrl+W
- 根据最近交互区域路由工作区或结果标签关闭,并隔离分离窗口
- 关闭日志标签时隐藏整个结果区,避免连续按键误关工作区
- 沿用未保存 SQL 与导入任务等工作区关闭保护
- 隔离 IME、快捷键录制、弹窗、抽屉与上下文菜单
- 补充多语言文案及快捷键路由回归测试
2026-07-20 20:22:35 +08:00
Syngnat
db74e01447 📝 docs(spec): 设计标签关闭快捷键路由 2026-07-20 16:37:30 +08:00
Syngnat
049ec7bdf1 feat(data-workbench): 直达批量操作并新增数据导入工作台
- 批量表和批量库入口无需预选目标即可进入统一工作台
- 将对象选择、导出、清空和删除操作迁入可持续查看的任务界面
- 新增侧栏数据导入入口并复用预览、字段映射和进度流程
- 保护运行中的导入标签并同步复用工作台的连接上下文
- 补齐多语言文案及批量操作、导入链路回归测试
2026-07-20 15:34:20 +08:00
Syngnat
21e0921b3b feat(data-sync/export): 支持同步与备份后台工作台
- 将数据同步及各级 SQL 备份统一迁入稳定工作区
- 全局保留任务状态、进度和结构化日志,关闭后可重新接回
- 支持从当前任务及历史记录打开 SQL 恢复工作台
- 锁定运行中配置并禁止后台任务分离到独立窗口
2026-07-20 13:43:58 +08:00
Syngnat
19d962d9ba feat(data-export): 增强备份进度与原子写入
- 为整库与 Schema 备份补齐逐对象进度和最终文件路径
- 通过临时文件同步落盘和原子替换保护已有完整备份
- 增加任务进度及失败保留回归测试
2026-07-20 13:43:20 +08:00
Syngnat
89e35b087a 🐛 fix(sqlserver): 修复存储过程执行消息丢失
- 按消息协议仅在 MsgNext 时扫描结果集,避免空结果边界提前耗尽消息
- 保留连续 DONE 边界后的 PRINT 输出与纯消息结果
- 更新 SQL Server driver-agent revision 并补充回归测试
2026-07-20 13:40:53 +08:00
Syngnat
258f5bdd64 🐛 fix(tab-manager): 修复工作区标签批量关闭菜单失效
- 将右键菜单项和下拉菜单排除在标签拖拽触发区域外
- 避免 dnd-kit 指针捕获吞掉关闭其他页及左右侧关闭操作
- 补充菜单项不触发拖拽的指针回归测试
2026-07-20 13:40:29 +08:00
Syngnat
02eec5aee3 🐛 fix(query-editor): 修复 SQL 执行与结果标签交互
- 空白光标回退执行编辑器内全部有效 SQL
- 修复活动结果标签必须切换日志后才能关闭的问题
- 阻止关闭按钮和菜单指针误触结果标签分离拖拽
- 补充空白行、尾部空白和结果标签关闭回归
2026-07-20 13:40:03 +08:00
Syngnat
63ffa12958 🐛 fix(connection): 修复多数据源连接与浏览异常
- 修复 Milvus 表概览元数据查询与 SELECT 预览路由异常
- ClickHouse 非标端口 Native 握手超时后继续 HTTP 自动探测
- Elasticsearch 连接测试与索引枚举解耦,避免健康服务误判
- 更新可选驱动 revision 并补充回归测试
2026-07-20 10:55:19 +08:00
Syngnat
cb5f6a668f feat(datagrid): 支持按单元格选区删除记录
- 统一单元格选区与复选框行的删除目标和计数,按记录行去重处理
- 保留复选框优先级,新增行直接移除,已有记录进入待提交删除
- 隔离页内查找高亮,并在刷新、数据重载或上下文切换时清理旧选区
- 补充选区删除、刷新保护、查找隔离和新增行回归测试
2026-07-20 10:21:01 +08:00
Syngnat
ae0eb03c21 🐛 fix(datagrid): 修复 macOS 触摸板横向滚动回跳
- 持续横向滚动期间守护最新视觉偏移,避免虚拟列表异步回写旧位置
- 增加可取消的逐帧视觉守卫并完善交互与卸载清理
- 补充守卫时序及 DataGrid 滚动接线回归测试
2026-07-20 09:43:36 +08:00
Syngnat
54c06ed113 feat(context-menu): 增强字段与数据库名称复制交互
数据表列头支持按元数据复制字段注释;数据库节点旧版与 V2 菜单支持复制数据库名;补齐六语言文案与回归测试。

Fixes #547
2026-07-19 18:17:14 +08:00
Syngnat
ea9ccb3648 🐛 fix(release): 修复自动更新日志缺少贡献者署名
- 基于关联 PR 与提交元数据解析外部贡献者账号
- 生成 GitHub 原生用户提及以展示贡献者头像名单
- 增加 API 熔断、版本历史告警与 CI 回归测试
2026-07-19 17:39:36 +08:00
Syngnat
c4f24a6245 🐛 fix(data-export): 自动补齐导出文件格式后缀
- 为 CSV、XLSX、JSON、Markdown、HTML 和 SQL 保存对话框绑定对应过滤器并统一补齐扩展名
- 覆盖单表、查询、批量 SQL、数据库、Schema 与 SQL 审计导出路径
- 防止补后缀后的目标绕过覆盖确认,并补充真实落盘、多语言与安全回归测试
2026-07-19 14:27:00 +08:00
Syngnat
453bed439d 🐛 fix(data-export): 修复查询重放 SQL 导出缺少目标表
- 导出工作台执行 SQL 查询重放时传递已知目标表
- 保持 MariaDB 方言与列元数据由后端统一处理
- 补充当前页 SQL 导出的真实组件回归测试
2026-07-19 14:02:03 +08:00
Syngnat
2428fe6cc5 🐛 fix(tdengine): 修复查询结果仅显示执行日志
- TDengine 只读查询跳过不支持的多结果集探测
- 保证可选驱动代理通过普通查询返回完整行列数据
- 补充应用层与驱动层回归测试

Fixes #610
2026-07-19 13:32:52 +08:00
Syngnat
94ad5ae2f6 feat(connection): 支持自定义 SQL 探活
- 新增连接级自定义探活 SQL 配置与多语言表单校验
- 限制单条 SELECT/WITH 并使用可取消的超时查询
- 隔离陈旧探活策略,避免配置错误驱逐健康连接

Fixes #611
2026-07-19 12:29:21 +08:00
Syngnat
82c62fd55c feat(query-result): 新增结果页快速查看表 DDL
- 识别单表查询并在 Legacy 与 V2 结果视图提供 DDL 入口
- 按数据库方言独立解析并传递 DDL 目标至浮动与独立窗口
- 避免多表和歧义查询误展示入口并补充回归测试

Fixes #537
2026-07-19 10:55:13 +08:00
Syngnat
3e9335d6ad 🐛 fix(ci): 修复草稿发布分页结果解析 [skip ci]
- 直接读取 Release workflow 与 release 列表响应
- 避免 paginate 映射回调产生空元素导致校验异常
- 保持目标提交和完整资产校验不变
2026-07-19 01:06:47 +08:00
Syngnat
b6cfeccfe4 🔧 ci(release): 支持发布分支触发草稿发布 [skip ci]
- 允许 publish/v* 临时分支触发受校验的发布流程
- 从分支名安全解析目标版本并复用完整资产检查
- 保留 workflow_dispatch 作为具备 Actions 权限时的入口
2026-07-19 01:04:55 +08:00
Syngnat
0fb986868b 🔧 ci(release): 新增草稿发布安全校验 [skip ci]
- 校验目标标签对应的 Release workflow 已成功完成
- 确认 13 项核心安装与更新资产均已上传
- 使用最小 GitHub 权限手动发布草稿并设为最新版
2026-07-19 01:03:37 +08:00
Syngnat
e404fa3ac0 🐛 fix(data-grid): 兼容无 navigator 的服务端渲染
- 通过 globalThis 安全注入浏览器 navigator,避免 Node 20 渲染期抛错
- 补充无 navigator 环境下的 DataGrid 服务端渲染回归测试
- 保持浏览器剪贴板行为不变
2026-07-19 00:22:02 +08:00
Syngnat
7913811549 ️ perf(data-grid): 优化大数据量横纵向滚动性能
- 为固定行高结果集启用常数时间纵向窗口计算和横向列虚拟化
- 合并横向滚动预览与空闲提交,减少滚动期间重复渲染
- 固化 rc-table 与 rc-virtual-list 补丁并同步 Wails、Docker 和 CI 安装链路
- 补充滚动边界、页内查找及调度回归测试
2026-07-19 00:10:25 +08:00
Syngnat
af49d15563 🐛 fix(table-metadata): 修复统计字段被误识别为表名 (#666)
## 背景

DBGetTables 会同时返回 Table、Rows、Data_length 和 Index_length 等字段。部分前端入口通过
Object.values(row)[0] 获取表名,Go map 序列化后的字段顺序变化时,行数或存储大小会被误当成表名。

## 变更内容

- 新增共享表元数据解析器,优先读取明确的表名字段
- 兼容 Table、table_name、tableName、name、MySQL Tables_in_*、字符串行和旧单字段响应
- 多字段响应缺少明确表名时不再猜测首个字段
- 统一 AI 关联上下文、AI 数据库工具、查询补全、ER 图、侧边栏、全库搜索、数据同步、批量导出、导出工作台和表概览的解析逻辑
- 补充统计字段顺序、兼容格式、空值和重复名称的回归测试

## 影响范围

仅调整前端对 DBGetTables 返回值的表名解析,不修改后端 API、数据库查询或表统计逻辑。

## 验证

- 12 个相关 Vitest 文件,共 62 条测试通过
- npm run build 通过,8669 个模块完成生产构建
- git diff --check 通过
2026-07-18 21:49:58 +08:00
AutumnNazi
6e58240c38 🐛 fix(table-metadata): 修复统计字段被误识别为表名
- 新增共享表元数据解析器,优先读取明确的表名字段
- 统一 AI、查询补全、侧边栏、导出和表概览等调用路径
- 补充字段顺序、兼容格式及去重行为的回归测试
2026-07-18 21:03:25 +08:00
Syngnat
3aea725f7b feat(import-export): 支持 SQL 导出前删除同名对象
- 新增默认关闭的 DROP IF EXISTS SQL 导出选项和数据删除提示
- 覆盖数据库、Schema、单表、批量导出及工作台入口
- 按方言生成逆序 DROP 前导段并保持纯数据与旧 API 兼容

Fixes #514
2026-07-18 18:20:30 +08:00
Syngnat
fd093b3325 feat(clickhouse): 支持自定义连接兼容 JDBC DSN
- 解析 ClickHouse 与 JDBC 风格 DSN 并映射协议、TLS、认证及数据库参数
- 统一连接缓存、DDL、数据同步与导出 driver-agent 生命周期
- 保持 DSN 加密存储并补充前后端回归测试和多语言提示

Fixes #383
2026-07-18 17:48:19 +08:00
Syngnat
0136b87311 🐛 fix(proxy): 避免全局代理影响数据库连接
移除数据库和 Redis 连接对全局代理的隐式继承

保留连接自身代理与 HTTP 隧道配置并修正缓存释放链路

同步六种语言的代理作用域说明并增加回归测试

Fixes #347
2026-07-18 16:43:39 +08:00
Syngnat
aeecffb517 feat(table-designer): 补齐 MySQL 空间字段类型
- 补充 Geometry、Point、MultiPoint 等八种 MySQL 空间类型候选
- 保留自定义字段类型表达式在建表和改表 SQL 中的原样输出
- 增加空间类型候选及 POINT SRID 属性回归测试

Fixes #341
2026-07-18 16:15:33 +08:00
Syngnat
5f5b28ced3 🐛 fix(schema): 修复 PostgreSQL 模式备份恢复与重命名
- 模式备份脚本写入安全转义的 CREATE SCHEMA IF NOT EXISTS
- 支持仅大小写变化的 PostgreSQL 模式重命名
- 补充备份头顺序、非 PostgreSQL 隔离和模式 DDL 回归测试

Fixes #480
2026-07-18 15:51:48 +08:00
Syngnat
15fba1d89f feat(import-export): 优化数据表导入导出能力
- 修复表格格式导出时空值显示为 null 的问题
- 支持按列导出并保持字段顺序
- 支持导入列与数据库字段映射及冲突校验
- 隔离并发导入任务进度并完善元数据回退

Fixes #646
2026-07-18 15:17:54 +08:00
Syngnat
f08bcb5e10 🐛 fix(monaco-editor): 修复 WebKit 输入法组合输入时异常滚动
- 仅在原生 WebKit 环境稳定 Monaco 输入法组合期间的文本区滚动位置
- 在组合结束、失焦和编辑器销毁时及时停止并清理监听器
- 补充 WebKit、Chromium 隔离及销毁清理回归测试
2026-07-18 14:00:19 +08:00
Syngnat
7017db418b 🐛 fix(updater): 修复 MSI 更新后任务栏图标失效与桌面快捷方式重建
- 自动迁移引用失效 MSI 缓存图标的旧任务栏固定项并刷新 Shell
- 在在线更新前备份桌面快捷方式状态,成功或失败后安全恢复
- 将桌面快捷方式拆分为条件安装组件并补充 Windows 回归测试
2026-07-18 13:48:35 +08:00
Syngnat
6cd2835066 feat(ai): 支持 Codex 与 Claude 订阅登录
- 新增基于本机官方 CLI 登录状态的 Codex 与 Claude 订阅 Provider
- 隔离 API Key、工具与项目配置,完善登录校验和错误处理
- 补充设置界面、国际化及前后端回归测试

Fixes #651
2026-07-18 11:57:25 +08:00
Syngnat
07a4fe1fc9 🐛 fix(query-editor): 修复 MySQL 关键字需美化后才着色
- 根据当前连接方言为 MySQL 系查询启用 Monaco MySQL 语法
- 将补全、悬浮、斜杠命令和代码片段同步注册到 MySQL 语言
- 增加方言映射和首屏着色回归测试

Fixes #612
2026-07-18 10:32:51 +08:00
Syngnat
1ee8f7f980 🐛 fix(ci): 修复 Docker 冒烟测试端口冲突
- Web Server 冒烟测试使用随机宿主端口
- 动态解析映射端口并复用健康检查与鉴权验证
2026-07-18 09:51:19 +08:00
Syngnat
d0a6a97f71 🐛 fix(sql-file): 修复整库备份导入失败
Fixes #662
2026-07-17 23:37:53 +08:00
Syngnat
e24942083a 🐛 fix(sidebar): 顶部新建查询绑定选中数据库
Fixes #665
2026-07-17 22:40:36 +08:00
Syngnat
e7857bb2e4 🐛 fix(ci): 修复跨 Runner 驱动版本误判
使用构建阶段 SHA 绑定 provenance 校验驱动发布资产。

保留原生二进制元数据和 legacy fallback 校验,并固定发布任务 Go 环境。

Fixes #622
2026-07-17 21:50:15 +08:00
Syngnat
8f52cbc319 🐛 fix(query-editor): 修复选区输入首字符丢失
- 为 Monaco 可打印输入兜底补充非空选区替换恢复
- 区分原生成功、仅删除选区和完全丢失,避免重复插入
- 覆盖连续输入、重选文本和模型事件光标竞态
- 新增 &lt; 单次替换为 < 的回归测试
2026-07-17 17:39:02 +08:00
Syngnat
ca8be18531 🐛 fix(ci): 校验驱动代理发布产物版本来源
- 原生平台执行 metadata 校验真实 driver type 与 revision
- 跨平台构建生成与二进制 SHA256 绑定的 provenance
- 复用旧资产时要求清单 SHA 与当前源码 revision 同时匹配
- 将 provenance 接入开发版和正式版发布聚合流程
- 发布链变更触发全平台 driver-agent 重建
2026-07-17 17:07:37 +08:00
Syngnat
54f18cdd2f 🐛 fix(driver-agent): 阻止运行不匹配的驱动代理
- 连接前复用安装阶段的严格 revision 与 metadata 校验
- 对旧版或无法验证的 agent 返回明确重装错误而非继续运行
- 覆盖 stale、metadata 失败、当前版本和自定义驱动边界
- 隔离 SQL 审计测试对本机已安装 agent 的依赖
2026-07-17 17:06:44 +08:00
Syngnat
0ca8b1960c 🐛 fix(milvus): 兼容不支持数据库枚举的服务
- 数据库列表接口不可用时验证当前数据库的集合枚举能力
- 验证成功后回退到已配置数据库并继续加载集合
- 补充测试连接成功但侧栏展开失败的回归用例
2026-07-17 17:06:08 +08:00
Syngnat
3f4b247faa feat(native-window): 完善原生独立窗口与跨屏拖拽
- 支持全部工作台标签与 AI 对话在多显示器中原生独立展示
- 修复拖拽指针捕获,恢复关闭、右键菜单和跨窗口释放识别
- 拆分子窗启动入口并按需加载语言与工作台内容
- 内容与 Monaco 完成可见绘制后再提交迁移,消除白屏和加载闪烁
- 加固结果编辑、宿主同步及关闭、超时和焦点竞态
2026-07-17 16:06:24 +08:00
Syngnat
a86f5c6078 🐛 fix(data-grid): 保留独立窗口完整结果快照
- 数据变更回传不再受界面隐藏列过滤
- 同步编辑与删除后的完整结果行数据
- 补充独立窗口隐藏列快照回归测试
2026-07-17 16:05:24 +08:00
Syngnat
38a18b9b8e 🐛 fix(ai): 稳定流式会话终止与窗口迁移
- 跟踪并等待全部流生产者退出,避免重叠请求残留
- 窗口迁移期间阻止新请求并保留最终流式消息
- 锁定会话切换与重试操作,状态落稳后再解除发送态
- 同步终止握手绑定并补充回归测试
2026-07-17 16:04:33 +08:00
Syngnat
f2d622b53f 🐛 fix(ai): 规范 OpenAI system 消息顺序
Fixes #663
2026-07-16 22:39:33 +08:00
Syngnat
fa87c2ee9f Merge pull request #658 from AutumnNazi/fix/msi-shortcut-icon 2026-07-16 21:16:02 +08:00
Syngnat
43b33ca42a Merge pull request #664 from AutumnNazi/fix/middle-click-close-tab 2026-07-16 21:11:36 +08:00
Syngnat
fa9d21be8b feat(native-window): 支持标签页拖出为原生独立窗口
- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口
- 通过受认证 loopback bridge 复用主进程后端与状态
- 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复
- 补充多窗口协议、状态同步和跨平台回归测试
2026-07-16 20:25:08 +08:00
Syngnat
d3dee295d6 🐛 fix(query-results): 修复 SQLite 与 SQL Server 查询结果空页
- SQLite 只读查询优先普通 Query,并安全降级不支持的多结果集接口
- SQL Server 在结果集边界前恢复未扫描数据,保持 data/affectedRows 顺序
- 普通与事务查询过滤状态空壳,修正结果展示与审计返回行数
- 更新 SQL Server driver-agent revision 并补充 SQLite、MSSQL 回归测试
2026-07-16 20:24:39 +08:00
AutumnNazi
35427d08a8 feat(tab): 支持鼠标中键关闭标签页
- 在标签标题上识别鼠标中键并阻止默认滚动行为
- 复用现有标签关闭流程,保留 SQL 文件未保存确认
- 修正停靠标签关闭范围断言并增加中键交互回归测试
2026-07-16 20:19:34 +08:00
Syngnat
193d68398b 🔧 chore(license): 补齐开源许可与发布声明
- 新增项目版权及上游 NOTICE
- 让桌面、容器、驱动发布包携带法律文档
- 补充 Wails 和前端许可证元数据
- 增加发布资产回归校验
2026-07-16 17:24:16 +08:00
Syngnat
d14ac65db9 🐛 fix(connection): 修复多数据源连接打开异常
- 修复 Milvus 等数据源类型被错误回退为 MySQL
- 为 ClickHouse 22.8 增加旧版 JSON HTTP 兼容回退
- 使用轻量别名接口验证 Elasticsearch 索引枚举
- 更新可选驱动 revision 并补充回归测试
2026-07-16 17:23:16 +08:00
Syngnat
c90dcce859 🐛 fix(driver-agent): 修复驱动重装循环与旧资产复用
- 在线重装与本地导入统一使用 staging 并严格校验 agent revision
- 旧候选失败时继续回退,激活异常时恢复原驱动与运行文件
- 修复公共驱动源码变更被误判为单一驱动的问题并补齐 Trino
- 显式传播依赖枚举失败,避免生成部分 revision 指纹
- 将驱动发布契约回归接入开发版与正式版构建流程
2026-07-16 15:57:49 +08:00
Syngnat
dfa6836580 🐛 fix(sidebar): 修复元数据展示开关未过滤嵌套对象
- 修复子节点数量不变时误复用未过滤父节点的问题
- 保留标签、连接、数据库与 Schema 包装层中的深层过滤结果
- 补充多层侧栏树对象显隐回归测试
2026-07-16 15:56:15 +08:00
AutumnNazi
8f4bd9a3a6 🐛 fix(installer): 修复 MSI 更新后快捷方式图标失效
- 让开始菜单和桌面快捷方式直接使用 GoNavi.exe 内嵌图标
- 避免 Major Upgrade 后 Shell 缓存继续引用已删除的 MSI 图标资源
- 增加 Windows 安装产物回归测试,锁定快捷方式图标来源
2026-07-16 14:38:20 +08:00
Syngnat
0080cb33ac 🐛 fix(table-overview): 修复未知存储大小误显示为零
将 Oracle、达梦和默认方言不支持的存储统计改为 NULL 占位。

补充 Oracle 与默认方言回归测试,保留 SQLite 真实零值的 0 B 展示。
2026-07-16 13:10:29 +08:00
AutumnNazi
2400823ca4 🐛 fix(sqlite): 修复表概览行数与大小统计
- 通过 driver-agent 为 SQLite 表列表补充精确行数与存储占用
- 使用 dbstat 区分数据页和索引页并支持相对大小展示
- 区分未知统计与真实零值并补充前后端回归测试
2026-07-16 12:44:18 +08:00
Syngnat
695fad283c 🐛 fix(ci): 补全前端 Node 文件系统类型声明
- 为测试环境的 node:fs shim 增加 readdirSync 返回类型
- 避免本机上级 @types/node 掩盖隔离构建错误
- 恢复 Dev Build 与 Web Server Docker 镜像前端编译
2026-07-16 12:11:29 +08:00
Syngnat
6de7807f01 🐛 fix(query-editor): 修复 Oracle 同名同义词补全
- 合并 PR #654,并按 OWNER 与名称保留同义词元数据
- 无限定名优先登录用户,缺失时回退 PUBLIC,显式 schema 精确匹配
- 保持 GetTables 仅返回物理表,避免影响导出、ER 与 ROWID 判断
- 补充 owner 解析、PUBLIC 回退及物理表查询回归测试
2026-07-16 11:49:15 +08:00
Syngnat
3de7e1bb40 feat(settings): 将工具中心整合至设置中心
- 侧栏移除独立工具入口并统一使用设置齿轮
- 设置中心扩展为六组导航并复用原有工具内页
- 统一安全更新、连接配置、AI 设置与工具页面的返回路径
- 清理快捷键捕获、连接密码弹窗和 AI 修复上下文等瞬态状态
- 更新设置、侧栏、SQL 审计与国际化回归测试
2026-07-16 11:17:59 +08:00
Syngnat
ac4b520295 🐛 fix(brand): 统一图标资源并适配 macOS Dock
- 将 40 个重复 PNG 变体收敛为 10 个 WebP 品牌资源
- 品牌选择器、关于页、favicon 与 Dock 统一复用所选图标
- 按 824px 安全区和 184px 标准圆角合成 macOS Dock 图标
- 补充资源映射、品牌展示和 Dock 合成回归测试
2026-07-16 11:16:47 +08:00
Syngnat
823fd54ef0 📝 docs(readme): 替换透明背景品牌 Logo
- 中英文 README 统一引用仓库内品牌 Logo
- 使用带透明通道的 512px 图片移除底色
- 保持 GitHub 页面与仓库预览直接可用
2026-07-16 11:14:48 +08:00
半袭纸鸢
5413089db0 Merge 4df1dac2c2 into 5d91deaade 2026-07-16 11:04:02 +08:00
autumn
4df1dac2c2 feat(data-grid): 支持查询结果导出 INSERT SQL
- 导出弹窗新增 INSERT SQL 格式并支持选中行、当前页和全部结果
- 单表查询自动映射真实表名与字段类型,连表结果使用带方言引号的 <table_name> 占位符
- 复用流式导出与批量 INSERT 生成逻辑,兼容 PostgreSQL 布尔字面量
- 补充前后端回归测试,覆盖单表、连表、别名和占位表场景
2026-07-16 10:58:00 +08:00
Syngnat
5d91deaade 🔧 chore(frontend): 同步 package 校验文件
- 将 package.json.md5 同步为当前 frontend/package.json 的真实 MD5
- 消除工作台更新遗留的过期校验值
- 不改动依赖声明或自动生成的 TS 绑定
2026-07-16 08:44:19 +08:00
Syngnat
b781a1197e 📝 docs(readme): 修复 GitHub 截图展示 2026-07-16 08:29:14 +08:00
Syngnat
176b100274 🐛 fix(app): 修复品牌图标与 SQLite 查询展示
- 修复 Windows、macOS 与关于页的品牌图标展示及资源裁切问题
- 补齐 SQLite 表记录数与 SELECT 查询结果渲染
- 增加品牌图标和 SQLite 回归测试
2026-07-15 22:53:47 +08:00
Syngnat
d87e314880 🎨 style(brand): 吉祥物品牌图标可选,并同步 macOS Dock
新增 10 款品牌吉祥物资源与设置页选择器;标题栏/关于页/favicon 跟随切换,
macOS 通过原生 API 更新 Dock 图标(满铺浅蓝 Dock 专用图)。
同步默认 appicon 与 README 品牌展示。
2026-07-15 19:55:20 +08:00
yx755141
9a693ec4b1 fix: GetTables 增加 UNION ALL all_synonyms
imp_basicinfo 名下只有同义词没有实际表,仅查 all_tables 返回空。
增加 all_synonyms 后 sbdev 输入 imp_basicinfo. 时能提示 AC01/AC02。
2026-07-15 17:29:42 +08:00
yx755141
1bf8271562 fix: 去掉OWNER=USER限制,ALL_SYNONYMS本身已权限过滤 2026-07-15 16:58:07 +08:00
tianqijiuyun-latiao
8a5209c084 ️ perf(data-grid): 优化大数据量横向滚动性能 2026-07-15 15:26:53 +08:00
tianqijiuyun-latiao
89c25e3019 🐛 fix(updater): 阻止 Windows 多实例并发安装更新 2026-07-15 15:26:53 +08:00
辣条
c8e7495f50 查询同义词,查询所有有权限的,不止当前租户 (#653) 2026-07-15 14:25:27 +08:00
yx755141
1b4e47d709 查询同义词,查询所有有权限的,不止当前租户 2026-07-15 14:20:57 +08:00
Syngnat
6548f939a2 🎨 style(brand): 替换 GoNavi 品牌 Logo 为 orbit-core
- 更新仓库根与前端 public 的 logo.svg 为轨道核心标识
- 同步 build/appicon.png(1024)及 macOS icns、Windows ico
- 视觉语义:核心圆为数据中枢,双轨道为跨库导航,卫星点为连接节点
2026-07-15 13:58:44 +08:00
tianqijiuyun-latiao
1458b57c0d Merge branch 'refs/heads/dev' into feature/20260602_connection_driver_i18n 2026-07-15 13:32:04 +08:00
tianqijiuyun-latiao
1c1adb8331 🐛 fix(redis): 防止命名空间行误触批量选择 2026-07-15 13:31:48 +08:00
tianqijiuyun-latiao
6f65f9e7cc 🐛 fix(sidebar): 修复 OceanBase Oracle 只读对象加载 2026-07-15 13:17:40 +08:00
Syngnat
5b8e7b538c 🐛 fix(query-editor): 修复视图与同义词补全
- 跨用户视图补全避免重复 owner 前缀
- 按结果集字段名去重视图列候选
- 加载 Oracle 私有及 PUBLIC 同义词并解析目标字段
- 补充跨用户视图与同义词回归测试
2026-07-15 11:37:36 +08:00
Syngnat
a0bb19b8bb feat(sidebar): 支持自定义对象分类显示
新增侧栏对象分类开关与仅显示表快捷设置
配置持久化并即时应用到所有连接
补充筛选、持久化与国际化回归测试
2026-07-14 21:53:57 +08:00
Syngnat
3fa4f8e900 🐛 fix(query-editor): 修复 OceanBase 视图查询与输入丢字
- 仅对元数据确认的 OceanBase Oracle 基表注入 ROWID
- 视图和同义词查询改为只读,避免触发 OBE-01445
- 在 SQL 补全中加入普通视图和物化视图候选
- 修复 beforeinput 已拦截时首个可打印字符被跳过的问题
- 补充视图、同义词和输入回归测试
2026-07-14 17:11:07 +08:00
Syngnat
8a1c1842ee feat(saved-query): 支持多级已存查询分组
- 新增已存 SQL 分组的持久化模型与后端管理接口
- 支持创建、编辑、嵌套、排序、移动和删除分组
- 在侧栏树和菜单中提供分组管理与未分组查询视图
- 同步 Wails 绑定、六种语言文案及前后端回归测试
2026-07-14 17:09:27 +08:00
Syngnat
7886cdc613 🐛 fix(sidebar): 排除已归属连接的分组选项
- 创建分组时仅展示未归属其他分组的连接
- 编辑分组时保留当前分组已有成员
- 无可选连接时显示明确空状态并补充多语言文案
- 增加连接筛选回归测试
2026-07-14 17:06:35 +08:00
Syngnat
454b6e0236 🐛 fix(dameng): 修复自增字段识别
通过达梦系统列元数据识别 IDENTITY 与 AUTO_INCREMENT。\n\n系统表不可访问时保留基础字段查询,并补充调用链回归测试。
2026-07-14 14:17:30 +08:00
Syngnat
c1e07dddaa feat(sidebar): 支持多层 Host 分组
- 支持分组父子层级与 Host/子分组混排排序
- 兼容旧分组配置并清理循环引用和重复归属
- 增加子分组创建、父分组选项及层级拖拽
- 删除分组时原位提升直属 Host 和子分组
2026-07-14 13:43:54 +08:00
Syngnat
1b3137d947 🐛 fix(transaction): 修复匿名SQL块自动提交
- 区分匿名 BEGIN...END 块与显式事务控制语句
- 统一前后端方言判定并挂起 Oracle、达梦和 SQL Server 块内 DML
- 补充编辑器、事务控制器及 SQL Server 回滚链路回归测试
2026-07-14 12:22:07 +08:00
Syngnat
72423e80a8 🐛 fix(datagrid): 修复 macOS 横向滚动拖拽抖动
- 拖动期间延迟虚拟表异步视觉重绘,减少重复样式写入
- 使用滚动空闲窗口保护外部原生滑块,避免偏移回写
- 松开拖拽后统一对齐表头、表体和滑块
- 补充横向滚动同步回归断言
2026-07-14 11:46:25 +08:00
Syngnat
5fd12d5dd8 🐛 fix(update): 同步 Windows Portable 更新版本
- 版本化 Portable 更新后切换到新包文件名并清理旧文件
- 下载完成进度采用后端实际返回的版本与资产
- 补充前端竞态和 Windows PowerShell 集成回归测试
2026-07-14 10:02:36 +08:00
Syngnat
c9ddd8919e feat(workbench): 丰富主页快捷资源与工作入口 2026-07-13 22:52:01 +08:00
Syngnat
b22a0894b8 🐛 fix(update): 修复隐藏下载进度后弹窗重开 2026-07-13 21:54:43 +08:00
Syngnat
e17e21b105 feat(workbench): 支持 Schema 筛选和历史快捷打开 2026-07-13 21:54:10 +08:00
Syngnat
57172f1296 feat(mcp-http): 持久化服务开关并支持启动恢复
保存启用偏好及运行参数,重启自动恢复;分离运行态与启用意图,补齐失败重试、并发收敛和回归测试。
2026-07-13 20:23:54 +08:00
Syngnat
2d5f246486 🐛 fix(kafka): 修复主题预览偏移量越界
- 使用绝对 offset 定位 Kafka 分区消息
- 避免 earliest offset 非零时重复叠加
- 增加直连预览读取的回归测试
2026-07-13 17:52:26 +08:00
Syngnat
0077c6dba7 ️ perf(sqlserver): 延长空闲连接复用窗口
- 将 SQL Server 单条空闲连接保留时间由 30 秒延长至 30 分钟
- 保持最大连接数和 30 分钟生命周期轮换策略不变
- Oracle、OceanBase 与其他数据库继续沿用原连接池策略
- 增加空闲窗口与连接复用边界回归测试
2026-07-13 17:30:23 +08:00
Syngnat
1423ac0902 🐛 fix(sql-editor): 修复退出保存重复生成查询副本
- 退出保存临时查询时复用原查询页签 ID
- 重启后通过稳定 ID 识别已经保存的同一条查询
- 增加连续退出不重复生成历史副本的回归测试
2026-07-13 17:30:02 +08:00
Syngnat
6958299ec2 🐛 fix(oceanbase): 修复 Oracle 模式单元格事务提交
- 单元格变更提交与预览使用所选 schema 限定目标表
- 对限定表名按分段方式引用,避免将 schema 与表名整体转义
- 为全列定位条件中的日期和时间值补充显式 Oracle 转换
- 增加 OceanBase Oracle 事务提交与 SQL 预览回归测试
2026-07-13 17:29:41 +08:00
Syngnat
6329345e73 🐛 fix(query-results): 修复分页统计与字段排序交互
- 为分页栏显示明确的首页和尾页入口,并接入真实的总数统计与取消回调
- 支持精确总数查询并隔离取消请求和过期响应,翻页时保留已统计总数
- 完整结果采用本地稳定排序,分页结果按数据库方言重新查询第一页
- 修复单元格编辑模式阻断列头排序箭头并补充回归测试
2026-07-13 17:29:15 +08:00
Syngnat
ef0845a9c5 🐛 fix(tabs): 修复短连接标签占用固定间距
- 将连接标签由固定 flex 基准改为按内容宽度收缩
- 保留 92px 最大宽度以避免长连接名称撑开标签
- 增加短连接名称间距回归测试
2026-07-13 17:28:33 +08:00
Syngnat
509962a773 feat(pagination): 增加首尾跳转并恢复查询翻页
- 自动限行结果保持未知总数语义并恢复后续页探测
- V2 与旧版分页增加首页和尾页快捷按钮
- 未知总页数时禁用尾页避免跳转到推算页
- 补齐六种语言文案及分页边界回归测试
2026-07-13 12:56:35 +08:00
Syngnat
b9e890379e feat(ai): 支持 OpenAI Responses 协议
- 设置中心为 OpenAI 预设增加 Chat 与 Responses 协议选择
- 实现 Responses API 同步、流式、推理摘要及工具调用适配
- 支持会话状态回放与上游能力不兼容时的自动降级
- 完善地址、模型列表和思考强度识别并补充回归测试
2026-07-13 12:55:58 +08:00
Syngnat
933e601816 🐛 fix(snippet): 保留代码片段尾部空白
- 持久化时完整保留代码片段正文中的尾部空格与换行
- 继续拒绝仅包含空白字符的无效片段
- 补充保存、存储及重新加载后的正文一致性测试
2026-07-13 12:55:22 +08:00
Syngnat
b23e768973 🐛 fix(query-editor): 修复 Oracle 序列查询自动限行报错
- 识别可执行 SQL 中的 NEXTVAL 与 CURRVAL 序列伪列
- Oracle、达梦及 OceanBase Oracle 序列查询跳过 ROWNUM 子查询包装
- 排除字符串和注释中的同名文本误判
- 补充自动限行与真实执行链路回归测试
2026-07-13 12:54:43 +08:00
Syngnat
4bee3cefa6 ️ perf(query-editor): 优化输入与选区交互流畅度
- 为 Monaco 编辑器及语言服务接入独立 Worker
- 限制大规模对象元数据与列装饰扫描范围
- 仅在按下导航修饰键时更新悬停状态
- 避免同一数据库上下文重复重建装饰并补充回归测试
2026-07-13 12:54:02 +08:00
Syngnat
1ae2b74279 🐛 fix(sql-parser): 修复尾随注释导致事务与只读判定异常
- 过滤分号后的纯注释语句并保留数据库可执行版本注释
- 前后端按数据库方言统一处理双横线、井号与块注释
- 修复事务选择、只读保护、SQL 审计及 AI 风险分析误判
- 补充流式 SQL、事务执行与方言解析回归测试
2026-07-13 12:52:56 +08:00
Syngnat
00473e7ac0 🐛 fix(theme): 提升连接状态与 SQL 关键字辨识度
- 为亮暗主题和内置预设增加独立连接状态语义色
- 强化 Host、数据库状态点及双行页签字段对比度
- 为 Monaco SQL 关键字补充高对比加粗规则
- 增加基础主题与内置预设的对比度回归测试
2026-07-13 09:54:23 +08:00
Syngnat
7bdce19d01 feat(release): 发布 Windows Portable 与 MSI 双产物
- Windows AMD64 与 ARM64 同时生成 Portable EXE 和 MSI 安装包
- 统一 stable 与 dev 的 MSI 产品身份并支持通道切换升级
- 发布清单同时包含两类资产及对应 SHA256 校验信息
- 在 Windows workflow 中执行双模式更新与 WiX 契约测试
2026-07-13 09:25:21 +08:00
Syngnat
431f740476 feat(update-ui): 区分 MSI 与 Portable 更新交互
- 接收后端安装方式与包类型并隔离同版本更新缓存
- 按 MSI 与 Portable 动态展示下载、安装和重启状态
- 保留未保存 SQL 的退出保护并统一两处安装入口
- 补齐六语言文案及前端更新交互测试
2026-07-13 09:24:44 +08:00
Syngnat
6e2e3afc85 feat(update): 支持 Windows MSI 与 Portable 分流更新
- 根据当前安装标记精确选择 MSI 或 Portable 更新包
- MSI 更新下载到用户缓存并通过 msiexec 保留原目录安装重启
- 下载与安装前校验包类型并隔离不同安装方式的缓存
- 补充安装模式、脚本执行和更新清理测试
2026-07-13 09:24:13 +08:00
Syngnat
4d90ed776f 🐛 fix(sql-audit): 修复固定操作列悬停内容穿透
- 覆盖审计表格固定操作列的不透明背景
- 使用不透明混合色保留行悬停反馈
- 增加固定列内容穿透回归测试
2026-07-12 16:28:17 +08:00
Syngnat
7c8e8d8dd3 feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
2026-07-12 15:24:27 +08:00
Syngnat
0b33b3a683 🐛 fix(query-editor): 修复 SQL Server 多查询冗余影响行数结果
- 将 SQL Server 成对返回的数据结果与 affectedRows 归属同一语句
- 保留 DML 影响行数与存储过程真实多结果集
- 补充后端正反用例及 V2 结果标签回归测试
2026-07-12 09:37:29 +08:00
Syngnat
ef5e344c4f feat(theme): 新增自定义主题与多套低刺激预设
- 支持导入、管理和持久化自定义 CSS 主题
- 新增六套内置主题并提供低刺激舒适暗色
- 完善 V2 主题令牌、多语言、可访问性与回归测试
2026-07-11 23:50:24 +08:00
Syngnat
034e968888 feat(sql-analysis): 完善慢 SQL 监控与执行计划诊断
- 完善慢查询采集、聚合、迁移与跨进程持久化

- 加固只读诊断边界并补齐多数据库执行计划解析

- 优化慢 SQL 面板、计划图、响应式布局与交互体验

- 补充后端、前端及 SQL Server 会话回归测试
2026-07-11 16:25:07 +08:00
Syngnat
5c80f6e9a2 ️ perf(sidebar): 优化左侧树搜索响应
延迟筛选配置持久化,避免输入期间触发全局状态更新。

稳定树标题渲染回调,减少可见节点重复协调。

补充搜索持久化与标题渲染回归测试。
2026-07-11 14:33:15 +08:00
Syngnat
cf979f436c feat(data-grid): 支持表头整列选择
- 单元格编辑模式下点击可写列表头选中整列数据

- 保留排序、筛选、外键跳转和列宽调整交互

- 补充表头整列选择回归测试

Fixes #622
2026-07-10 22:37:39 +08:00
Syngnat
328c9dbb28 feat(redis): 支持 List 单个值删除
- 为 Redis List 数据行增加删除操作与确认提示

- 新增 RedisListRemove RPC,使用 LREM 删除一个匹配元素

- 补充 Redis 命令、前端交互和本地化回归测试

Fixes #639
2026-07-10 22:21:08 +08:00
Syngnat
9870959ca6 🐛 fix(ui): 修复旧版编辑器布局并迁移默认界面至 V2
- 修复旧版 SQL 编辑器在弹性布局中的遮挡问题

- 将既有用户的界面偏好一次性迁移至 V2 并保留后续手动选择

- 覆盖持久化迁移与旧版编辑器布局回归测试
2026-07-10 22:00:05 +08:00
Syngnat
1ac9103735 🐛 fix(data-grid): 补全数据编辑事务语句 2026-07-10 21:21:42 +08:00
Syngnat
69be54c1cf 🐛 fix(web): 补齐浏览器端连接导入导出 2026-07-10 20:57:44 +08:00
Syngnat
689467ec91 🐛 fix(datagrid): 修复结果横向滚动条拖拽卡顿
- 合并同一帧内的外部横向滚动位置,避免每个原生事件触发布局写入
- 拖拽期间禁止虚拟表异步偏移反写外部滑块,松开后统一对齐
- 覆盖丢失指针捕获、窗口失焦、结果集隐藏和卸载时的同步状态清理
- 补充横向滚动同步回归断言
2026-07-10 18:10:27 +08:00
Syngnat
be09808d1f 🐛 fix(query-editor): 修复快捷执行与 Windows 输入丢字
- 快捷执行仅由 Monaco 编辑器处理,保留 Shift+Home 反向选区和光标位置
- 缓冲并恢复 Windows 下丢失的可打印输入和延迟 IME 提交
- 降低 AI 行内补全预检与元数据刷新造成的编辑器输入抖动
- 补充快捷键、输入回退和 AI 补全回归测试
2026-07-10 18:09:56 +08:00
Syngnat
18f40753dd feat(milvus): 新增 Milvus 向量数据库支持
- 内置 REST v2 驱动,支持集合浏览、向量检索和实体写入
- 支持认证、TLS、SSH 代理及多数据库连接切换
- 增加连接配置、图标、多语言文案和使用说明
- 补齐 JSON 查询路由与前后端回归测试
2026-07-10 15:37:54 +08:00
Syngnat
b23c1ba932 🐛 fix(query-results): 修复复杂查询固定列失效
为无物理表引用的结果集生成独立固定列作用域。

右键固定和取消固定不再依赖 tableName,保留只读编辑边界。

覆盖多行单表和关联查询的固定列回归。
2026-07-10 14:46:38 +08:00
Syngnat
e1fb5b12d4 🐛 fix(update): 修复 Windows 在线更新中文路径失效
- 将 Windows 更新脚本统一为 PowerShell,并通过 Unicode 环境变量传递中文与特殊字符路径
- 保留当前可执行文件路径,补充替换重试、启动健康检查、失败回滚和严格 ZIP 选择
- 更新前复用未保存 SQL 确认流程,并延长退出兜底时间
- 新增 Windows 原生集成测试并接入 dev/release 构建
2026-07-10 13:31:34 +08:00
Syngnat
06bfbc4a84 🐛 fix(window): 修复 Windows 后台可见时输入卡顿
- Windows 默认关闭透明 WebView、窗口透明与 Acrylic 背景
- 保留 macOS 透明效果并补充跨平台窗口视觉配置回归测试
- 同步更新多语言性能模式提示与开发文档
2026-07-10 11:16:34 +08:00
Syngnat
8d186fc066 feat(web-auth): 支持六位密码与环境变量托管
- 密码策略统一为至少六个可见字符,覆盖初始化与设置中心改密
- 支持 GONAVI_WEB_PASSWORD 启动同步并保留既有 2FA 与会话配置
- 设置中心识别环境托管状态并禁用临时改密
- Compose、env 示例及双语文档补充密码配置和容器重建说明
- 增加认证持久化、环境覆盖、前端状态与 Docker 登录回归校验
2026-07-10 10:34:03 +08:00
Syngnat
0a9aec5929 🐛 fix(docker): 修复容器驱动重装提示不消失
- Web 与 MCP 镜像编译前按目标平台生成驱动 revision
- 校验生成步骤位于源码复制后和 Go 编译前
- Docker 镜像工作流增加运行时 Dockerfile 回归检查
2026-07-10 10:04:38 +08:00
Syngnat
155ba0f1f7 🐛 fix(mcp): 修复在线更新后外部客户端连接失效
- 启动时自动迁移 Claude Code 与 Codex 中失效的 GoNavi MCP 路径
- 兼容 Windows 版本化 EXE 改名及旧进程占用导致的残留路径
- 限制迁移范围,保留仍有效的稳定路径与自定义命令
- 补充路径迁移和误覆盖边界回归测试
2026-07-10 09:44:26 +08:00
Syngnat
52592ffd6c 🐛 fix(query-editor): 修复 Oracle 查询自动限行语法
- 将 Oracle/Dameng 查询编辑器自动限行从 FETCH FIRST 改为 ROWNUM 兼容写法

- 识别 ROWNUM 自动封顶 SQL,避免结果分页继续基于截断 SQL 查询

- 补充自动限行与结果分页回归测试,覆盖 Oracle/Dameng 分支
2026-07-09 17:37:09 +08:00
Syngnat
92aedde121 🐛 fix(db): 优化 SQLServer 首次查询耗时
- 为 SQLServer 连接池保留 1 条空闲连接,减少反复冷连接开销

- 复用 Oracle/OceanBase 的默认空闲连接常量,其他默认数据库池策略保持不变

- 新增连接池行为测试,覆盖 SQLServer 复用与默认池关闭 idle 连接
2026-07-09 17:18:12 +08:00
Syngnat
b2df94a3ba 🐛 fix(window): 修复 mac 窗口恢复越界 (#643)
## 问题描述

macOS 多屏环境下,普通窗口恢复/修正位置时可能被定位到当前屏幕外,尤其是在外接屏位于主屏左侧或上方、浏览器
`screen.availLeft/availTop` 出现全局偏移时更容易触发。

## 复现条件说明

该问题不是所有 macOS 用户都能稳定复现,更依赖特定显示器布局和窗口状态。高概率触发条件包括:

- 使用 macOS 多屏。
- 外接屏位于主屏左侧或上方,导致 `screen.availLeft` / `screen.availTop` 为负数或非零。
- GoNavi 处于普通窗口态,而不是最大化或全屏。
- 应用保存过窗口位置,并在重启、切屏、拔插显示器、focus/resize 后触发窗口 bounds 恢复或修正。

单屏 mac、外接屏仅位于主屏右侧、始终最大化/全屏打开,或未保存过异常窗口位置的用户通常不容易触发。

## 根本原因

Wails 的 `WindowSetPosition(x, y)` 在 macOS 上接收的是当前 monitor
可见区域内的局部坐标,而不是全局桌面坐标。原逻辑把 `window.screen.availLeft/availTop`
作为可见区起点传入恢复算法,导致保存的局部坐标和恢复时的全局偏移混用。

## 修复方案

- 新增 `resolveWailsWindowVisibleViewport`,集中处理 Wails 主窗口恢复使用的可见区域。
- macOS 下将可见区原点固定为当前 monitor 局部 `(0, 0)`,避免把浏览器 screen 全局偏移继续传给 Wails。
- 补充单元测试覆盖负 `availLeft` 的外接屏场景。
- 更新 App 窗口恢复源码断言测试。

## 变更文件

| 文件 | 修改说明 | 变更行数 |
|---|---|---|
| `frontend/src/App.tsx` | macOS 下使用 Wails 局部坐标解析窗口可见区 | +7 -7 |
| `frontend/src/utils/wailsWindowViewport.ts` | 新增 Wails 窗口可见区解析工具 | +52
|
| `frontend/src/utils/wailsWindowViewport.test.ts` | 覆盖 macOS 局部原点和
fallback 场景 | +43 |
| `frontend/src/App.tool-center.test.ts` | 同步窗口恢复源码断言 | +2 -1 |

## 合并注意事项

- **影响功能点**:桌面端普通窗口启动恢复、focus/resize 后窗口 bounds 修正。
- **验证方式**:
- `npm test -- --run src/utils/wailsWindowViewport.test.ts
src/utils/windowRestoreBounds.test.ts
src/utils/windowStartupLayout.test.ts src/App.tool-center.test.ts`
  - `npm run build`
- **回归风险**:低。变更集中在窗口可见区解析;非 macOS 平台保留原 `screen.availLeft/availTop` 处理。
- **回滚方式**:revert 此 PR 即可。
2026-07-09 16:31:44 +08:00
Syngnat
19572a3cc9 feature(query-result): 支持SQL执行结果差异比对 2026-07-09 16:28:59 +08:00
Syngnat
8d7e7a6ddf feat(query-editor): 结果页列元数据跨库加载并实心化保存按钮
- 结果集携带 metadataDbName/TableName,列类型注释从真实库表加载
- 只读查询也解析 tableRef,避免结果页丢元数据
- 选择列底色与固定列对齐;保存按钮改为蓝色实心主按钮样式
- 独立结果窗/还原路径同步元数据字段
2026-07-09 15:37:27 +08:00
Syngnat
af9f490172 feat(datagrid): 完善左固定列体验并统一选中主题色
- 虚拟表横滚:表头 sticky + 表体 translateX 补偿,勾选/行号/钉住列同步固定
- 支持列头钉住左侧、钉住列显示图标;序号列可拖宽且不参与排序
- 行选中与单元格选区统一 V2 绿色,固定列底色对齐 --gn-bg-panel
- 查询结果页启用列头筛选,并在客户端按条件过滤当前结果集
2026-07-09 15:37:20 +08:00
DurianPankek
099443fddc 🐛 fix(window): 修复 mac 窗口恢复越界 2026-07-09 15:25:48 +08:00
Syngnat
947277bdec 🐛 fix(query-editor): 修复限定名超链接并支持跨库/schema 解析
- 两段名兼容 MySQL 库.表与 PG/SQL Server 的 schema.表
- 三段名支持 database.schema.object,常见 schema 不当成库拉取
- SQL 变更时按引用库防抖补拉元数据并刷新装饰
- 修复元数据 key 提前占死导致取消后不再拉取、超链接全灭
- 补充限定名解析与收集逻辑单测
2026-07-09 14:23:09 +08:00
Syngnat
b1bd4bb5ec feat(datagrid): 数据预览与 SQL 结果支持行号显示及设置开关
- 新增 appearance.showDataTableRowNumber,默认开启,主题设置可切换
- 数据预览与查询结果表统一跟随该偏好显示行号列
- 行号列收窄为固定窄宽,少列时多余宽度只加到数据列避免撑宽
- 补充 i18n 与相关单测
2026-07-09 14:23:09 +08:00
Syngnat
d210b3f39c 🐛 fix(window): 加固 Windows 冷启动半窗,原生最大化并兜底铺满工作区
- Windows 启动 WindowStartState 改为 Maximised,默认尺寸提升到 1440×900
- 无记忆/历史 1024×768/覆盖率过低的普通窗记忆改为启动最大化
- Maximise 多次失败时铺满工作区兜底,避免残留浮动半窗
- 用户刻意拉大的普通窗仍先 Unmaximise 再恢复尺寸位置
2026-07-09 14:02:53 +08:00
Syngnat
901b436107 🐛 fix(connection-package): 导出/导入连接包携带 Redis DB 别名
- 连接包 payload 与导出选项支持 redisDbAliases(连接 ID → db 序号 → 别名)
- 导入返回 ConnectionPackageImportResult,一并恢复别名映射
- 前端导出时注入 appearance.redisDbAliases,导入后 merge 回 appearance
- 补充别名往返测试与 mergeRedisDbAliases 单元测试
2026-07-09 13:57:32 +08:00
Syngnat
097826508a 🐛 fix(update): 修复 dev→latest 安装后丢包且仍停在旧版的问题
- 安装成功且 relaunch 发出后再删除下载包,失败保留 latest 包便于手动安装
- macOS open 使用路径形式,避免 -a 传完整路径导致重启失败
- 明确覆盖「当前运行的 .app」(桌面 dev 包),避免误以为只更新了 Applications
- Windows 同样先启动目标再删 SOURCE,防止重启失败时只剩旧包
2026-07-09 13:45:17 +08:00
Syngnat
cdfbdd5dd5 feat(update): 下载进度到 100% 后由用户点击「重启应用更新」
- 取消 macOS 下载完成后自动安装/退出,Win/Mac 统一交互
- 进度弹窗在 100% 显示就绪提示,主按钮改为「重启应用更新」
- 关于页安装入口同步文案;点击后才执行安装脚本并重启
- 补充多语言与前端回归测试
2026-07-09 13:38:07 +08:00
Syngnat
d31bb978f2 🐛 fix(update/macos): 加固在线更新自动安装脚本与进程隔离
- 安装脚本 Setsid 脱离主进程,避免 Quit 后脚本被连带结束
- 等待宿主退出增加 120s 超时,防止无限挂起
- 支持 dmg/zip 解包定位 .app,稳健解析 Contents/MacOS 可执行文件
- 不再 rm -rf 脚本所在 STAGED 目录,降低安装中途自删风险
- 替换失败明确走管理员权限回退,增强 relaunch(open -n -a / nohup)
- 补充 macOS 更新脚本单测
2026-07-09 13:35:41 +08:00
Syngnat
d067e2c00a feat(update): dev 通道同步生成 latest-dev.json 静态清单
- dev-build 发版流程上传 latest-dev.json 到 dev-latest Release
- 客户端 dev 通道优先读取 releases/download/dev-latest/latest-dev.json
- build-release 本地打包按版本自动选择 latest / latest-dev
- SHA256SUMS 生成时跳过清单文件本身
2026-07-09 12:38:18 +08:00
Syngnat
3beab030f4 feat(update): 检查更新优先静态 latest.json,避免用户撞 GitHub API 限流
- 客户端优先下载 releases/latest/download/latest.json,再回退 REST API
- 成功结果写入磁盘缓存,限流/断网时可 stale 回退
- 静默检查增加节流;限流文案改为用户无需配置 Token
- CI 与 build-release 发版时生成 latest.json 清单
- 补充静态清单/缓存/节流单测与生成脚本测试
2026-07-09 12:35:12 +08:00
Syngnat
566339f09f 🐛 fix(window): 加固 Windows 启动最大化与窗口尺寸记忆落盘
- windowState/windowBounds 与启动偏好一致,立即写入本地持久化
- 启动最大化首次立即执行并加重试,成功后固化 maximized 状态
- 仅在 hydration 完成后恢复,避免默认 1024×768 半窗被误记
- 补充 store 与启动恢复相关测试断言
2026-07-09 12:35:12 +08:00
Syngnat
dec9b8317c ♻️ refactor(settings/ai): 聊天入口打开 AI 设置改走设置中心
- 从 AI 聊天面板打开设置时进入设置中心服务-AI 页
- 移除独立 AISettingsModal 挂载,复用 AISettingsContent
- 安全更新修复跳转同步走设置中心,关闭后可回到修复流程
- 更新设置中心与工具中心相关源码级测试断言
2026-07-09 12:19:22 +08:00
Syngnat
c77d338ab1 feat(ai-chat): 支持侧栏/独立窗默认打开、双向切换与尺寸记忆
- 新增 AI 独立浮动窗,标题栏可拖拽缩放并与侧栏双向切换
- 设置中可配置默认打开方式(侧栏/独立窗),偏好持久化
- 记忆独立窗上次位置与尺寸,再次打开自动恢复
- 优化输入区默认高度与底部模型/思考/用量工具条布局
- 去掉独立窗外层重复标题栏,由面板 header 统一承载操作
2026-07-09 12:18:51 +08:00
Syngnat
bc3f76dd73 feat(ai-chat): 思考强度移入聊天面板并按次请求下发
- 面板底部新增思考强度选择,按服务商 profile 动态选项
- ChatSendOptions 支持 thinkingIntensity,仅本次发送生效
- 从供应商表单移除思考强度配置,避免与面板控制重复
- 补齐多语言文案与相关前后端测试
2026-07-09 12:17:40 +08:00
Syngnat
4fcd7ff61f feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic
- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式
- 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数
- 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块
- 补充前后端回归测试与多语言文案
2026-07-09 11:34:38 +08:00
Syngnat
f930ffe153 🐛 fix(docker/web-server): 修复前端构建缺失 shared/i18n 导致 CI 失败
- frontend 阶段改为 monorepo 布局 WORKDIR /src/frontend
- COPY shared/ 到 /src/shared,恢复 catalog/messages/translate 相对路径解析
- dist 产物路径同步为 /src/frontend/dist
- 新增 Dockerfile 布局回归校验脚本,避免再次漏拷 shared
2026-07-09 11:16:30 +08:00
Syngnat
26c22379ae 🐛 fix(window/theme/updater/query-editor): 修复 Windows 冷启动半窗与主题/更新/补全体验
- 窗口:最大化恢复加重试与启动宽限,无记忆尺寸时 Windows 居中大窗,冷启动主动 WebView2 settle
- 主题:v2 设置中心主题外观改扁平自定义控件,legacy 保持侧栏卡片布局
- 更新:启动发现更新改开设置中心关于页,下载失败支持 assets API 回退并硬化检查链路
- 补全:suggest 列表字段名优先完整显示,元数据空间不足时再省略
- 测试:补充 window/theme/updater/补全相关回归断言
2026-07-09 11:13:42 +08:00
Syngnat
4c9bff5e8c feat(web-server): 支持 Docker 部署浏览器 Web UI
- 新增 Dockerfile.web-server:前端构建 + 纯 Go 主程序 web-server 模式
- 提供 Compose/env 示例与本地源码构建 override
- CI 增加 gonavi-web-server 镜像构建、冒烟与 GHCR 发布
- 文档补充 Web 容器部署、数据卷与安全暴露说明
2026-07-09 09:56:56 +08:00
Syngnat
8808a5aa77 🐛 fix(dameng): 修复设计表加载字段因 comment 保留字别名失败
- GetColumns/GetAllColumns 将 AS comment 改为 AS col_comment
- 达梦 COMMENT 为保留字,AS comment 会触发 Error -2007 语法分析出错
- 结果映射优先读取 COL_COMMENT,兼容 COMMENT/COMMENTS
- 补充单测锁定禁止使用 AS comment 别名
2026-07-09 09:48:52 +08:00
Syngnat
6fb6674803 feat(tab/window): 完善Tab拖出独立窗口跟手预览与入场动效
- 结果Tab支持拖出拆分,拖拽过程抑制文本选中
- 抽取 DetachDragPreview:进度条/跟手预览/阈值高亮
- 工作区主Tab拖出复用同一套预览动效,松手位置跟手
- 浮动窗增加 scale 入场动画,增强拆出反馈
2026-07-09 09:44:53 +08:00
Syngnat
c1cf19ef67 🐛 fix(sidebar/kingbase): 修复函数列表重复叠加导致越刷越多
- 元数据 fallback:完整目录查询首次成功后停止,避免多条 SQL 结果叠加
- 函数去重:按 schema+name+type 大小写不敏感去重,折叠同名 overload
- 树节点 key 区分 func/proc,避免同名函数/过程 key 冲突引发虚拟列表刷屏
- 补充 Kingbase/MySQL 函数元数据加载单测锁定回归
2026-07-09 09:20:33 +08:00
Syngnat
de868a2763 feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理
- 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭
- 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区
- 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失
- 补充六语言文案与 detach/store 相关单元测试
2026-07-09 09:08:50 +08:00
Syngnat
5fc33102a9 🐛 fix(updater): 修复关于页正式版信息兜底
- 为关于页补充固定项目元数据兜底
- 过滤 Unknown/未知 等无效版本与作者信息
- 增加正式版 AppInfo 缺失时的回归测试
2026-07-08 21:37:10 +08:00
Syngnat
f74ad02b9d 🐛 fix(query-editor): 限制 macOS 搜索仅响应 Cmd+F
- 拦截 Monaco 内置 Cmd+E find-with-selection 行为
- 保留默认 Cmd+E 当前语句选择逻辑,禁用或改键时不触发搜索
- 增加 SQL 编辑器快捷键回归测试并加固 Monaco 测试包装
2026-07-08 20:30:31 +08:00
Syngnat
dca1e8a251 feat(updater): 优化关于页更新信息展示
- 关于页直接进入 GoNavi 内容详情,减少二次点击
- 将更新通道改为分段选择器并优化描述布局
- 保留后端发布发布时间元数据并在版本信息中展示
2026-07-08 20:30:05 +08:00
Syngnat
22fab86010 🐛 fix(ui): 修复数据表按钮与侧栏滚动条显示
- 修复 v2 分页“统计总数”按钮被压缩为图标宽度的问题

- 首次打开时隐藏树主体横向溢出,保留独立横向滚动条占位

- 将自定义字段显示里的全局隐藏列文案改为多语言翻译

- 增加数据表按钮和侧栏横向滚动条布局回归断言
2026-07-08 17:37:21 +08:00
Syngnat
da895ea502 feat(settings): 统一设置中心内页与代理交互
- 将主题设置、AI 设置和关于页纳入设置中心详情页,补齐返回和取消交互

- 重构全局代理设置为草稿应用模式,新增常用预设、URL 测试入口和测试结果展示

- 按参考样式重做关于页版本与项目入口布局,并收紧顶部品牌卡尺寸

- 提取 AISettingsContent 供设置中心内嵌复用,控制详情页内部滚动边界

- 补齐多语言文案和设置中心静态回归断言
2026-07-08 17:37:05 +08:00
Syngnat
b94168957e 🐛 fix(update/proxy): 稳定更新检查与代理运行时
- 更新检查前恢复持久化全局代理,避免重启后代理状态未进入运行时

- 将 DNS、EOF 和临时网络异常转为本地化错误,并对可重试网络错误做短重试

- 优先读取 GitHub asset digest,缺失时再回退 SHA256SUMS 校验文件

- 新增全局代理连接测试后端接口,支持草稿代理、已保存密码复用和清除密码意图

- 更新 Wails 绑定、浏览器 mock 与代理草稿序列化测试
2026-07-08 17:36:42 +08:00
Syngnat
c792a30932 🐛 fix(query-result): 修复SQL执行后结果面板崩溃
- 提前解析当前激活结果页 key
- 避免构造结果 tab 时读取未初始化变量
- 修复 MySQL SELECT 成功返回后编辑器闪退问题
2026-07-08 14:16:36 +08:00
Syngnat
dd01d1c3f9 fix(update): auto-install macOS updates after download 2026-07-08 13:49:00 +08:00
Syngnat
23c895e839 test(update): auto install macOS package after download 2026-07-08 13:43:13 +08:00
Syngnat
7a5960e737 fix(update): auto install macOS package after download 2026-07-08 13:41:45 +08:00
Syngnat
5c6110a57e feat(query-result): support global hidden columns 2026-07-08 12:29:58 +08:00
Syngnat
17843ffd71 chore: remove temporary file 2026-07-08 12:26:21 +08:00
Syngnat
7296ab0425 tmp stop 2026-07-08 12:26:05 +08:00
Syngnat
47664023ee chore: remove temporary file 2026-07-08 12:25:57 +08:00
Syngnat
33918c6837 no 2026-07-08 12:25:42 +08:00
Syngnat
66a6052f88 chore: remove temporary file 2026-07-08 12:25:35 +08:00
Syngnat
31b5b838a2 chore: tmp create pr 2026-07-08 12:25:23 +08:00
Syngnat
575a3d88b9 chore: remove temporary PR trigger file 2026-07-08 12:25:07 +08:00
Syngnat
bef811ce13 tmp 2026-07-08 12:24:53 +08:00
Syngnat
dee5620fc9 test(data-grid): cover global hidden column helpers 2026-07-08 12:24:27 +08:00
Syngnat
b1174861d4 feat(query-result): apply global hidden columns 2026-07-08 12:24:12 +08:00
Syngnat
a1f296ee08 feat(data-grid): expose global hidden column configuration 2026-07-08 12:20:54 +08:00
Syngnat
3e34795ef5 feat(data-grid): add global hidden column storage helpers 2026-07-08 12:19:47 +08:00
Syngnat
f73d8dcb97 fix(oceanbase): tolerate Oracle sequence metadata differences 2026-07-08 12:18:44 +08:00
Syngnat
82ad393a05 perf(oceanbase): use FETCH FIRST for Oracle auto limit 2026-07-08 12:18:32 +08:00
Syngnat
257406120d fix(editor): recover dropped printable Monaco input 2026-07-08 12:18:20 +08:00
Syngnat
9ba3216fa8 test(oceanbase): cover Oracle sequence metadata fallbacks 2026-07-08 12:05:48 +08:00
Syngnat
fca3f0cffb fix(oceanbase): make Oracle sequence metadata queries tolerant 2026-07-08 12:05:14 +08:00
Syngnat
5712b39cf9 test(oceanbase): expect Oracle auto limit FETCH FIRST 2026-07-08 11:52:06 +08:00
Syngnat
07dc1436af perf(oceanbase): use FETCH FIRST for Oracle auto limit 2026-07-08 11:51:05 +08:00
Syngnat
9e52397732 fix(editor): recover dropped printable Monaco input 2026-07-08 11:35:00 +08:00
Syngnat
20ff4f3687 fix(query-result): show capped result counts as exact totals 2026-07-08 11:32:01 +08:00
Syngnat
73ce3039ce fix(oceanbase): improve query limit and Oracle object links 2026-07-08 11:31:29 +08:00
Syngnat
c27a038fc5 test(query-result): auto limit cap has exact page total 2026-07-08 11:08:03 +08:00
Syngnat
c3c4efc6ec fix(query-result): avoid not-counted summary for auto limit cap 2026-07-08 11:07:31 +08:00
Syngnat
61296bc855 chore: remove OceanBase follow-up note from focused fix 2026-07-08 10:44:43 +08:00
Syngnat
78da0076cf chore: remove temporary OceanBase follow-up note 2026-07-08 10:44:34 +08:00
Syngnat
279b38fec3 docs(oceanbase): note first implementation batch 2026-07-08 10:44:06 +08:00
Syngnat
0e348fcca4 test(query-editor): allow literal 100-row limit label 2026-07-08 10:43:46 +08:00
Syngnat
7d79fae84d fix(query-editor): use literal label for 100-row option 2026-07-08 10:43:02 +08:00
Syngnat
f642e0325d chore: remove temporary max rows i18n note 2026-07-08 10:40:20 +08:00
Syngnat
43a408f1c9 docs(query-editor): note 100-row option i18n key 2026-07-08 10:40:05 +08:00
Syngnat
b97b5cf6e2 docs(oceanbase): track remaining follow-up issues 2026-07-08 10:39:55 +08:00
Syngnat
6c269a99cf fix(oceanbase): support multi-part Oracle table links 2026-07-08 10:39:32 +08:00
Syngnat
24841f2dd9 test(query-editor): require 100-row limit label key 2026-07-08 10:37:10 +08:00
Syngnat
c2e60c1e52 feat(query-editor): add 100-row limit option 2026-07-08 10:36:38 +08:00
Syngnat
7a87d43bdc chore: remove temporary macOS CI annotation cleanup workflow 2026-07-08 09:43:21 +08:00
Syngnat
5fa1c90674 chore: trigger macOS CI annotation cleanup workflow 2026-07-08 09:42:18 +08:00
Syngnat
c79225ef27 chore: add temporary macOS CI annotation cleanup workflow 2026-07-08 09:41:44 +08:00
Syngnat
0bd8fdea1f chore: remove temporary macOS CI annotation cleanup workflow 2026-07-08 09:40:30 +08:00
Syngnat
18dbd043ae chore: temporarily apply macOS CI annotation cleanup 2026-07-08 09:39:28 +08:00
Syngnat
a7535dec2b fix(update): repair Go syntax in Windows update cleanup 2026-07-08 08:46:42 +08:00
Syngnat
dbd4b7f763 fix(update): repair Go multiline boolean expression 2026-07-08 08:44:15 +08:00
Syngnat
68eacbbcf7 fix(update): preserve latest Windows portable filename 2026-07-07 22:47:05 +08:00
Syngnat
cecefce84d fix(update): keep script builder test compatibility 2026-07-07 21:35:02 +08:00
Syngnat
2406c1ff71 fix(update): relaunch versioned portable exe with new filename 2026-07-07 21:28:21 +08:00
Syngnat
a4a0ac25cf fix(update): clean Windows update packages and logs 2026-07-07 20:54:13 +08:00
Syngnat
fbb23f0b9a fix(update): import logger for cleanup launcher 2026-07-07 20:50:57 +08:00
Syngnat
016e2bc70e chore(update): remove unused cleanup imports 2026-07-07 20:46:14 +08:00
Syngnat
070c2328e0 test(update): cover Windows cleanup helpers 2026-07-07 20:43:40 +08:00
Syngnat
c117da42e4 fix(update): clean staged Windows packages and logs 2026-07-07 20:42:15 +08:00
Syngnat
950856d86f 合并拉取请求 #627
fix: improve OceanBase Oracle performance, navigation, and count UX
2026-07-07 17:51:42 +08:00
Syngnat
93a3240862 fix(query-editor): fallback ctrl-click for OB Oracle schema tables 2026-07-07 17:47:45 +08:00
Syngnat
1116a3736b fix(data-grid): bridge pagination count action to toolbar 2026-07-07 17:45:37 +08:00
Syngnat
19af57e46b chore: remove temporary patch workflow 2026-07-07 17:36:48 +08:00
Syngnat
401c33c505 chore: apply OB Oracle performance navigation count patches 2026-07-07 17:33:59 +08:00
Syngnat
8c968c1a87 feat(data-grid): expose total count action in pagination 2026-07-07 17:31:53 +08:00
Syngnat
4fee58999d perf(db): keep one idle Oracle-style connection 2026-07-07 17:29:17 +08:00
Syngnat
bd0752df96 test(oceanbase): keep Oracle default timeout unset 2026-07-07 17:28:57 +08:00
Syngnat
47a15a6674 fix(oceanbase): stop inflating Oracle query timeout by default 2026-07-07 17:28:10 +08:00
Syngnat
2a64bad76e fix(oceanbase): reduce Oracle long-query bad connection failures 2026-07-07 16:56:05 +08:00
Syngnat
7ee8698165 test(query-editor): cover bad connection timeout semantics 2026-07-07 16:50:57 +08:00
Syngnat
9ae35e5fec fix(query-editor): classify driver bad connection as timeout 2026-07-07 16:49:39 +08:00
Syngnat
b618927454 test(oceanbase): cover Oracle query timeout default 2026-07-07 16:48:27 +08:00
Syngnat
e8ad34d2f0 fix(oceanbase): extend default Oracle query timeout 2026-07-07 16:47:50 +08:00
Syngnat
007fdc9a5d fix(oceanbase): set current schema for Oracle tenant queries 2026-07-07 15:44:14 +08:00
Syngnat
61b62fe5af test(oceanbase): cover Oracle current schema init 2026-07-07 15:41:22 +08:00
Syngnat
d574e6b396 fix(oceanbase): set Oracle current schema for selected tenant 2026-07-07 15:40:36 +08:00
Syngnat
f798b183e6 fix(query-editor): prevent AI inline completion from trapping cursor 2026-07-07 15:14:30 +08:00
Syngnat
e8ef4b824a fix(query-editor): prevent AI inline completion from trapping cursor 2026-07-07 15:10:45 +08:00
Syngnat
d87f8bf36a ♻️ perf(query-editor): 消除大库元数据下补全路径的全量扫描
延续上一轮 AI 补全卡顿修复:AI 元数据预热把整库列灌入共享缓存后,仍有三处热点会
阻塞主线程——buildQueryEditorAiContext 每次请求全量重建合并 Map、普通补全
findPreloadedColumns/relevantColumns 对全量列逐条正则扫描、ghost 可见期间
scroll/layout 每帧全量重渲染 overlay。改为:AI 上下文按依赖引用相等缓存(并保持
数组身份稳定使下游索引跨请求复用)、共享列按表名/标识符建 WeakMap 索引、
重定位用 rAF 合并。
2026-07-06 22:25:39 +08:00
Syngnat
50b4169148 🐛 fix(query-editor): 修复大库下SQL AI补全阻塞主线程导致全局卡顿
根因:全库列元数据加载后,每次内联补全在主线程对全部列做 O(列数×表数) 正则匹配,
且补全上下文被重复构建两次,8万列规模下单次请求耗时约900ms;同时 table_name 意图
每次补全都真实查库。改为按表名末段建索引(WeakMap按请求缓存)、复用已收敛上下文、
warmup 成功后会话内缓存,单次请求耗时降至约12ms。
2026-07-06 20:29:23 +08:00
Syngnat
4a63a175e4 🐛 fix(update): Windows在线更新改为覆盖当前exe并自动重启 2026-07-06 19:36:43 +08:00
Syngnat
2508b7935a 🐛 fix(update): 修复Windows在线更新复用旧安装包 2026-07-06 17:40:45 +08:00
Syngnat
22697baa37 🐛 fix(query-editor): 修复SQL AI补全误触普通补全 2026-07-06 17:26:41 +08:00
Syngnat
2fc587fd31 🐛 fix(frontend): 修复开发启动前端构建报错
- 修正 SQL 草稿快照定时器句柄类型兼容浏览器与 Node 环境
- 删除连接类型目录里重复的 trino 分支避免构建告警
- 配合可用 GOPROXY 验证 wails dev 已能完成前端编译与打包
2026-07-06 17:12:46 +08:00
Syngnat
2a3bd5bd97 🐛 fix(db): 修复Oracle同义词字段注释回填
- 元数据查不到列定义时补查 ALL_SYNONYMS 解析真实表
- 对解析出的 owner 和 table 继续回查列注释与主键信息
- 新增 SBDEV 指向 DEV.PERSON_INFO 的字段注释回归测试
2026-07-06 16:59:46 +08:00
Syngnat
ac68729734 🐛 fix(query-editor): 修复Oracle匿名块事务托管缺失
- 区分 BEGIN 事务控制语句与 Oracle 匿名块起始
- 让包含 DML 的 Oracle like 匿名块进入 SQL 编辑器托管事务
- 补充匿名块进入事务与回滚收口的后端回归测试
2026-07-06 16:59:17 +08:00
Syngnat
19c10f2369 🐛 fix(db): 修复 OceanBase Oracle 空结果并收敛提示
- OceanBase Oracle 只读查询优先走普通 Query 路径
- 补齐普通执行与托管事务两条回归测试
- 调整多语句回退提示避免误导为协议不支持
- 同步前端注释与多语言文案
2026-07-06 15:38:42 +08:00
Syngnat
8af8dd1ae0 🐛 fix(update): 修正 Windows 在线更新落盘与重启逻辑
- Windows 更新包直接下载到当前 EXE 目录
- staging 脚本目录移到临时目录避免暴露隐藏目录
- 安装时直接拉起新下载 EXE 并保留 zip 兼容分支
- 补充 Windows 更新路径回归测试
2026-07-06 15:37:56 +08:00
Syngnat
258fccd56f 🐛 fix(query-editor): 修复 OceanBase 执行误用登录Schema
- 保持 OceanBase Oracle 默认登录 schema 解析不变
- 未限定表名改写时增加登录 schema 到所选 schema 的回退候选
- 补充 SBDEVREAD/SBDEV 与 ORCLPDB1 场景回归测试

Fixes #615
2026-07-06 10:30:00 +08:00
Syngnat
93dc696e90 🐛 fix(db): 修复达梦DDL缺失字段注释 Fixes #615
- 补齐达梦列元数据查询中的字段注释读取
- 保留原始 CREATE TABLE 结果并追加 COMMENT ON COLUMN 语句
- 修正达梦列定义映射时 COMMENT 丢失的问题
- 新增达梦 DDL 注释补全回归测试
2026-07-06 08:56:41 +08:00
Syngnat
00d70d2934 🐛 fix(update): 修复 Windows 更新安装卡住并将安装包落到应用目录
- Windows 安装前预检当前安装目录写权限,避免脚本启动后主进程先退出
- 已下载更新补充打开安装目录入口,便于手动执行安装包
- 更新工作区优先改为当前应用运行目录,并补充回归测试与多语言文案
2026-07-05 20:06:51 +08:00
Syngnat
2f39767211 🐛 fix(update): 修复下载进度标题显示内部键并消除通道切换竞态
- 下载进度状态拆分 key 与 version 字段,弹窗标题恢复显示纯版本号
- SetUpdateChannel 将检查、持久化与缓存清理并入同一临界区,消除下载窗口期竞态
- 通道配置文件改为临时文件加重命名的原子写入
- 移除 useAppUpdateManager 未使用的 isMacRuntime 参数
2026-07-05 17:13:28 +08:00
Syngnat
5beb4c3ce4 feat(data-grid): 无主键表支持全列匹配编辑并移除只读限制
- 新增 all-columns 全列匹配行定位策略,无主键/唯一索引时不再强制只读
- 元数据加载失败、索引查询失败等误报场景全部降级为全列匹配编辑
- 修复 resolveQueryLocatorPlan 同步解析异常导致 SQL 结果页签不出现的问题
- 行安全由后端影响行数校验兜底,非单行命中即回滚整个事务
- 同步更新六语言文案与相关测试用例
2026-07-05 16:00:25 +08:00
Syngnat
8280f342a8 🐛 fix(mcp-server): 修复 arm64 镜像误装 amd64 二进制
- 移除 TARGETOS/TARGETARCH 的默认值,BuildKit 仅在无值声明时注入实际目标平台
- 原默认值 amd64 使 arm64 镜像内二进制架构错误,运行时报 exec format error
2026-07-05 12:13:56 +08:00
Syngnat
e5baf9ead6 feat(update): 支持 latest 与 dev 更新通道切换
- 新增更新通道持久化与按通道检查 GitHub Release
- 关于页支持切换通道并隔离本地更新缓存状态
- 补充多语言文案、Wails 绑定与前后端测试覆盖
2026-07-05 12:08:45 +08:00
Syngnat
06c4222aff ️ perf(ci): Docker 镜像改为原生 runner 分平台构建
- 构建矩阵拆分为 2 镜像 × 2 平台,arm64 使用 ubuntu-24.04-arm 原生 runner,移除 QEMU 模拟
- 各平台独立冒烟测试后以 digest 推送,merge job 合成多平台 manifest 并打标签
- 构建缓存 scope 按镜像-平台划分,避免跨平台缓存混用
2026-07-05 12:03:57 +08:00
Syngnat
f57f9913d5 🐛 fix(ci): 修复 build-env 镜像 npm 符号链接被 COPY 解引用
- 移除对 npm/npx/corepack 的直接 COPY,改为拷贝 node_modules 后重建符号链接
- COPY 会解引用符号链接,导致脚本内相对路径 require 解析失败
2026-07-05 11:40:14 +08:00
Syngnat
579891a53a 🐛 fix(ci): 修复 build-env 镜像 login shell 下 Go 工具链缺失
- 新增 /etc/profile.d 脚本,在 login shell 重置 PATH 后恢复 Go 工具链路径
- 修正 ENV PATH 中无效的 /root/go/bin 为 GOPATH 实际路径 /go/bin
2026-07-05 11:19:56 +08:00
Syngnat
061f78b897 🐛 fix(ci): 修复 mcp-server 本地 replace 模块分层构建
- 在 go mod download 前补拷贝 third_party/highgo-pq 与 third_party/go-irisnative 的 go.mod 依赖信息\n- 保留 Docker layer 缓存,同时兼容本地 replace 模块的依赖解析\n
2026-07-05 11:00:23 +08:00
Syngnat
6671915cf7 🐛 fix(ci): 修复 Docker 镜像构建基础环境
- 将 mcp-server 构建镜像升级到 Go 1.25,匹配当前 go.mod 版本要求\n- 调整 build-env 的 Node 资产复制方式,避免覆盖 Go 工具链\n- 显式补齐 /usr/local/go/bin 到 PATH,修复 smoke test 中 go 不可用\n
2026-07-05 10:57:07 +08:00
Syngnat
0cfcfa77d3 feat(mcp-server): 新增容器化部署支持
- 新增 MCP Server 的 Dockerfile、Compose 环境示例与 GHCR 镜像流水线\n- 补充 Podman Quadlet、Kubernetes Kustomize 与 Helm Chart 部署样例\n- 完善 MCP Server 独立 README,补充本地与远端 Agent 接入说明\n\nFixes #618
2026-07-04 21:57:21 +08:00
Syngnat
9e6a56a77b 📝 docs(readme): 补充 AI MCP 与数据源能力说明
- 补全中英文主 README 的 AI 与 MCP 工作流说明\n- 新增安全边界说明,覆盖 schema-only 与 allowMutating 约束\n- 补齐向量库、消息队列、国产数据库及时序库等数据源清单\n\nRefs #618
2026-07-04 21:56:10 +08:00
Syngnat
714c6fc553 feat(web-server): 新增浏览器访问认证与初始化流程
- 新增 web-server 运行模式与浏览器端运行时桥接
- 支持管理员密码、会话策略、Google Authenticator 与恢复码
- 设置中心补充浏览器访问认证状态与改密入口
- 优化初始化向导,未启用 2FA 时跳过验证器步骤

Refs #618
2026-07-04 21:50:52 +08:00
Syngnat
d7e15f5eb0 feat(release): 补齐 Linux arm64 构建与驱动发布链路
- 增加 dev/release 工作流 Linux arm64 构建矩阵与驱动分流
- 补齐在线更新资产识别及 driver release manifest/asset 校验
- 兼容 Windows 本地 bash/python 解析并补充对应测试
2026-07-04 10:15:13 +08:00
Syngnat
b388b08226 feat(sql-file-execution): 外部SQL执行切换工作台并优化大文件链路
- 前端将运行外部SQL改为工作台Tab并复用导出式进度视图

- 后端新增仅选择文件元数据接口并优化流式读取与批量拼接

- 补充执行Runner、国际化文案和定向测试
2026-07-03 22:11:49 +08:00
Syngnat
3bf42a169c 🐛 fix(sidebar): 修复侧边栏拖拽顺序重启后丢失
- 保留启动恢复阶段未回填连接时的 sidebarRootOrder
- 避免 hydration 过早裁剪连接根节点排序 token
- 补充重启后连接回填场景回归测试
2026-07-03 21:39:40 +08:00
Syngnat
29ee21e9d7 feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置
- 新增独立内联补全模型配置与服务端透传
- 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全
- 引入基于已存查询和执行日志的 SQL 记忆补全
- 补充前后端本地化、快捷键与补全回归测试
2026-07-03 20:44:33 +08:00
Syngnat
5eafd2aa3c feat(query-editor): 支持 AI 行内补全与文本生成 SQL
- 新增 SQL 编辑器 AI 辅助服务,接入已配置供应商并生成行内补全

- 增加 Text-to-SQL 弹窗与插入、替换选区、替换全文应用模式

- 补齐多语言文案和定向测试覆盖
2026-07-02 21:36:44 +08:00
Syngnat
b0f077ffbb 🐛 fix(table-designer): 优化结构设计器元数据加载性能
- 字段元数据优先加载并拆分各页签 loading 状态

- 收窄 Oracle 与达梦约束字典查询范围

- 补充结构设计器与字典查询回归测试
2026-07-02 21:15:40 +08:00
Syngnat
0fe3060cf1 🎨 style(query-editor): 优化补全候选元数据显示
- 候选行未选中时也显示元数据,避免只有当前选中项可见
- 将字段名与元数据改为紧凑排列,减少候选列表空间浪费
- 增加表备注、字段类型备注、当前库隔离和候选样式回归断言
2026-07-02 17:50:56 +08:00
Syngnat
c2eaf27c20 🐛 fix(query-editor): 修复补全元数据来源与库范围
- 限定普通表名补全只使用当前选中库,避免其他库表污染候选列表
- 表名补全补充表备注,并在懒加载时回填共享表元数据
- 字段补全详情统一展示表名、类型和备注,文档区补充库表上下文
2026-07-02 17:50:48 +08:00
Syngnat
275d490ca7 🐛 fix(sidebar): 修复 Oracle 表元数据显示缺失
- Oracle/Dameng 表元数据查询移除 ALL_SEGMENTS 依赖
- 使用 ALL_TABLES.BLOCKS 轻量估算表大小,避免权限或大库聚合失败
- 保留表注释、行数、创建时间和最后 DDL 时间展示
- 补充 Oracle-like 元数据 SQL 回归断言
2026-07-02 15:21:45 +08:00
Syngnat
0707d60f0b 🐛 fix(sidebar): 放宽左侧树拖拽宽度上限
- 统一侧栏宽度常量,最大宽度放宽到 960px
- 拖拽时按窗口宽度预留主工作区空间
- 同步 v2 与 legacy 侧栏 CSS 最大宽度
- 持久化侧栏宽度时允许保存更宽配置并补充测试
2026-07-02 15:20:59 +08:00
Syngnat
9b5e785d67 feat(sidebar): 支持表元数据拖拽排序
- 设置中心元数据项支持拖拽调整展示顺序
- 新增 sidebarTableMetadataFieldOrder 持久化并按顺序渲染
- 保持表注释旧配置迁移与元数据开关兼容
- 补充排序、持久化和侧栏渲染回归测试
2026-07-02 15:19:44 +08:00
Syngnat
69d9f41085 feat(connection): 支持生产保护默认折叠
- 为连接配置分区卡片增加可折叠渲染能力

- 生产连接保护默认收起,仅保留标题说明和当前策略标签

- 补充中英文折叠态与展开态回归测试
2026-07-02 14:12:47 +08:00
Syngnat
ffb4d16950 🐛 fix(definition-viewer): 修复对象修改保留完整定义
- 统一对象查看页定义展示,补齐可复制的 CREATE OR REPLACE DDL

- 打开对象修改前清理同标签旧草稿,避免旧草稿覆盖完整注释

- 补充 Oracle 函数、过程和包定义的回归测试
2026-07-02 14:12:22 +08:00
Syngnat
68d4081fcf feat(sidebar): 支持表元数据显示配置
- 新增侧栏表元数据字段配置并兼容旧版表备注开关
- 设置中心新增侧栏表元数据入口并移除树上重复显示开关
- 批量补齐表大小、创建时间、修改时间等元数据加载与渲染
- 统一表元数据格式化逻辑并修复 V2 左树横向滚动裁切
- 补充设置中心、store、侧栏元数据相关回归测试与多语言文案
2026-07-02 12:54:43 +08:00
Syngnat
92eb4266da 🐛 fix(query-editor): 统一对象查看与修改字号
- object-edit 查询态补齐与查看页一致的 Monaco 字号和行高配置
- 统一对象查看与修改的行号宽度,避免视觉尺寸不一致
- 补充 QueryEditor 与 DefinitionViewer Monaco 选项回归测试
2026-07-02 10:47:08 +08:00
Syngnat
b220dd051f 🐛 fix(definition-viewer): 复用对象修改原标签页
- 查看页对象修改直接复用当前标签切换到 object-edit 编辑态
- 侧边栏视图和函数编辑入口统一补齐 queryMode 标记
- 补充 DefinitionViewer 与 store 回归测试锁定标签复用行为
2026-07-02 10:45:59 +08:00
Syngnat
e466e87fcc 🐛 fix(sidebar): 修正树节点加载与展开时序 2026-07-02 09:40:09 +08:00
Syngnat
0428a30071 🐛 fix(data-viewer): 延迟对象设计页的数据预览加载 2026-07-02 09:39:52 +08:00
Syngnat
faaf7169da 🐛 fix(query-editor): 修复 SQL 片段补全与插入交互
- 插入 SQL 片段后主动关闭片段弹窗,保持右键插入行为一致
- 提升 Monaco 补全详情区最小高度,放大片段说明展示区域
- 列补全解析改为基于当前语句引用上下文,修复当前语句前半段补全丢列问题
- 为 SQL 草稿持久化补齐 window 定时器兜底,避免测试环境 clearTimeout 缺失
- 更新 SQL 片段弹窗关闭和补全说明高度相关回归测试
2026-07-01 18:11:41 +08:00
Syngnat
04a61a50dc 🐛 fix(workbench): 统一 v2 工具中心与工作区主题
- 显式传递 uiVersion 构建 overlay theme,避免设置中心和工具中心混用旧版黄蓝色
- 统一 v2 模式下 Ant Design 主色和选中态为绿色语义
- 移除工具中心详情头部重复的返回上一步按钮
- 修复侧边树对象筛选反复切换时的 rc-tree 复用漂移
- 调整 v2 左侧树横向滚动条与首页空态布局,释放工作区展示空间
- 补充工具中心、主题 token 和侧边树 remount 的回归测试
2026-07-01 18:11:08 +08:00
Syngnat
749e543e37 🐛 fix(query-editor): 修复 SQL 片段插入与弹窗交互
- 在 QueryEditor 右键菜单中稳定注册插入 SQL 片段动作,并随语言切换刷新文案,避免入口偶发消失
- SQL 片段选择器改为统一的可拖拽可缩放弹窗,优先直连 Monaco snippetController 插入并保留回退链路
- 选中片段后保持弹窗打开以便连续插入,同时补充入口注册、延迟 onMount 与多语言文案回归测试
2026-07-01 11:03:48 +08:00
Syngnat
d94335dc62 feat(query-editor): 支持自定义新建查询默认 SQL
- 在 appearance 持久化设置中新增 newQuerySqlTemplate,支持默认回退与空白模板
- 新建空白查询标签页按用户模板初始化,不影响外部 SQL、保存查询和只读标签
- 在工具中心补充模板编辑与恢复默认入口,并完善 store、编辑器和 i18n 回归测试
2026-07-01 09:44:22 +08:00
Syngnat
e135956eba 🐛 fix(definition-viewer): 修复对象定义页滚动行号重叠
- 在 DefinitionViewer 只读 Monaco 实例上显式关闭 stickyScroll,避免滚动后出现悬浮代码行与行号叠层
- 同步关闭 TriggerViewer 的 stickyScroll,统一对象定义类只读查看器滚动表现
- 新增对象定义查看器 Monaco 选项回归测试,锁定只读定义页必须禁用 stickyScroll
2026-07-01 09:17:54 +08:00
Syngnat
66b14766a3 🐛 fix(query-editor): 修复补全范围与光标执行
- 仅按当前语句与光标前范围构建别名和表引用,避免跨语句泄露其他库表字段补全
- 在新语句起始关键字场景优先提升 SQL 关键字候选,避免关键字被字段建议压到后面
- Ctrl/Cmd+Enter 无选中内容时回退执行光标所在语句,不再误报没有可选择的 SQL 语句
- 补充补全排序、列范围和光标末尾执行的回归测试
2026-07-01 09:17:33 +08:00
Syngnat
923e789211 🐛 fix(elasticsearch): 修复 ES SQL 末尾分号导致空结果 Fixes #605
- 清理 parseESSQL 解析出的 WHERE 和 ORDER BY 子句尾部分号
- 避免 range 条件把数值误当成字符串传给 Elasticsearch
- 补充带分号的 ES SQL 端到端与解析回归测试
2026-06-30 22:28:17 +08:00
Syngnat
52eacdd9df 🐛 fix(sidebar): 修复表分组折叠后二次展开消失 Fixes #606
- 限制侧边栏折叠时的子树释放范围,只清理可重新懒加载的 connection/database 节点

- 保留 tables object-group 的已加载子节点,避免二次点击展开后表列表被清空

- 补充针对大表分组折叠场景的回归测试
2026-06-30 21:46:13 +08:00
Syngnat
a9bae6bb3f 🐛 fix(explorer): 补齐 Redis 部分导入与库节点加载态
- 新增 Redis 导入预览与按 key 勾选导入能力

- 后端支持导入文件预读、选中 key 过滤与本地化错误提示

- 打开数据库节点时同步展示加载态并补齐前后端回归测试

Fixes #607

Fixes #609
2026-06-30 21:17:00 +08:00
Syngnat
b0d8ccbf23 🐛 fix(definition-viewer): 修复对象定义页行号重影 2026-06-30 20:31:44 +08:00
Syngnat
4b18075314 🐛 fix(db): 修复达梦读查询结果空页
- 统一按解析后的驱动类型判断只读查询结果策略

- 达梦及 dm/dm8 自定义驱动只读查询优先走普通 Query

- 补充事务执行路径与达梦结果集回归测试
2026-06-30 20:06:57 +08:00
Syngnat
784d7d60e2 🐛 fix(query-editor): 修复Monaco搜索框与悬浮提示遮挡
- 移除 QueryEditor 中临时的 find widget DOM 监听与 offset zone 逻辑
- 为 V2 查询编辑器的 Monaco stage 增加搜索框可见态顶部安全区并放开必要溢出
- 调整分栏高度计算与回归测试,确保搜索框间距只作用于 V2 query-editor
2026-06-30 17:25:48 +08:00
Syngnat
ece1119c31 feat(query-editor): 增强行级快捷键并优化搜索框
- 接管 Cmd/Ctrl+E,选择当前行并复制,避免落到宿主搜索
- 新增 Cmd/Ctrl+D 复制当前行到下一行,并同步快捷键冲突校验
- 增加 macOS SQL 菜单桥接,确保原生 Cmd+E 回落到编辑器
- 保持 Monaco 内置搜索并下移,避免遮挡 SQL 第一行
- 补齐多语言文案与 QueryEditor/shortcut/main 回归测试
2026-06-30 15:25:01 +08:00
Syngnat
b9ffcd2959 🐛 fix(sidebar): 支持调节V2左侧导航按钮大小 2026-06-29 21:25:13 +08:00
Syngnat
d900bda756 test(sqlserver): 补强索引预览与DDL回归覆盖
Fixes #379
2026-06-29 20:47:01 +08:00
Syngnat
8ac435dc6d 🐛 fix(sidebar): 修复达梦表分组计数丢失
Fixes #412
2026-06-29 20:43:15 +08:00
Syngnat
99942e37d6 feat(database-ui): 补充表概览元数据并调整数据库菜单
Fixes #395
2026-06-29 20:35:59 +08:00
Syngnat
d2a535e6ff 🐛 fix(mysql): 补充 PolarDB-X 数据库列表回退查询
Fixes #455
2026-06-29 20:25:30 +08:00
Syngnat
61c2e524b4 feat(redis-viewer): 新增加载全部 Key 入口
Fixes #603
2026-06-29 20:17:01 +08:00
Syngnat
b434247838 🐛 fix(ddl): 补齐 kingbase 表DDL索引输出
- 在 SHOW CREATE TABLE 不可用时,fallback 额外拉取索引元数据并生成 CREATE INDEX 语句\n- 跳过与主键重复的索引,避免在 fallback DDL 中重复输出主键索引\n- 补充 kingbase DDL fallback 的索引回归测试\n\nFixes #601
2026-06-29 20:09:01 +08:00
Syngnat
b3f69ac401 🐛 fix(sidebar): 调整暗黑模式横向滚动条样式
- 为 v2 侧栏树横向虚拟滚动条补充暗色轨道与边界样式\n- 保持滚动条尺寸与 thumb 交互不变,仅修正暗黑模式视觉协调性\n- 补充侧栏主题样式断言覆盖\n\nFixes #602
2026-06-29 20:04:17 +08:00
Syngnat
93006d8423 feat(query-editor): 新增编辑器内搜索入口
- 在查询编辑器工具栏新增搜索入口并直连 Monaco 查找\n- 接管 Ctrl/Cmd+F,优先打开编辑器内搜索而不是浏览器默认查找\n- 补充多语言文案与静态测试覆盖\n\nFixes #539
2026-06-29 20:00:27 +08:00
Syngnat
a7fdab5bb2 feat(theme): 新增跟随系统主题模式
- 增加主题偏好持久化并兼容旧版主题状态恢复
- 监听系统深浅色变化并同步窗口与应用主题
- 补齐主题设置文案与持久化相关测试

Fixes #407
2026-06-29 19:52:22 +08:00
Syngnat
bedd1eceb5 🐛 fix(query-editor): 修复异常退出后SQL草稿丢失
- 为查询页签增加 crash-recovery 草稿快照并按 160ms 落盘
- 启动恢复时补齐缺失的 query tabs 与连接上下文
- 保存或关闭查询后清理临时草稿并补充恢复回归测试
2026-06-29 19:21:37 +08:00
Syngnat
dac9e1f571 🐛 fix(sidebar): 恢复批量库表危险操作
- 恢复批量表和批量库按钮到原批量操作弹窗\n- 补回批量删除表和删除库的危险确认与执行逻辑\n- 删除表仅处理 table 对象,避免视图误走 DropTable\n- 补齐多语言文案与入口/删除回归测试
2026-06-29 17:09:01 +08:00
Syngnat
aed87665f4 🐛 fix(query-results): 区分执行消息内容区域
- 为执行消息正文滚动区增加独立边框和浅背景
- 保留标题与复制按钮在顶部区域,正文内容单独成框
- 补充消息块样式断言,覆盖边框、背景和内边距
2026-06-29 15:18:35 +08:00
Syngnat
04bbab3d7e 🐛 fix(query-results): 修复金仓带模式表主键识别
- PG 类数据库保留 schema.table 作为查询结果表名和元数据表名
- 元数据连接继续使用当前数据库,避免把 schema 误当 database
- 补充金仓列、索引和前端可编辑定位回归测试
2026-06-29 15:18:13 +08:00
Syngnat
4798d3e8ec 🐛 fix(db): 修复金仓读查询结果空页
- 调整 PG 类数据库只读查询路径,优先使用普通 Query 返回真实结果集
- 避免金仓原生多结果集返回空行列时吞掉可展示数据
- 补充金仓读查询与原生空结果回退测试覆盖
2026-06-29 15:17:47 +08:00
Syngnat
c940e930bc feat(connection): 支持导入Navicat NCX连接 2026-06-29 14:16:17 +08:00
Syngnat
8280306f82 🔧 chore(wails): 同步数据库对象前端绑定 2026-06-29 13:45:34 +08:00
Syngnat
59e12b0867 🐛 fix(ui): 修复危险主按钮文字对比度 2026-06-29 13:38:07 +08:00
Syngnat
ded531a3c2 🐛 fix(sql-editor): 修复新建查询关闭应用未提示保存 2026-06-29 13:02:23 +08:00
Syngnat
3a471bc1aa 🐛 fix(mcp): 修复执行SQL结果不可见
- execute_sql 文本 Content 输出可读的 SQL 执行摘要和 Markdown 结果表\n- 保留 structuredContent 结构化结果,兼容机器解析客户端\n- 新增 in-memory MCP 调用回归测试,覆盖 select 1 可见结果
2026-06-29 12:32:07 +08:00
Syngnat
ea4f88a20d feat(sql-editor): 关闭应用时提示保存未保存SQL
- 关闭应用前拦截 Wails 退出事件,前端弹出确认退出、保存退出和取消三按钮\n- 保存退出支持外部 SQL 文件和已保存查询,未命名临时查询保留草稿恢复语义\n- 补充退出保护后端测试、前端保存目标测试和多语言文案
2026-06-29 11:16:55 +08:00
Syngnat
8e857b9aee 🐛 fix(query-results): 修复多结果集回退空结果页 2026-06-29 11:09:40 +08:00
Syngnat
f55a332ead feat(mcp): 扩展数据库对象结构读取工具
- 新增 get_views/get_objects 工具并让 get_tables 附带视图列表
- 后端统一返回表、视图、触发器、函数、过程、序列、包、事件和队列类对象
- 补充 MCP 服务测试、对象解析测试、远程 schema-only 文档和多语言文案
2026-06-29 10:25:37 +08:00
Syngnat
893a0f70fb 🐛 fix(query-results): 修复原生空结果集导致结果页缺失
- 后端对只读查询的原生多结果集空列空行结果增加逐条执行回退
- 前端执行成功后显式合并结果集并激活最新结果 tab
- 新增空原生结果集对象的回归测试,避免 SELECT 成功但结果页缺失
2026-06-29 10:23:58 +08:00
Syngnat
fdb05fb8d3 🐛 fix(i18n): 修复系统语言跟随不实时更新
Refs #335
2026-06-28 20:15:56 +08:00
Syngnat
730d4bb1ab 🐛 fix(tabs): 修复对象编辑页关闭后未回到来源标签
- 为 SQL 超链接打开的对象标签记录来源标签

- 关闭当前对象标签时优先回到来源标签

- 补充表对象与对象编辑标签关闭回退测试
2026-06-28 20:03:15 +08:00
Syngnat
fee703e879 🐛 fix(sqlserver): 修复消息循环缺失时吞掉查询结果
- 在 SQL Server 消息循环未产出结果集时兜底扫描 rows

- 保留 SELECT 返回的行数据和空结果列元数据

- 补充 SQL Server fallback 扫描回归测试
2026-06-28 16:41:12 +08:00
Syngnat
4f2f7003c8 ️ perf(ai-chat): 降低流式思考输出渲染开销
- 合并 AI 流式 token 刷新,减少高频状态写入

- 避免纯流式更新重排会话列表,并收窄当前会话订阅

- 补充 thinking 合并刷新和会话列表稳定性回归测试
2026-06-28 16:03:22 +08:00
Syngnat
97540206b3 feat(query-editor): 新增 SQL 美化快捷键 2026-06-28 12:08:40 +08:00
Syngnat
793e078676 🐛 fix(ci): 修复 Doris driver-agent revision 校验失败
- 统一 release/dev 构建流程中的 Doris/diros 驱动归一
- 保持 Doris 资产名为 doris,构建 tag 使用 gonavi_diros_driver
- 优化 driver-agent revision 校验失败日志,避免输出 JSON 解析 traceback
2026-06-28 08:35:05 +08:00
Syngnat
7453600ee5 feat(data-grid): 支持数据视图列头筛选 #490
- 列头筛选复用工具栏筛选状态,应用后同步条件但不自动打开筛选面板

- 修复列头筛选应用、清除和操作符下拉交互

- 补充筛选状态同步、主键查询和列头交互回归测试
2026-06-27 22:58:10 +08:00
Syngnat
6c53ef4eff feat(query-editor): 记住查询编辑区结果区比例
- 持久化查询编辑器与结果面板的分割比例
- 新开查询 Tab 时按已保存比例恢复编辑区高度
- 补充分割比例计算与 QueryEditor 回归测试

Fixes #538
2026-06-27 18:21:24 +08:00
Syngnat
60eb696859 🐛 fix(sidebar): 修复事件定义编辑入口 #540
- 事件右键编辑定义改为打开 object-edit 查询

- 拉取 CREATE EVENT 作为可编辑 SQL,避免只显示 SHOW CREATE EVENT

- 补充事件定义编辑与菜单入口回归测试

Fixes #540
2026-06-27 17:58:54 +08:00
Syngnat
e456925c23 feat(sidebar): 优化表备注悬浮信息展示
- 读取不同数据源表备注并写入左侧表节点
- 支持表分组菜单切换备注显示
- 表节点悬浮复用 Tab 信息卡并移除原生双提示

Fixes #569
2026-06-27 17:43:11 +08:00
Syngnat
038ecc8b70 🐛 fix(export): 兼容换行 SELECT 查询导出
- SELECT/WITH 只读判断支持关键字后换行
- 覆盖多表 JOIN 查询结果导出场景

Fixes #583
2026-06-27 16:41:35 +08:00
Syngnat
3bfcd15dc0 🐛 fix(data-grid): 允许未知总数结果跳页
- 未知总数分页保留跳页控件
- 跳页输入不再被当前估算页数截断

Fixes #584
2026-06-27 16:41:12 +08:00
Syngnat
6abdb8684f 🐛 fix(sidebar): 折叠海量对象树时释放子节点
- 折叠海量数据库/对象分组时清理子树缓存和后代展开态
- 避免虚拟树在大批量函数、过程等对象折叠后残留渲染

Fixes #587
2026-06-27 11:37:55 +08:00
Syngnat
080ae0986a 🐛 fix(window): 适配分辨率变化后的窗口边界
- 运行期检测普通窗口是否超出当前可用屏幕并自动收回
- 启动恢复时同步裁剪超过当前屏幕的窗口尺寸

Fixes #594
2026-06-27 11:17:42 +08:00
Syngnat
bfb61c8449 feat(redis): 优化数据库别名与计数展示
- Redis DB 标题不再重复拼接 key 数量
- 将别名单独渲染为浅色文本并支持设置后即时刷新

Fixes #593
2026-06-27 10:58:24 +08:00
Syngnat
23d7511f55 🐛 fix(sidebar): 单击打开数据库对象节点
- 支持视图、函数、触发器等对象单击直接打开定义 tab
- 覆盖查询日志打开后 CK/SQLite 查询结果仍切回数据结果

Fixes #595
2026-06-27 10:51:50 +08:00
Syngnat
2d73bcc6de 🐛 fix(query-editor): 跳过TDengine托管事务
Fixes #592
2026-06-27 10:41:28 +08:00
Syngnat
a24f4a2bc1 🐛 fix(query-editor): 限制快捷键仅执行选中SQL
Fixes #596
2026-06-27 10:36:46 +08:00
Syngnat
bf51003c66 🐛 fix(query-results): 修复执行消息面板布局异常 2026-06-27 10:21:39 +08:00
Syngnat
2df9dce78b 🐛 fix(query-results): 优化SQL执行消息展示
- 执行消息拆分标题栏和滚动正文区域,避免 SQL 文本被遮挡

- 消息正文关闭自动换行并保留原始空白,支持横向滚动查看完整 SQL

- 补充 SQLServer 消息展示和结果面板结构回归断言
2026-06-26 17:29:03 +08:00
Syngnat
3ed45fab41 🐛 fix(sidebar): 修复表默认打开行为与左树滚动
- 工具设置新增双击表名行为开关,默认打开表数据,可切换到对象设计

- 左侧树、表概览卡片视图和列表视图统一按开关打开数据页内对象设计

- 调整 V2 左侧树横向滚动条布局,使滚动条保持在底部可见
2026-06-26 17:28:49 +08:00
Syngnat
7c9cf95698 🐛 fix(query-editor): 修复对象跳转与存储过程补全
- 对象超链接支持表跳转到数据页内对象设计,并兼容 Ctrl/Cmd 修饰键状态丢失场景

- MySQL 补全增加 CALL 关键字和存储过程/函数名称提示,支持 CALL routine 跳转编辑

- DataGrid 支持按 tab 请求初始切换到对象设计视图,补充相关回归测试
2026-06-26 17:28:09 +08:00
Syngnat
af2c358822 🐛 fix(sidebar): 修复左侧树长名称滚动仍截断
- 调整侧栏树节点内容宽度,避免标题层继续被 ellipsis 截断

- 同步 V2 树标题和标签样式,横向滚动后可查看完整名称

- 补充样式回归测试,覆盖节点内容、标题和标签完整宽度
2026-06-26 15:43:43 +08:00
Syngnat
1226e544ec 🐛 fix(data-grid): 修复文本模式字段注释显示
- 文本记录视图接入列元数据与字段显示开关
- 字段名下方同步显示类型和注释,保持与表格列头设置一致
- DataGridShell 将查询结果列元数据传入文本视图
- 补充文本模式显示和隐藏字段元数据回归测试
2026-06-26 10:08:04 +08:00
Syngnat
faa7ed1ae2 🐛 fix(query-editor): 修复选择器与事务提示浮层
- host 和数据库下拉选项关闭原生 title,避免和自定义 Tooltip 重复显示
- 事务模式说明默认向上弹出,并保留边界自动避让
- 移除下拉打开时强制隐藏事务说明的状态控制
- 补充工具栏和事务设置回归断言
2026-06-26 10:06:56 +08:00
Syngnat
22cdeb677b 🐛 fix(query-editor): 修复对象超链接误定位左树 2026-06-25 22:29:09 +08:00
Syngnat
b210d078d0 test(data-grid): 补充 DDL 视图回归覆盖
覆盖切表常驻、侧栏布局继承和加载态保持

覆盖活动 DDL 入口隐藏重开与侧栏拖拽预览

覆盖 DDL 文本选择时保持横向视角的交互行为
2026-06-25 21:36:27 +08:00
Syngnat
ce30de20b6 🐛 fix(data-grid): 修复 DDL 视图常驻与侧栏交互
记忆 DDL 底部/侧栏布局并支持切表常驻刷新

修复侧栏关闭、拖拽预览和首次加载布局抖动

优化 DDL 行号 gutter,并保留文本选择时的横向视角
2026-06-25 21:36:14 +08:00
Syngnat
8dde4c3c6d 🐛 fix(tool-center): 移除内嵌工具重复标题头
- 工具中心详情页统一承载入口标题和说明
- 数据同步、表结构比对和数据比对嵌入时隐藏内部 hero
- 数据目录、快捷键、连接包和安全更新嵌入时隐藏内部 Modal 标题
- 补充工具中心全 pane 标题归属回归断言
2026-06-25 18:42:53 +08:00
Syngnat
bdb60a656a 🐛 fix(sidebar): 修复 V2 当前连接新建查询入口
- 在当前连接头部增加更明显的新建查询按钮
- 使用文档图标区分连接创建入口,避免与加号按钮混淆
- 通过连接树节点上下文触发新建查询,修复点击无响应
- 补充 V2 侧边栏入口位置与图标回归断言
2026-06-25 18:42:31 +08:00
Syngnat
8a56367d02 🐛 fix(query-editor): 优化工具栏长名称提示与片段弹窗布局
- SQL 编辑器连接和数据库选择器支持悬浮延迟显示完整名称
- SQL 片段管理嵌入工具中心时隐藏重复标题
- 缩小片段操作按钮并拆分滚动区域,避免底部内容被遮挡
- 补充工具栏和片段弹窗源码级回归断言
2026-06-25 18:42:11 +08:00
Syngnat
aebe9bab54 🐛 fix(sql-log): 统一 V2 SQL 日志入口
- V2 左侧入口改为打开当前工作区内嵌 SQL 日志,legacy 继续使用底部全局面板
- 表数据页新增 sqlLog 视图并复用嵌入式 LogPanel,避免无 SQL 编辑器时无法查看日志
- 移除 V2 侧栏底部重复 SQL 日志按钮,保留慢查询入口并补充回归测试
2026-06-25 17:39:57 +08:00
Syngnat
9ab31a7614 🐛 fix(oracle): 修复存储过程斜杠分隔执行截断
- 支持 SQL*Plus 斜杠分隔符后的可选分号,避免 Oracle 过程执行出空语句
- 光标落在过程异常尾部或斜杠分隔行时,仍选择完整 PL/SQL 定义执行
- 补充前端语句选择、QueryEditor 执行和后端 split/DBQueryMulti 回归测试
2026-06-25 17:37:32 +08:00
Syngnat
f6556f25d5 🐛 fix(oracle): 修复过程CASE分割导致执行截断 2026-06-25 13:52:20 +08:00
Syngnat
16a8a763f4 🐛 fix(oracle): 接入对象跳转并修复过程修改执行
- SQL 编辑器补齐 Oracle 序列和存储包元数据、hover 提示与 Ctrl/Cmd 点击跳转
- 对象编辑 SQL 保留 SQLPlus 斜杠分隔符,避免生成 /; 导致 ORA-00900
- 补充导航、对象编辑执行和多语言目录回归测试
2026-06-25 11:36:24 +08:00
Syngnat
4b1cd1b727 🐛 fix(oracle): 修复触发器脚本显示为空
- 触发器列表查询补齐 OWNER、TABLE_NAME、TRIGGER_BODY 等 Oracle 元数据字段
- 优先使用 DBMS_METADATA.GET_DDL 返回完整 CREATE TRIGGER 脚本
- 在 DDL 不可用时基于 USER_TRIGGERS/ALL_TRIGGERS 重建可编辑触发器语句
- 补充 Oracle 触发器 DDL 获取与回退重建回归测试
2026-06-25 10:50:07 +08:00
Syngnat
37ccaf7743 🐛 fix(oracle): 修复存储包定义截断与斜杠误执行
- 对象树支持序列和存储包双击及右键查看定义
- Oracle package 同时读取 PACKAGE 与 PACKAGE BODY 并生成可编辑 SQL
- SQL 拆分器跳过 SQL*Plus 独立斜杠和注释分隔符
- 补充前后端 PL/SQL 拆分与定义查看回归测试
2026-06-25 10:12:28 +08:00
Syngnat
06a984f39d 🐛 fix(elasticsearch): 修复 ES SQL 分号结尾查询无结果
- 兼容 SELECT FROM 索引名后直接接分号的查询语句
- 避免解析失败后退回全索引 query_string 导致空结果
- 补充 ES SQL 转 _search 的回归测试

Fixes #590
2026-06-24 23:36:37 +08:00
Syngnat
6d1c034052 🐛 fix(sidebar): 修复长表名横向滚动截断
- 取消 V2 对象树等宽标签自截断,支持横向滚动展示完整名称

- 提高对象树横向滚动宽度上限并补充回归测试
2026-06-24 22:42:04 +08:00
Syngnat
672d05d124 🐛 fix(sidebar): 恢复连接加载中的转圈状态
- 扩展连接状态为 loading/success/error

- 在连接根节点加载开始时显示 pending,成功后才置绿

- 同步 V2 active host 与 legacy Badge 状态显示并补测试
2026-06-24 22:31:13 +08:00
Syngnat
322d0a7cb8 feat(sidebar): 支持 Oracle 序列和存储包对象树
- 新增 Oracle/Dameng 序列与存储包元数据加载

- 同步对象树节点、V2 筛选、搜索、复制和拖拽支持

- 补充多语言文案和侧边栏回归测试
2026-06-24 22:26:39 +08:00
Syngnat
5725e78931 🐛 fix(mcp): 修复外部 MCP 读取连接不完整
- 启动时自动同步旧本地连接到后端安全仓库

- 启动 HTTP MCP 或安装外部 MCP 前执行连接同步

- 增加安全配置同步回归测试

Fixes #591
2026-06-24 21:37:52 +08:00
Syngnat
69b6072e37 🐛 fix(query-editor): 修复 OceanBase Oracle 查询默认 schema 误判
- 优先使用当前选中的 Oracle-like schema 解析未限定表名

- 为 OceanBase Oracle 只读账号查询补齐业务 schema 限定

- 增加回归用例覆盖登录用户与选中 schema 不一致场景
2026-06-24 21:25:22 +08:00
Syngnat
1a9d417c0a 🐛 fix(query-editor): 修复过程脚本斜杠分隔符误执行
- 前端语句选择跳过独立 SQL*Plus 斜杠分隔符

- 后端 SQL 拆分和流式文件执行保持过程体完整

- 增加 Oracle 过程脚本执行回归测试
2026-06-24 21:10:59 +08:00
Syngnat
8a552c4cb3 🐛 fix(query-editor): 优化事务下拉显示与浮层交互
- 自动提交延迟选项改为多语言完整文案,避免短标签语义不清
- 收窄并校准 V2 工具栏事务下拉宽度,兼顾不截断与紧凑布局
- 事务模式 Select 展开时自动隐藏 DBeaver 参考 Tooltip,避免浮层互相遮挡
- 补充事务设置行为测试和布局守护测试
2026-06-24 17:50:57 +08:00
Syngnat
9da9a36cf3 🐛 fix(snippet): 修复SQL片段入口与弹窗布局
- 将 SQL 编辑器代码片段管理入口改为打开工具中心 SQL 片段面板
- 关闭旧独立片段弹窗入口,避免同一功能出现两个入口形态
- 限制片段管理弹窗内容区高度并固定底部操作行,避免按钮被语法参考内容挤出
2026-06-24 17:49:48 +08:00
Syngnat
d08ab62f92 🐛 fix(data-grid): 修复刷新后未提交修改被清空 2026-06-24 15:56:07 +08:00
辣条
1cb112bcdf 1. 收口多语言能力 2. 修复查询编辑器、侧边栏、DDL 展示等业务问题 (#589)
## 主要改动

### 1. 多语言能力收口
- 收口 6 种语言下的业务文案与交互文案
- 覆盖连接驱动、查询编辑器、导出流程、部分 AI 工具面板等场景
- 补齐多语言回归测试,减少语言切换后文案残留中文或不同步的问题

### 2. 查询编辑器相关修复
- 修复新建查询标签页在语言切换后标题不同步的问题
- 修复查询结果集不展示、总数长期停留在“正在统计中”的问题
- 修复有限分页场景下仍可继续翻到不存在页码并展示异常数据的问题
- 修复 SQL 编辑器挂起事务内执行后续查询时,无法读到未提交修改的问题
- 修复 SQL Server 单条查询尾随 `affectedRows` 结果被误渲染为额外结果页签的问题
- 补齐 SQL Server 多结果集归属与过滤逻辑,避免冗余结果干扰展示

### 3. 侧边栏与视图相关修复
- 修复数据库连接下视图节点展开/滚动后渲染错乱、内容铺满侧栏的问题
- 修复收起视图栏后页面仍残留视图内容的问题
- 修复视图元数据重复导致的侧边栏树结构异常问题
- 补齐相关测试桩与回归测试

### 4. Oracle / DDL 相关修复
- 修复 Oracle 建表 DDL 追加注释时缺少语句结束分号与空行分隔的问题
- 避免 `CREATE TABLE` 与 `COMMENT ON TABLE/COLUMN` 粘连,提升复制后可直接执行的稳定性

### 5. 其他界面修复
- 修复语言设置面板选项显示截断问题
- 收口部分 dev 合并后的多语言遗漏与提示文案不一致问题

## 验证情况

已完成的本地验证包括:

- 前端定向测试
- `npm test -- QueryEditor.external-sql-save.test.tsx
sqlEditorTransaction.test.ts`
- `npm test -- QueryEditor.results-and-drop.test.tsx
QueryEditor.external-sql-save.test.tsx sqlEditorTransaction.test.ts
useSqlEditorTransactionController.test.tsx`
- `npm test -- QueryEditor.results-and-drop.test.tsx ddlFormat.test.ts`

- 后端定向测试
- `go test ./internal/app -run
'TestDBQueryMultiTransactionalKeepsDMLTransactionOpenUntilCommit|TestDBQueryMultiInTransactionReusesPendingManagedSessionForReadQueries'`
- `go test ./internal/db -run 'Test.*GetCreateStatement|Test.*Oracle.*'`

## 已知说明

- `internal/app` 下部分 SQL Server 相关定向测试在当前机器上受“SQL Server 纯 Go
驱动未启用”环境限制,失败原因为本地驱动依赖,不是本次改动引入的问题
- 本 PR 改动面较大,建议合并前重点关注:
  - 多语言切换后的查询编辑器与侧边栏行为
  - SQL Server 结果集展示
  - Oracle DDL 预览与复制执行
  - 挂起事务中的查询结果一致性

## 风险点

- 查询编辑器结果集合并、分页、事务复用逻辑改动较集中
- 多语言文案收口涉及范围较广,建议合并后做一次核心路径冒烟验证
2026-06-24 14:15:31 +08:00
Syngnat
2fb4851d1f 🐛 fix(query-editor): 补齐SQL Server冗余结果集过滤
- 为 SQL Server 原生多结果集缺失 statementIndex 的场景补齐结果归属
- 修复单条 SELECT 查询尾随 affectedRows 结果被误渲染为额外结果页签的问题
- 补充前后端回归测试并保持存储过程多结果集展示不变
2026-06-24 13:58:12 +08:00
Syngnat
32c3ba017e 🐛 fix(oracle): 修复DDL注释拼接缺少分号与换行
- 为 Oracle 建表 DDL 追加注释前补齐语句结束符并保留空行分隔
- 修复 COMMENT ON TABLE/COLUMN 与 CREATE TABLE 粘连导致复制后无法直接执行的问题
- 补充后端 Oracle 建表语句与前端 DDL 格式化回归测试
2026-06-24 13:57:49 +08:00
Syngnat
d09b968cc0 🐛 fix(query-editor): 补齐SQL Server冗余结果集过滤
- 为 SQL Server 原生多结果集缺失 statementIndex 的场景补齐结果归属
- 修复单条 SELECT 查询尾随 affectedRows 结果被误渲染为额外结果页签的问题
- 补充前后端回归测试并保持存储过程多结果集展示不变
2026-06-24 11:57:19 +08:00
Syngnat
90d84da849 🐛 fix(oracle): 修复DDL注释拼接缺少分号与换行
- 为 Oracle 建表 DDL 追加注释前补齐语句结束符并保留空行分隔
- 修复 COMMENT ON TABLE/COLUMN 与 CREATE TABLE 粘连导致复制后无法直接执行的问题
- 补充后端 Oracle 建表语句与前端 DDL 格式化回归测试
2026-06-24 11:40:18 +08:00
tianqijiuyun-latiao
d2c4160c6d 🐛 fix(query-editor): 修复挂起事务内查询结果不一致 2026-06-24 11:37:34 +08:00
tianqijiuyun-latiao
2b4beae9df test(i18n): 校准多语言守卫测试源码引用位置 2026-06-24 11:17:43 +08:00
tianqijiuyun-latiao
7dab6f2e33 🐛 fix(query-editor): 修复结果集阻塞与未知总数分页误导
- 为查询结果定位元数据探测增加软超时降级,避免租户元数据卡死阻塞主查询结果渲染
- 将未知总数分页切换为顺序翻页模式,并修正文案仅在真实统计时显示正在统计中
- 补充查询结果与分页回归测试,覆盖元数据超时和 legacy 未知总数分页场景
2026-06-24 10:47:37 +08:00
tianqijiuyun-latiao
1aab48783d 🐛 fix(query-tab): 修复新建查询标签页语言切换不同步
- 提取共享的新建查询默认标题识别逻辑
- 按当前语言重算默认查询和数据库作用域查询标题
- 补充跨语言标题回归测试
2026-06-24 10:26:05 +08:00
tianqijiuyun-latiao
5089e6b294 test(sidebar): 补齐视图元数据加载测试桩返回字段 2026-06-24 10:17:05 +08:00
tianqijiuyun-latiao
3be08cb6cd 🐛 fix(sidebar): 修复视图元数据重复导致侧边栏树渲染错乱
- MySQL 视图元数据缺失 schema 时回填当前数据库
- 避免多条回退查询生成重复视图节点
- 补充侧边栏视图去重回归测试
2026-06-24 10:14:22 +08:00
tianqijiuyun-latiao
6c4833dcd1 🐛 fix(settings): 修复语言设置面板选项显示截断 2026-06-24 10:07:52 +08:00
tianqijiuyun-latiao
ce17629a8d Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n 2026-06-24 08:08:53 +08:00
Syngnat
5e15c4dd2d 🐛 fix(query-editor): 隐藏 SQL Server 冗余影响行数结果集 2026-06-24 00:22:31 +08:00
tianqijiuyun-latiao
c04d82ee30 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/components/QueryEditor.results-and-drop.test.tsx
2026-06-23 23:58:25 +08:00
tianqijiuyun-latiao
7962aea008 🐛 test(query-editor): 修复 SQL Server 消息结果断言 2026-06-23 23:44:06 +08:00
Syngnat
5493b62bb9 🐛 fix(query-editor): 修复 SQL Server 结果消息缩进并校正回归测试 2026-06-23 23:42:30 +08:00
tianqijiuyun-latiao
86c39ac36f Merge branch 'dev' into feature/20260602_connection_driver_i18n 2026-06-23 23:34:16 +08:00
Syngnat
6bf05c9ed7 🐛 fix(update): 修复桌面端更新安装与重启流程
- macOS 下载完成后改为直接走安装更新链路,不再只打开安装目录
- Windows 更新脚本在重启时显式带上目标工作目录,提升安装后拉起稳定性
- 删除失效的 winget 发布工作流,并补充前端与脚本回归测试

Fixes #585
2026-06-23 22:18:07 +08:00
tianqijiuyun-latiao
71989af586 🐛 fix(i18n): 收口 dev 合并后的业务提示多语言遗漏 2026-06-23 18:26:20 +08:00
tianqijiuyun-latiao
09ae3d74c4 🐛 fix(i18n): 合并 dev 后补齐只读保护多语言 2026-06-23 17:05:35 +08:00
tianqijiuyun-latiao
839fdd7d66 Merge branch 'dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 16:23:43 +08:00
tianqijiuyun-latiao
52d193b2e8 fix(i18n): 收口 dev 合并后的驱动与 QueryEditor 本地化 2026-06-23 14:04:25 +08:00
tianqijiuyun-latiao
d9e52c734a Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n 2026-06-23 13:14:43 +08:00
tianqijiuyun-latiao
3ce85617da feat(i18n): 收口导出前端多语言 2026-06-23 13:14:22 +08:00
tianqijiuyun-latiao
0ba984b277 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.edit-password.test.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataSyncModal.i18n.test.ts
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/SnippetSettingsModal.tsx
#	frontend/src/components/TableOverview.tsx
#	frontend/src/components/ai/AIChatHeader.test.tsx
#	frontend/src/components/ai/AISettingsProvidersSection.tsx
#	frontend/src/components/ai/aiChatPayloadDispatch.ts
#	frontend/src/components/ai/aiChatReadiness.ts
#	frontend/src/components/ai/aiSettingsModalConfig.tsx
#	frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
#	frontend/src/components/sidebarV2Utils.ts
#	frontend/src/i18n/catalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.ts
#	frontend/src/utils/tabDisplay.ts
#	internal/ai/provider/custom.go
#	internal/ai/service/service.go
#	internal/app/methods_driver.go
#	internal/app/methods_file.go
#	internal/db/custom_impl.go
#	internal/db/iris_impl.go
#	internal/db/mariadb_impl.go
#	internal/db/sqlserver_impl.go
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 12:41:27 +08:00
tianqijiuyun-latiao
f282da3bcb feat(i18n): 收口多语言功能业务代码 2026-06-22 15:12:42 +08:00
tianqijiuyun-latiao
eba689754c test(ai): 对齐多语言 fallback 测试基线 2026-06-22 13:46:42 +08:00
tianqijiuyun-latiao
d13c153f5e feat(i18n): 收口数据库驱动多语言代码
- 提交 internal/db 多驱动用户可见错误与状态文案多语言化

- 补齐数据库驱动多语言测试与六语言 catalog

- 修复 frontend i18n catalog 的 4 个失效 guard
2026-06-22 10:09:45 +08:00
1297 changed files with 270850 additions and 46520 deletions

18
.containerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.github
.idea
.ace-tool
.playwright-mcp
.superpowers
.worktrees
bin
build
dist
frontend/dist
node_modules
frontend/node_modules
tools/__pycache__
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent
optional-driver-agent.exe

View File

@@ -25,6 +25,18 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Test release asset contracts
run: |
bash tools/detect-changed-driver-agents.test.sh
bash tools/generate-driver-agent-revisions.test.sh
python3 tools/generate-driver-release-manifest.test.py
python3 tools/generate-update-latest-manifest.test.py
python3 tools/r2-release-retention.test.py
python3 tools/package-driver-release-assets.test.py
python3 tools/legal-release-assets.test.py
python3 tools/windows-release-artifacts.test.py
python3 tools/generate-release-notes.test.py
- name: Setup Node
uses: actions/setup-node@v5
with:
@@ -36,7 +48,7 @@ jobs:
uses: actions/cache@v5
with:
path: frontend/node_modules
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
@@ -51,6 +63,9 @@ jobs:
node frontend/scripts/wails-frontend-install.mjs
npm --prefix frontend run build
- name: Test frontend virtual scrolling
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
- name: Pack frontend dist
shell: bash
run: tar -cf frontend-dist.tar -C frontend/dist .
@@ -73,6 +88,7 @@ jobs:
drivers_windows_amd64: ${{ steps.detect.outputs.drivers_windows_amd64 }}
drivers_windows_arm64: ${{ steps.detect.outputs.drivers_windows_arm64 }}
drivers_linux_amd64: ${{ steps.detect.outputs.drivers_linux_amd64 }}
drivers_linux_arm64: ${{ steps.detect.outputs.drivers_linux_arm64 }}
has_changes: ${{ steps.detect.outputs.has_changes }}
release_source: ${{ steps.detect.outputs.release_source }}
compare_base: ${{ steps.detect.outputs.compare_base }}
@@ -167,6 +183,7 @@ jobs:
windows/amd64) echo "windows_amd64" ;;
windows/arm64) echo "windows_arm64" ;;
linux/amd64) echo "linux_amd64" ;;
linux/arm64) echo "linux_arm64" ;;
*)
echo "unknown platform: $1" >&2
return 1
@@ -204,7 +221,7 @@ jobs:
PLATFORM_DIFF_FAILED=false
if [[ "$BASE_REF" != "all" && "$FORCE_GLOBAL_DRIVER_BUILDS" != "true" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
DIFF_ARGS=(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")
if [[ -n "$DRIVERS" ]]; then
@@ -228,12 +245,12 @@ jobs:
fi
if [[ "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" || "$PLATFORM_DIFF_FAILED" == "true" ]]; then
PLATFORM_OUTPUTS=()
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${DRIVERS}")
done
elif [[ ${#PLATFORM_OUTPUTS[@]} -eq 0 ]]; then
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=")
done
@@ -312,6 +329,15 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
@@ -380,6 +406,24 @@ jobs:
}
& $upxCmd --version
- name: Install WiX Toolset (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$wixVersion = "6.0.2"
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
dotnet tool install wix --tool-path $wixTools --version $wixVersion
if ($LASTEXITCODE -ne 0) {
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
}
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$env:PATH = "$wixTools;$env:PATH"
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
if ($LASTEXITCODE -ne 0) {
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
}
wix --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
@@ -397,6 +441,12 @@ jobs:
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -499,6 +549,11 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version
@@ -554,6 +609,9 @@ jobs:
linux/amd64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_amd64 }}'
;;
linux/arm64)
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_arm64 }}'
;;
*)
echo "unsupported platform: ${{ matrix.platform }}" >&2
exit 1
@@ -588,7 +646,45 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -644,10 +740,7 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -742,7 +835,12 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$CHANGED_DRIVER_AGENTS"
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
mkdir -p driver-provenance
python3 ./tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
# macOS Packaging
- name: Package macOS DMG
@@ -758,6 +856,9 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
mkdir -p "$APP_NAME/Contents/Resources"
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
@@ -801,14 +902,16 @@ jobs:
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows EXE
- name: Package Windows Portable EXE and MSI
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ steps.version.outputs.version }}"
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
$archName = "${{ matrix.arch_name }}"
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -851,8 +954,65 @@ jobs:
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
$runNumber = [int64]$env:GITHUB_RUN_NUMBER
if ($runNumber -le 0 -or $runNumber -gt 65535) {
throw "GitHub run number 无法映射为 MSI 版本号: $runNumber"
}
$productVersion = "255.0.$runNumber"
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
$componentGuid = if ($archName -eq "Arm64") {
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
} else {
"0BCEE70B-9CF2-449C-9ADE-190188493234"
}
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
} else {
"114030DD-DB5F-460A-9CAC-997680E9F938"
}
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
wix build $wxsPath `
-arch $wixArch `
-ext "WixToolset.UI.wixext/6.0.2" `
-d "ProductName=GoNavi" `
-d "ProductVersion=$productVersion" `
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
-d "ComponentGuid=$componentGuid" `
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
-d "InstallFolderName=GoNavi" `
-d "RegistryKeyName=GoNavi" `
-d "SourceExe=$sourceExe" `
-d "InstallMarker=$installMarkerPath" `
-d "IconPath=$iconPath" `
-d "LicenseRtf=$licenseRtf" `
-d "LicenseFile=$licenseFile" `
-d "NoticeFile=$noticeFile" `
-pdbtype none `
-intermediatefolder $wixIntermediateDir `
-out "..\\..\\$msiName"
if ($LASTEXITCODE -ne 0) {
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
}
# Linux Packaging
- name: Package Linux
@@ -883,7 +1043,7 @@ jobs:
fi
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
mv "$TAR_NAME" ../../
if [ -f /tmp/skip-appimage ]; then
@@ -894,9 +1054,12 @@ jobs:
echo "📦 正在生成 AppImage..."
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/doc/gonavi
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
printf '%s\n' \
'[Desktop Entry]' \
@@ -943,9 +1106,11 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.msi
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
driver-provenance/**
retention-days: 7
# 汇总所有产物并发布为 Pre-release
@@ -954,11 +1119,21 @@ jobs:
needs:
- build
- driver_agents
# Serialize only the publication stage with stable releases so both
# channels cannot pass the retained-byte preflight concurrently.
concurrency:
group: gonavi-r2-publication
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Download All Artifacts
uses: actions/download-artifact@v7
with:
@@ -966,9 +1141,29 @@ jobs:
pattern: dev-build-artifacts-*
merge-multiple: true
- name: Add legal documents
shell: bash
run: install -m 0644 LICENSE NOTICE release-assets/
- name: List Assets
run: ls -R release-assets
- name: Download Previous Driver Manifest
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
shell: bash
run: |
set -euo pipefail
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
else
rm -f "$manifest_path"
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
fi
- name: Complete Driver Agent Assets
if: needs.driver_agents.outputs.has_changes == 'true'
env:
@@ -1002,11 +1197,19 @@ jobs:
drivers \
../driver-release-assets
python3 ../tools/generate-driver-release-manifest.py \
--assets-dir drivers \
MANIFEST_ARGS=(
--assets-dir drivers
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
)
if [ -d driver-provenance ]; then
MANIFEST_ARGS+=(--provenance driver-provenance)
fi
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
fi
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
rm -rf drivers
rm -rf drivers driver-provenance
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
- name: Generate SHA256SUMS
@@ -1016,6 +1219,9 @@ jobs:
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
case "$file" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
@@ -1050,6 +1256,26 @@ jobs:
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
# 静态更新清单dev 通道读
# https://github.com/Syngnat/GoNavi/releases/download/dev-latest/latest-dev.json
- name: Generate static update manifest (latest-dev.json)
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$DEV_VERSION" \
--tag dev-latest \
--channel dev \
--name "Dev Build (${DEV_VERSION})" \
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
--download-tag "$DEV_VERSION" \
--output release-assets/latest-dev.json
test -s release-assets/latest-dev.json
echo "📄 latest-dev.json ready:"
head -n 40 release-assets/latest-dev.json
- name: Format Build Time
id: build_time
shell: bash
@@ -1204,3 +1430,249 @@ jobs:
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Mirror, promote, and retain latest dev release in Cloudflare R2
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
DEV_VERSION: ${{ steps.version.outputs.version }}
HAS_DRIVER_ASSETS: ${{ steps.driver_assets.outputs.has_driver_assets }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
echo "Cloudflare R2 release credentials are not configured" >&2
exit 1
fi
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
app_dir="release-assets"
driver_dir="driver-release-assets"
app_prefix="gonavi/dev/releases/download/${DEV_VERSION}"
driver_prefix="drivers/dev/releases/download/${DEV_VERSION}"
jq -e \
--arg version "${DEV_VERSION}" \
--arg mirror_base "https://download.syngnat.top/gonavi/dev/releases/download" \
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download/dev-latest" \
'
.channel == "dev"
and .tagName == "dev-latest"
and .version == $version
and (.assets | type == "array" and length > 0)
and all(.assets[];
(.url | type == "string" and startswith($mirror_base + "/" + $version + "/"))
and (.apiUrl | type == "string" and startswith($github_base + "/"))
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
)
' "${app_dir}/latest-dev.json" >/dev/null
driver_latest_file="${RUNNER_TEMP}/driver-dev-latest-index.json"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
echo "DriverAgents SHA256SUMS is missing" >&2
exit 1
}
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
jq \
--arg tag "dev-latest" \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName = $tag | .mirrorTagName = $mirror_tag | del(.assets["GoNavi-DriverAgents.zip"])' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
> "${driver_latest_file}"
jq -e '.assets | type == "object" and length > 0' "${driver_latest_file}" >/dev/null
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${current_driver_keep_prefix}"
else
echo "::warning::Current dev driver pointer could not be fully validated; preserving every dev driver object"
fi
fi
app_manifest_size="$(stat -c '%s' "${app_dir}/latest-dev.json")"
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest-dev.json")"
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
budget_args=(
measure
--bucket "${R2_BUCKET}"
--endpoint-url "${endpoint}"
--prefix "gonavi/"
--prefix "drivers/"
--exclude-prefix "gonavi/dev/releases/download/"
--exclude-prefix "gonavi/dev/releases/latest/"
)
if [[ -s "${driver_latest_file}" ]]; then
driver_index_size="$(stat -c '%s' "${driver_latest_file}")"
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_latest_file}")"
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size * 2))"
budget_args+=(
--exclude-prefix "drivers/dev/releases/download/"
--exclude-prefix "drivers/dev/releases/latest/"
)
fi
python3 tools/r2-release-retention.py "${budget_args[@]}" \
--add-bytes "${planned_bytes}" \
--max-bytes 9000000000
upload_immutable() {
local file="$1"
local key="$2"
local content_type="${3:-}"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "Refusing to overwrite immutable R2 object ${key}" >&2
exit 1
fi
return
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
exit 1
fi
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
if [[ -n "${content_type}" ]]; then
args+=(--content-type "${content_type}")
fi
aws "${args[@]}"
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 verification failed for ${key}" >&2
exit 1
fi
}
while IFS=$'\t' read -r name expected_size expected_sha; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev app asset name: ${name}" >&2
exit 1
}
file="${app_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev app asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
echo "Dev app asset does not match latest-dev.json: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${app_prefix}/${name}"
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest-dev.json")
upload_immutable \
"${app_dir}/latest-dev.json" \
"${app_prefix}/latest-dev.json" \
"application/json; charset=utf-8"
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
while IFS=$'\t' read -r name expected_size; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid dev driver asset name: ${name}" >&2
exit 1
}
file="${driver_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Dev driver asset is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
if [[ "${actual_size}" != "${expected_size}" ]]; then
echo "Dev driver asset size does not match index: ${name}" >&2
exit 1
fi
upload_immutable "${file}" "${driver_prefix}/${name}"
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' "${driver_latest_file}")
upload_immutable \
"${driver_latest_file}" \
"${driver_prefix}/GoNavi-DriverAgents-Index.json" \
"application/json; charset=utf-8"
fi
promote_mutable() {
local file="$1"
local key="$2"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${sha}" \
--only-show-errors
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 mutable object verification failed for ${key}" >&2
exit 1
fi
}
app_latest_key="gonavi/dev/releases/latest/latest-dev.json"
promote_mutable "${app_dir}/latest-dev.json" "${app_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${app_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg version "${DEV_VERSION}" '.channel == "dev" and .version == $version' >/dev/null
if [[ -s "${driver_latest_file}" ]]; then
driver_latest_key="drivers/dev/releases/latest/GoNavi-DriverAgents-Index.json"
promote_mutable "${driver_latest_file}" "${driver_latest_key}"
curl --fail --silent --show-error \
"https://download.syngnat.top/${driver_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e \
--arg mirror_tag "${DEV_VERSION}" \
'.tagName == "dev-latest" and .mirrorTagName == $mirror_tag' >/dev/null
fi
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "gonavi/dev/releases/download/" \
--keep-prefix "${app_prefix}/"
driver_keep_prefix=""
if [[ -s "${driver_latest_file}" ]]; then
driver_keep_prefix="${driver_prefix}/"
else
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel dev > "${current_driver_state}"; then
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
else
echo "::warning::Skipping dev driver cleanup because the current R2 pointer or retained assets are invalid"
fi
fi
if [[ -n "${driver_keep_prefix}" ]]; then
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/dev/releases/download/" \
--keep-prefix "${driver_keep_prefix}"
fi
python3 tools/r2-release-retention.py measure \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--prefix "gonavi/" \
--prefix "drivers/" \
--max-bytes 9000000000

260
.github/workflows/docker-images.yml vendored Normal file
View File

@@ -0,0 +1,260 @@
name: Docker Images
on:
push:
branches:
- dev
tags:
- 'v*'
concurrency:
group: docker-images-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build:
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
image_name:
- gonavi-mcp-server
- gonavi-web-server
- gonavi-build-env
platform:
- linux/amd64
- linux/arm64
include:
- image_name: gonavi-mcp-server
dockerfile: Dockerfile.mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-web-server
dockerfile: Dockerfile.web-server
description: GoNavi Web Server (browser UI) container image
- image_name: gonavi-build-env
dockerfile: Dockerfile.build-env
description: GoNavi Linux build environment image
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Validate runtime Dockerfiles
if: matrix.image_name == 'gonavi-web-server'
shell: bash
run: |
set -euo pipefail
bash tools/validate-web-server-dockerfile.test.sh
env_file="$(mktemp)"
trap 'rm -f "$env_file"' EXIT
printf 'GONAVI_HOST_DATA_ROOT=%s\nGONAVI_WEB_PASSWORD=123456\n' "${RUNNER_TEMP}/gonavi-data" >"$env_file"
rendered="$(docker compose --env-file "$env_file" -f docker-compose.web-server.yml config)"
grep -Eq "GONAVI_WEB_PASSWORD: ['\"]?123456['\"]?$" <<<"$rendered"
- name: Prepare build variables
id: prep
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
org.opencontainers.image.licenses=Apache-2.0
- name: Build smoke image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
load: true
tags: codex-smoke/${{ matrix.image_name }}:local
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Smoke test MCP Server image
if: matrix.image_name == 'gonavi-mcp-server'
shell: bash
run: |
set -euo pipefail
cid="$(docker run -d -p 8765:8765 -e GONAVI_MCP_HTTP_TOKEN=smoke-token codex-smoke/${{ matrix.image_name }}:local http)"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl --fail --silent http://127.0.0.1:8765/healthz >/dev/null; then
exit 0
fi
sleep 1
done
docker logs "$cid"
echo "MCP Server image smoke test failed" >&2
exit 1
- name: Smoke test Web Server image
if: matrix.image_name == 'gonavi-web-server'
shell: bash
run: |
set -euo pipefail
cookie_jar="$(mktemp)"
cid=""
trap 'rm -f "$cookie_jar"; if [[ -n "$cid" ]]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi' EXIT
cid="$(docker run -d -p 127.0.0.1::34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 -e GONAVI_WEB_PASSWORD=123456 codex-smoke/${{ matrix.image_name }}:local web-server)"
host_port="$(docker port "$cid" 34116/tcp | awk -F: 'NR == 1 { print $NF }')"
if [[ ! "$host_port" =~ ^[0-9]+$ ]]; then
docker logs "$cid"
echo "Failed to resolve the Web Server smoke-test port" >&2
exit 1
fi
base_url="http://127.0.0.1:${host_port}"
ready=false
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if curl --fail --silent "${base_url}/__gonavi/healthz" >/dev/null; then
ready=true
break
fi
sleep 1
done
if [[ "$ready" != "true" ]]; then
docker logs "$cid"
echo "Web Server image smoke test failed" >&2
exit 1
fi
status="$(curl --fail --silent --show-error "${base_url}/__gonavi/auth/status")"
grep -Fq '"configured":true' <<<"$status"
login="$(curl --fail --silent --show-error -c "$cookie_jar" -H 'Content-Type: application/json' -d '{"password":"123456","code":""}' "${base_url}/__gonavi/auth/login")"
grep -Fq '"success":true' <<<"$login"
app_status="$(curl --silent --show-error -o /dev/null -w '%{http_code}' -b "$cookie_jar" "${base_url}/")"
[[ "$app_status" == "200" ]]
- name: Smoke test build environment image
if: matrix.image_name == 'gonavi-build-env'
shell: bash
run: |
set -euo pipefail
docker run --rm codex-smoke/${{ matrix.image_name }}:local \
bash -lc 'go version && node -v && npm -v && wails version'
- name: Push by digest
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
- name: Export digest
shell: bash
run: |
set -euo pipefail
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.push.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Publish ${{ matrix.image_name }}
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
include:
- image_name: gonavi-mcp-server
description: GoNavi MCP Server container image
- image_name: gonavi-web-server
description: GoNavi Web Server (browser UI) container image
- image_name: gonavi-build-env
description: GoNavi Linux build environment image
steps:
- name: Normalize image namespace
id: vars
shell: bash
run: |
set -euo pipefail
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-${{ matrix.image_name }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}
flavor: |
latest=false
tags: |
type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }}
type=sha,format=short,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
labels: |
org.opencontainers.image.title=${{ matrix.image_name }}
org.opencontainers.image.description=${{ matrix.description }}
org.opencontainers.image.licenses=Apache-2.0
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
shell: bash
run: |
set -euo pipefail
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}@sha256:%s ' *)
- name: Inspect image
shell: bash
run: |
set -euo pipefail
docker buildx imagetools inspect "ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}:${{ steps.meta.outputs.version }}"

293
.github/workflows/issue-claim.yml vendored Normal file
View File

@@ -0,0 +1,293 @@
# 评论认领:在 Issue 下评论「我想做」等关键词后,自动指派/标记认领人。
# 说明:
# - 仓库协作者write 及以上)可成功写入 Assignees
# - 外部贡献者若无写权限GitHub 不允许设为 assignee此时会打 claimed 标签并回复确认
# - 支持取消认领:取消认领 / /unclaim
name: Issue Claim
on:
issue_comment:
types: [created]
permissions:
issues: write
contents: read
jobs:
claim:
name: Auto claim or unclaim
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: Process claim / unclaim comment
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const comment = context.payload.comment;
const actor = comment.user.login;
const actorType = comment.user.type;
// 忽略机器人自己的评论,避免循环
if (actorType === 'Bot') {
core.info('Skip bot comment');
return;
}
const raw = (comment.body || '').replace(/\r\n/g, '\n').trim();
if (!raw) {
return;
}
// 仅认领短指令:整段或首行匹配,避免长讨论误触发
const firstLine = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
const normalize = (s) =>
s
.replace(/^[@/]/g, '')
.replace(/[!。..、,\s]+$/g, '')
.trim()
.toLowerCase();
const claimPhrases = new Set([
'我想做',
'我来做',
'我来',
'认领',
'我认领',
'claim',
'i claim',
'i want this',
'i will take this',
'assign me',
]);
const unclaimPhrases = new Set([
'取消认领',
'不认领了',
'放弃认领',
'unclaim',
'release',
]);
const nFull = normalize(raw);
const nFirst = normalize(firstLine);
// 允许 /claim /unclaim 形式
const slash = firstLine.match(/^\/(claim|unclaim)\b/i);
const isClaim =
claimPhrases.has(nFull) ||
claimPhrases.has(nFirst) ||
(slash && slash[1].toLowerCase() === 'claim');
const isUnclaim =
unclaimPhrases.has(nFull) ||
unclaimPhrases.has(nFirst) ||
(slash && slash[1].toLowerCase() === 'unclaim');
if (!isClaim && !isUnclaim) {
core.info('Comment is not a claim/unclaim command');
return;
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = issue.number;
const CLAIMED_LABEL = 'claimed';
async function ensureClaimedLabel() {
try {
await github.rest.issues.getLabel({ owner, repo, name: CLAIMED_LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
owner,
repo,
name: CLAIMED_LABEL,
color: '0E8A16',
description: '已被社区认领,进行中',
});
}
}
async function ensureLabels(names) {
const current = new Set((issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name)));
const toAdd = names.filter((n) => !current.has(n));
if (toAdd.length) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd });
}
}
async function removeLabels(names) {
for (const name of names) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name });
} catch (e) {
if (e.status !== 404) throw e;
}
}
}
async function tryAssign(login) {
try {
await github.rest.issues.addAssignees({
owner,
repo,
issue_number,
assignees: [login],
});
return { ok: true };
} catch (e) {
const msg = e.message || String(e);
core.warning(`assign failed for ${login}: ${msg}`);
return { ok: false, message: msg };
}
}
async function tryUnassign(login) {
try {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number,
assignees: [login],
});
return { ok: true };
} catch (e) {
core.warning(`unassign failed for ${login}: ${e.message || e}`);
return { ok: false };
}
}
// 从 issue 正文底部的认领锚点解析当前认领人
const CLAIM_MARKER_RE =
/<!--\s*gonavi-claim\s*:\s*([A-Za-z0-9-]+)\s*-->/;
const body = issue.body || '';
const markerMatch = body.match(CLAIM_MARKER_RE);
const markerClaimer = markerMatch ? markerMatch[1] : null;
const assignees = (issue.assignees || []).map((a) => a.login);
const currentClaimer = assignees[0] || markerClaimer || null;
const hasClaimedLabel = (issue.labels || []).some(
(l) => (typeof l === 'string' ? l : l.name) === CLAIMED_LABEL
);
if (isUnclaim) {
if (!currentClaimer && !hasClaimedLabel) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: ` @${actor} 当前无人认领,无需取消。`,
});
return;
}
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
// 仅认领人本人可取消;维护者可强制取消
const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
context.payload.comment.author_association
);
if (!isMaintainer) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `⚠️ @${actor} 本 Issue 当前由 @${currentClaimer} 认领。请对方评论 \`取消认领\`,或联系维护者。`,
});
return;
}
}
const who = currentClaimer || actor;
await tryUnassign(who);
await removeLabels([CLAIMED_LABEL]);
let newBody = body;
if (CLAIM_MARKER_RE.test(newBody)) {
newBody = newBody
.replace(/\n?<!--\s*gonavi-claim\s*:\s*[A-Za-z0-9-]+\s*-->\s*/g, '\n')
.replace(/\n?### 🔐 认领状态[\s\S]*?(?=\n## |\n# |$)/, '\n')
.trimEnd();
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
}
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `🔓 @${actor} 已取消认领${who !== actor ? `(原认领人 @${who}` : ''}。其他人可评论 \`我想做\` 重新认领。`,
});
return;
}
// —— claim ——
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `⚠️ @${actor} 本 Issue 已由 @${currentClaimer} 认领。\n\n- 若对方长期无进展,可评论提醒,或由维护者 \`取消认领\` 后重开\n- 你也可以先在本线程说明想做的方向,维护者可协助拆分子任务`,
});
return;
}
if (currentClaimer && currentClaimer.toLowerCase() === actor.toLowerCase()) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: `✅ @${actor} 你已经是本 Issue 的认领人,无需重复认领。开干吧 💪`,
});
return;
}
await ensureClaimedLabel();
const assignResult = await tryAssign(actor);
await ensureLabels([CLAIMED_LABEL]);
// 在正文写入可机读认领锚点assignee 失败时仍可防双认领)
const stamp = new Date().toISOString().slice(0, 10);
const claimBlock =
`\n\n### 🔐 认领状态\n\n` +
`- 认领人:@${actor}\n` +
`- 认领日期:${stamp}\n` +
`- 取消方式:评论 \`取消认领\` 或 \`/unclaim\`\n` +
`<!-- gonavi-claim: ${actor} -->\n`;
let newBody = body;
if (CLAIM_MARKER_RE.test(newBody)) {
newBody = newBody.replace(
CLAIM_MARKER_RE,
`<!-- gonavi-claim: ${actor} -->`
);
if (!/### 🔐 认领状态/.test(newBody)) {
newBody = newBody.trimEnd() + claimBlock;
}
} else {
newBody = newBody.trimEnd() + claimBlock;
}
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
if (assignResult.ok) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`✅ @${actor} 已认领本 Issue并已加入 **Assignees**。\n\n` +
`建议下一步:\n` +
`1. 从 \`dev\` 拉分支:\`fix/*\` 或 \`feature/*\`\n` +
`2. 完成后向 **\`dev\`** 提 PR\n` +
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
`贡献指南https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`✅ @${actor} 已认领本 Issue已标记 \`claimed\`)。\n\n` +
`> 说明GitHub 仅允许将**仓库协作者**设为正式 Assignee。你目前可能还没有写权限因此无法写入 Assignees 字段;**社区认领仍然有效**,其他人再评论「我想做」会被拦截。\n\n` +
`建议下一步:\n` +
`1. Fork 仓库,从 \`dev\` 拉分支开发\n` +
`2. 向 **\`dev\`** 提 PR并在 PR 描述里写 \`Fixes #${issue_number}\`\n` +
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
`贡献指南https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
});
}

532
.github/workflows/publish-release.yml vendored Normal file
View File

@@ -0,0 +1,532 @@
name: Publish Draft Release
on:
push:
branches:
- 'publish/v*'
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (for example, v0.8.7)
required: true
type: string
permissions:
actions: read
contents: write
concurrency:
# Stable and dev publications share the same retained R2 byte budget.
group: gonavi-r2-publication
cancel-in-progress: false
jobs:
publish:
name: Validate, mirror, and publish release
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
steps:
- name: Validate release
id: validate
uses: actions/github-script@v8
with:
script: |
const requestedTag = String(process.env.RELEASE_TAG || '').trim();
const tag = requestedTag.startsWith('publish/')
? requestedTag.slice('publish/'.length)
: requestedTag;
if (!/^v\d+\.\d+\.\d+$/.test(tag)) {
core.setFailed(`Invalid release tag: ${tag}`);
return;
}
const { owner, repo } = context.repo;
const tagRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${tag}`,
});
let commitSha = tagRef.data.object.sha;
if (tagRef.data.object.type === 'tag') {
const annotatedTag = await github.rest.git.getTag({
owner,
repo,
tag_sha: tagRef.data.object.sha,
});
commitSha = annotatedTag.data.object.sha;
}
const workflowRuns = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'release.yml',
event: 'push',
branch: tag,
per_page: 100,
});
const runs = workflowRuns.data.workflow_runs;
const successfulRun = runs.find((run) => (
run.head_sha === commitSha
&& run.status === 'completed'
&& run.conclusion === 'success'
));
if (!successfulRun) {
core.setFailed(`No successful Release workflow found for ${tag} at ${commitSha}`);
return;
}
const releaseList = await github.rest.repos.listReleases({
owner,
repo,
per_page: 100,
});
const releases = releaseList.data;
const release = releases.find((item) => item.tag_name === tag);
if (!release) {
core.setFailed(`Release not found for ${tag}`);
return;
}
let latestRelease = null;
try {
latestRelease = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
const semverParts = (value) => value.slice(1).split('.').map(Number);
const compareSemver = (left, right) => {
const a = semverParts(left);
const b = semverParts(right);
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) {
return a[index] - b[index];
}
}
return 0;
};
let shouldPublish = release.draft;
if (release.draft) {
const latestTag = String(latestRelease?.tag_name || '').trim();
if (latestTag && /^v\d+\.\d+\.\d+$/.test(latestTag) && compareSemver(tag, latestTag) <= 0) {
core.setFailed(`Refusing to publish ${tag} over current latest ${latestTag}`);
return;
}
} else if (!latestRelease || latestRelease.id !== release.id) {
core.setFailed(`Published release ${tag} is not the current GitHub latest release`);
return;
} else {
shouldPublish = false;
}
const version = tag.slice(1);
const expectedAssets = [
'LICENSE',
'NOTICE',
'SHA256SUMS',
'latest.json',
`GoNavi-${version}-Linux-Amd64-WebKit41.tar.gz`,
`GoNavi-${version}-Linux-Amd64.tar.gz`,
`GoNavi-${version}-Linux-Arm64.tar.gz`,
`GoNavi-${version}-MacOS-Amd64.dmg`,
`GoNavi-${version}-MacOS-Arm64.dmg`,
`GoNavi-${version}-Windows-Amd64-Installer.msi`,
`GoNavi-${version}-Windows-Amd64-Portable.exe`,
`GoNavi-${version}-Windows-Arm64-Installer.msi`,
`GoNavi-${version}-Windows-Arm64-Portable.exe`,
];
const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset]));
const missingAssets = expectedAssets.filter((name) => {
const asset = assetsByName.get(name);
return !asset || asset.state !== 'uploaded' || asset.size <= 0;
});
if (missingAssets.length > 0) {
core.setFailed(`Release assets are incomplete: ${missingAssets.join(', ')}`);
return;
}
core.setOutput('tag', tag);
core.setOutput('release_id', String(release.id));
core.setOutput('should_publish', String(shouldPublish));
core.notice(`Validated ${release.draft ? 'draft' : 'published'} release ${tag} and its assets`);
- name: Checkout release tooling
uses: actions/checkout@v5
- name: Mirror and verify stable release in Cloudflare R2
env:
GH_TOKEN: ${{ github.token }}
DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" || -z "${DRIVER_RELEASE_TOKEN}" ]]; then
echo "Cloudflare R2 or DriverAgents release credentials are not configured" >&2
exit 1
fi
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
app_dir="${RUNNER_TEMP}/gonavi-release"
driver_dir="${RUNNER_TEMP}/driver-release"
mkdir -p "${app_dir}" "${driver_dir}"
gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --dir "${app_dir}"
driver_release_json="${RUNNER_TEMP}/driver-release.json"
driver_status="$(curl --silent --show-error \
--output "${driver_release_json}" \
--write-out '%{http_code}' \
--header "Authorization: Bearer ${DRIVER_RELEASE_TOKEN}" \
--header 'Accept: application/vnd.github+json' \
--header 'X-GitHub-Api-Version: 2022-11-28' \
"https://api.github.com/repos/Syngnat/GoNavi-DriverAgents/releases/tags/${RELEASE_TAG}")"
has_driver_release=false
case "${driver_status}" in
200)
jq -e --arg tag "${RELEASE_TAG}" \
'.tag_name == $tag and .draft == false' "${driver_release_json}" >/dev/null
GH_TOKEN="${DRIVER_RELEASE_TOKEN}" gh release download "${RELEASE_TAG}" \
--repo Syngnat/GoNavi-DriverAgents --dir "${driver_dir}"
has_driver_release=true
;;
404)
echo "No DriverAgents release for ${RELEASE_TAG}; skipping driver mirror"
;;
*)
echo "DriverAgents release lookup failed with HTTP ${driver_status}" >&2
exit 1
;;
esac
jq -e \
--arg tag "${RELEASE_TAG}" \
--arg mirror_base "https://download.syngnat.top/gonavi/releases/download" \
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download" \
'
.tagName == $tag
and (.assets | type == "array" and length > 0)
and all(.assets[];
(.url | type == "string" and startswith($mirror_base + "/" + $tag + "/"))
and (.apiUrl | type == "string" and startswith($github_base + "/" + $tag + "/"))
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
)
' "${app_dir}/latest.json" >/dev/null
if [[ "${has_driver_release}" == true ]]; then
jq -e '.assets | type == "object" and length > 0' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" >/dev/null
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
echo "DriverAgents SHA256SUMS is missing" >&2
exit 1
}
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
jq --arg tag "${RELEASE_TAG}" \
'.tagName = $tag' \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
> "${RUNNER_TEMP}/driver-latest-index.json"
fi
while IFS=$'\t' read -r name expected_size expected_sha; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid app asset name in latest.json: ${name}" >&2
exit 1
}
file="${app_dir}/${name}"
[[ -f "${file}" ]] || {
echo "App asset referenced by latest.json is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
echo "App asset does not match latest.json: ${name}" >&2
exit 1
fi
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest.json")
if [[ "${has_driver_release}" == true ]]; then
while IFS=$'\t' read -r name expected_size; do
[[ "${name}" == "$(basename "${name}")" ]] || {
echo "Invalid driver asset name in index: ${name}" >&2
exit 1
}
file="${driver_dir}/${name}"
[[ -f "${file}" ]] || {
echo "Driver asset referenced by index is missing: ${name}" >&2
exit 1
}
actual_size="$(stat -c '%s' "${file}")"
if [[ "${actual_size}" != "${expected_size}" ]]; then
echo "Driver asset size does not match index: ${name}" >&2
exit 1
fi
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' \
"${driver_dir}/GoNavi-DriverAgents-Index.json")
else
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel stable > "${current_driver_state}"; then
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/releases/download/" \
--keep-prefix "${current_driver_keep_prefix}"
else
echo "::warning::Current stable driver pointer could not be fully validated; preserving every stable driver object"
fi
fi
# Preflight the final retained footprint before uploading or
# publishing. Existing objects in the channel being replaced are
# excluded, then the exact local candidate size is added.
app_manifest_size="$(stat -c '%s' "${app_dir}/latest.json")"
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest.json")"
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
budget_args=(
measure
--bucket "${R2_BUCKET}"
--endpoint-url "${endpoint}"
--prefix "gonavi/"
--prefix "drivers/"
--exclude-prefix "gonavi/releases/download/"
--exclude-prefix "gonavi/releases/latest/"
)
if [[ "${has_driver_release}" == true ]]; then
driver_index_size="$(stat -c '%s' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
driver_latest_size="$(stat -c '%s' "${RUNNER_TEMP}/driver-latest-index.json")"
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size + driver_latest_size))"
budget_args+=(
--exclude-prefix "drivers/releases/download/"
--exclude-prefix "drivers/releases/latest/"
)
fi
python3 tools/r2-release-retention.py "${budget_args[@]}" \
--add-bytes "${planned_bytes}" \
--max-bytes 9000000000
upload_immutable() {
local file="$1"
local key="$2"
local content_type="${3:-}"
local sha size head_json remote_sha remote_size
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "Refusing to overwrite immutable R2 object ${key}" >&2
exit 1
fi
echo "R2 object already verified: ${key}"
return
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
exit 1
fi
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
if [[ -n "${content_type}" ]]; then
args+=(--content-type "${content_type}")
fi
aws "${args[@]}"
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 verification failed for ${key}" >&2
exit 1
fi
}
while IFS= read -r name; do
file="${app_dir}/${name}"
upload_immutable "${file}" "gonavi/releases/download/${RELEASE_TAG}/${name}"
done < <(jq -r '.assets[].name' "${app_dir}/latest.json")
if [[ "${has_driver_release}" == true ]]; then
# Upload only the standalone assets referenced by the index. The
# aggregate bundle duplicates them and remains on GitHub fallback.
while IFS= read -r name; do
file="${driver_dir}/${name}"
upload_immutable "${file}" "drivers/releases/download/${RELEASE_TAG}/${name}"
done < <(jq -r '.assets | keys[]' "${driver_dir}/GoNavi-DriverAgents-Index.json")
fi
# Metadata is the commit point: publish it only after every referenced object is verified.
if [[ "${has_driver_release}" == true ]]; then
upload_immutable \
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
"drivers/releases/download/${RELEASE_TAG}/GoNavi-DriverAgents-Index.json" \
"application/json; charset=utf-8"
fi
upload_immutable \
"${app_dir}/latest.json" \
"gonavi/releases/download/${RELEASE_TAG}/latest.json" \
"application/json; charset=utf-8"
- name: Publish validated draft
if: steps.validate.outputs.should_publish == 'true'
uses: actions/github-script@v8
env:
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
with:
script: |
const tag = String(process.env.RELEASE_TAG || '').trim();
const releaseId = Number(process.env.RELEASE_ID || 0);
if (!/^v\d+\.\d+\.\d+$/.test(tag) || !Number.isSafeInteger(releaseId) || releaseId <= 0) {
core.setFailed(`Invalid validated release: tag=${tag} id=${releaseId}`);
return;
}
const { owner, repo } = context.repo;
const published = await github.rest.repos.updateRelease({
owner,
repo,
release_id: releaseId,
draft: false,
prerelease: false,
make_latest: 'true',
});
if (published.data.draft || !published.data.published_at) {
core.setFailed(`Release ${tag} was not published successfully`);
return;
}
core.notice(`Published ${tag}: ${published.data.html_url}`);
- name: Verify GitHub release is published and latest
uses: actions/github-script@v8
env:
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
with:
script: |
const tag = String(process.env.RELEASE_TAG || '').trim();
const releaseId = Number(process.env.RELEASE_ID || 0);
const { owner, repo } = context.repo;
const [releaseResult, latestResult] = await Promise.all([
github.rest.repos.getRelease({ owner, repo, release_id: releaseId }),
github.rest.repos.getLatestRelease({ owner, repo }),
]);
const release = releaseResult.data;
const latest = latestResult.data;
if (release.draft || !release.published_at || release.tag_name !== tag || latest.id !== release.id) {
core.setFailed(`Release ${tag} is not the current published GitHub latest release`);
return;
}
core.notice(`Verified GitHub latest release ${tag}`);
- name: Promote and verify Cloudflare R2 latest manifest
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
R2_BUCKET: gonavi-downloads
shell: bash
run: |
set -euo pipefail
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
file="${RUNNER_TEMP}/gonavi-release/latest.json"
key="gonavi/releases/latest/latest.json"
sha="$(sha256sum "${file}" | awk '{print $1}')"
size="$(stat -c '%s' "${file}")"
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${sha}" \
--only-show-errors
head_json="$(aws s3api head-object \
--bucket "${R2_BUCKET}" \
--key "${key}" \
--endpoint-url "${endpoint}" \
--output json)"
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
echo "R2 latest manifest verification failed" >&2
exit 1
fi
curl --fail --silent --show-error \
"https://download.syngnat.top/gonavi/releases/latest/latest.json?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
driver_latest_file="${RUNNER_TEMP}/driver-latest-index.json"
if [[ -s "${driver_latest_file}" ]]; then
driver_key="drivers/releases/latest/GoNavi-DriverAgents-Index.json"
driver_sha="$(sha256sum "${driver_latest_file}" | awk '{print $1}')"
driver_size="$(stat -c '%s' "${driver_latest_file}")"
aws s3 cp "${driver_latest_file}" "s3://${R2_BUCKET}/${driver_key}" \
--endpoint-url "${endpoint}" \
--cache-control "no-cache,must-revalidate" \
--content-type "application/json; charset=utf-8" \
--metadata "sha256=${driver_sha}" \
--only-show-errors
driver_head_json="$(aws s3api head-object \
--bucket "${R2_BUCKET}" \
--key "${driver_key}" \
--endpoint-url "${endpoint}" \
--output json)"
remote_driver_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${driver_head_json}")"
remote_driver_size="$(jq -r '.ContentLength // 0' <<<"${driver_head_json}")"
if [[ "${remote_driver_sha}" != "${driver_sha}" || "${remote_driver_size}" != "${driver_size}" ]]; then
echo "R2 latest driver index verification failed" >&2
exit 1
fi
curl --fail --silent --show-error \
"https://download.syngnat.top/${driver_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
fi
# Only prune after the mutable pointers are publicly verified. A
# failed cleanup leaves extra storage but never breaks the active
# updater path; rerunning this job is idempotent.
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "gonavi/releases/download/" \
--keep-prefix "gonavi/releases/download/${RELEASE_TAG}/"
driver_keep_prefix=""
if [[ -s "${driver_latest_file}" ]]; then
driver_keep_prefix="drivers/releases/download/${RELEASE_TAG}/"
else
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
if python3 tools/r2-release-retention.py validate-driver-pointer \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--channel stable > "${current_driver_state}"; then
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
else
echo "::warning::Skipping stable driver cleanup because the current R2 pointer or retained assets are invalid"
fi
fi
if [[ -n "${driver_keep_prefix}" ]]; then
python3 tools/r2-release-retention.py prune \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--root-prefix "drivers/releases/download/" \
--keep-prefix "${driver_keep_prefix}"
fi
# Keep a 1 GB safety margin below the 10 GB-month free allowance.
python3 tools/r2-release-retention.py measure \
--bucket "${R2_BUCKET}" \
--endpoint-url "${endpoint}" \
--prefix "gonavi/" \
--prefix "drivers/" \
--max-bytes 9000000000

View File

@@ -1,22 +0,0 @@
name: Publish to WinGet
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_tag:
required: true
description: 'Tag of release you want to publish'
type: string
jobs:
publish:
runs-on: windows-2025-vs2026
steps:
- uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Syngnat.GoNavi
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
release-tag: ${{ inputs.release_tag || github.ref_name }}
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -7,6 +7,7 @@ on:
permissions:
contents: write
pull-requests: read
jobs:
frontend:
@@ -21,6 +22,18 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Test release asset contracts
run: |
bash tools/detect-changed-driver-agents.test.sh
bash tools/generate-driver-agent-revisions.test.sh
python3 tools/generate-driver-release-manifest.test.py
python3 tools/generate-update-latest-manifest.test.py
python3 tools/r2-release-retention.test.py
python3 tools/package-driver-release-assets.test.py
python3 tools/legal-release-assets.test.py
python3 tools/windows-release-artifacts.test.py
python3 tools/generate-release-notes.test.py
- name: Setup Node
uses: actions/setup-node@v5
with:
@@ -32,7 +45,7 @@ jobs:
uses: actions/cache@v5
with:
path: frontend/node_modules
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
@@ -47,6 +60,9 @@ jobs:
node frontend/scripts/wails-frontend-install.mjs
npm --prefix frontend run build
- name: Test frontend virtual scrolling
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
- name: Pack frontend dist
shell: bash
run: tar -cf frontend-dist.tar -C frontend/dist .
@@ -177,7 +193,7 @@ jobs:
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
if [[ "$BASE_REF" != "all" ]]; then
REVISION_DRIVERS=""
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
@@ -263,6 +279,15 @@ jobs:
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-22.04-arm
platform: linux/arm64
os_name: Linux
arch_name: Arm64
build_name: gonavi-build-linux-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
- os: ubuntu-24.04
platform: linux/amd64
@@ -334,6 +359,24 @@ jobs:
}
& $upxCmd --version
- name: Install WiX Toolset (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$wixVersion = "6.0.2"
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
dotnet tool install wix --tool-path $wixTools --version $wixVersion
if ($LASTEXITCODE -ne 0) {
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
}
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$env:PATH = "$wixTools;$env:PATH"
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
if ($LASTEXITCODE -ne 0) {
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
}
wix --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
@@ -354,6 +397,12 @@ jobs:
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
echo "⚠️ Linux arm64 暂不生成 AppImage仅产出 tar.gz"
touch /tmp/skip-appimage
exit 0
fi
# Download linuxdeploy tools for AppImage packaging
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
@@ -457,6 +506,11 @@ jobs:
& "$env:CC" --version
& "$env:CXX" --version
- name: Test Windows Online Updater (Windows AMD64)
if: ${{ matrix.platform == 'windows/amd64' }}
shell: pwsh
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
- name: Build
shell: bash
run: |
@@ -535,7 +589,45 @@ jobs:
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
mkdir -p "$GOCACHE"
echo "🧭 可选驱动使用隔离 GOCACHE$GOCACHE"
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
normalize_driver_token() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
case "$value" in
"") return 1 ;;
doris|diros) echo "doris" ;;
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elastic|elasticsearch) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
echo "$value"
;;
*)
echo "❌ 不支持的 driver-agent${1:-}" >&2
return 1
;;
esac
}
build_driver_name() {
case "$1" in
doris) echo "diros" ;;
*) echo "$1" ;;
esac
}
declare -a RAW_DRIVERS=()
declare -a DRIVERS=()
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
DRIVERS+=("$DRIVER")
done
if [ ${#DRIVERS[@]} -eq 0 ]; then
echo "🧭 没有需要构建的 driver-agent"
exit 0
fi
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
@@ -591,10 +683,7 @@ jobs:
}
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
@@ -689,7 +778,12 @@ jobs:
bash ./tools/verify-driver-agent-revisions.sh \
--assets-dir drivers \
--platform "$TARGET_PLATFORM" \
--drivers "$CHANGED_DRIVER_AGENTS"
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
mkdir -p driver-provenance
python3 ./tools/generate-driver-release-manifest.py \
--assets-dir drivers \
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
# macOS Packaging
- name: Package macOS DMG
@@ -706,6 +800,9 @@ jobs:
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
mkdir -p "$APP_NAME/Contents/Resources"
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
@@ -751,7 +848,7 @@ jobs:
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows EXE
- name: Package Windows Portable EXE and MSI
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
@@ -761,7 +858,9 @@ jobs:
$version = $version.Substring(1)
}
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
$archName = "${{ matrix.arch_name }}"
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
@@ -804,8 +903,71 @@ jobs:
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
if ($version -match '^(?<major>\d+)\.(?<minor>\d+)\.(?<build>\d+)(?:[-+].*)?$') {
$major = [int]$Matches.major
$minor = [int]$Matches.minor
$build = [int]$Matches.build
} else {
throw "Windows MSI 版本必须是 major.minor.patch 格式,当前为: $version"
}
if ($major -gt 255 -or $minor -gt 255 -or $build -gt 65535) {
throw "Windows MSI 版本超出范围 (major/minor <= 255, patch <= 65535): $version"
}
$productVersion = "$major.$minor.$build"
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
$componentGuid = if ($archName -eq "Arm64") {
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
} else {
"0BCEE70B-9CF2-449C-9ADE-190188493234"
}
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
} else {
"114030DD-DB5F-460A-9CAC-997680E9F938"
}
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
wix build $wxsPath `
-arch $wixArch `
-ext "WixToolset.UI.wixext/6.0.2" `
-d "ProductName=GoNavi" `
-d "ProductVersion=$productVersion" `
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
-d "ComponentGuid=$componentGuid" `
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
-d "InstallFolderName=GoNavi" `
-d "RegistryKeyName=GoNavi" `
-d "SourceExe=$sourceExe" `
-d "InstallMarker=$installMarkerPath" `
-d "IconPath=$iconPath" `
-d "LicenseRtf=$licenseRtf" `
-d "LicenseFile=$licenseFile" `
-d "NoticeFile=$noticeFile" `
-pdbtype none `
-intermediatefolder $wixIntermediateDir `
-out "..\\..\\$msiName"
if ($LASTEXITCODE -ne 0) {
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
}
# Linux Packaging (tar.gz and AppImage)
- name: Package Linux
@@ -838,7 +1000,7 @@ jobs:
# 1. Create tar.gz
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
mv "$TAR_NAME" ../../
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
@@ -852,9 +1014,12 @@ jobs:
# Create AppDir structure
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/doc/gonavi
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
# Create desktop file
printf '%s\n' \
@@ -907,9 +1072,11 @@ jobs:
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.msi
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
driver-provenance/**
retention-days: 1
# Phase 2: Collect all artifacts and Publish Release (Single Job)
@@ -923,6 +1090,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Download All Artifacts
uses: actions/download-artifact@v7
with:
@@ -930,9 +1102,29 @@ jobs:
pattern: build-artifacts-*
merge-multiple: true
- name: Add legal documents
shell: bash
run: install -m 0644 LICENSE NOTICE release-assets/
- name: List Assets
run: ls -R release-assets
- name: Download Previous Driver Manifest
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
shell: bash
run: |
set -euo pipefail
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
else
rm -f "$manifest_path"
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
fi
- name: Complete Driver Agent Assets
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
env:
@@ -1006,12 +1198,20 @@ jobs:
drivers \
../driver-release-assets
python3 ../tools/generate-driver-release-manifest.py \
--assets-dir drivers \
MANIFEST_ARGS=(
--assets-dir drivers
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
)
if [ -d driver-provenance ]; then
MANIFEST_ARGS+=(--provenance driver-provenance)
fi
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
fi
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
# GoNavi 主仓库只保留主程序包;驱动资产发布到独立仓库。
rm -rf drivers
rm -rf drivers driver-provenance
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
- name: Generate SHA256SUMS
@@ -1021,6 +1221,9 @@ jobs:
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
case "$file" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
@@ -1031,6 +1234,25 @@ jobs:
sha256sum "${FILES[@]}" > SHA256SUMS
fi
# 静态更新清单:客户端优先下载 github.com/.../latest/download/latest.json
# 避免终端用户检查更新打爆 api.github.com 未认证配额。
- name: Generate static update manifest (latest.json)
shell: bash
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
python3 tools/generate-update-latest-manifest.py \
--assets-dir release-assets \
--version "$VERSION" \
--tag "$TAG" \
--channel latest \
--download-base-url https://download.syngnat.top/gonavi/releases/download \
--output release-assets/latest.json
test -s release-assets/latest.json
echo "📄 latest.json ready:"
head -n 40 release-assets/latest.json
- name: Generate Driver SHA256SUMS
if: steps.driver_assets.outputs.has_driver_assets == 'true'
shell: bash
@@ -1080,65 +1302,19 @@ jobs:
- name: Generate Changelog
id: changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd repo-for-changelog
TAG="${{ github.ref_name }}"
# 获取上一个 tag
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
if [ -z "$PREV_TAG" ]; then
echo "⚠️ 未找到上一个 tag使用全部 commit"
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
echo "📋 生成更新日志:$RANGE"
# 提取 commit 消息(排除 merge commit
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
BODY="暂无提交记录。"
else
CAT_FEAT=""
CAT_FIX=""
CAT_PERF=""
CAT_REFACTOR=""
CAT_I18N=""
CAT_OTHER=""
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"
BODY=""
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
# 附加 compare 链接
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
fi
fi
# 写入到文件避免多行环境变量问题
printf '%b' "$BODY" > /tmp/changelog.md
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
CHANGELOG_FILE="$RUNNER_TEMP/changelog.md"
python3 tools/generate-release-notes.py \
--repo "${{ github.repository }}" \
--tag "${{ github.ref_name }}" \
--repository-url "${{ github.server_url }}/${{ github.repository }}" \
--output "$CHANGELOG_FILE"
echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v3

4
.gitignore vendored
View File

@@ -11,10 +11,14 @@ build/bin/
# wails / node artifacts (按需)
node_modules/
frontend/wailsjs/tsconfig.json
__pycache__/
*.py[cod]
dist/
.DS_Store
.gemini-clipboard
.env
docker.*.env
GoNavi-Wails
GoNavi-Wails.exe
optional-driver-agent

58
Dockerfile.build-env Normal file
View File

@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1.7
ARG GO_IMAGE=golang:1.25-bookworm
ARG NODE_IMAGE=node:20-bookworm
FROM ${NODE_IMAGE} AS node
FROM ${GO_IMAGE}
ARG WEBKIT_API=4.0
ARG WAILS_VERSION=v2.11.0
ENV DEBIAN_FRONTEND=noninteractive
ENV PATH=/go/bin:/usr/local/go/bin:${PATH}
# Debian's /etc/profile resets PATH in login shells (bash -l), dropping the Go
# toolchain; restore it via profile.d which is sourced after that reset.
RUN printf 'export PATH=/go/bin:/usr/local/go/bin:$PATH\n' > /etc/profile.d/10-gonavi-build-env.sh
# Copy only Node.js runtime assets so the Go toolchain from the base image
# stays intact for smoke tests and interactive build shells.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
# npm/npx/corepack are symlinks in the node image; COPY dereferences them into
# standalone scripts whose relative requires break, so recreate the symlinks.
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
build-essential \
ca-certificates \
curl \
file \
git \
make \
pkg-config \
python3 \
unzip \
zip \
libgtk-3-dev \
&& if [ "${WEBKIT_API}" = "4.1" ]; then \
apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libsoup-3.0-dev; \
else \
apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev; \
fi \
&& go install github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION} \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /usr/share/doc/gonavi
COPY LICENSE NOTICE /usr/share/doc/gonavi/
WORKDIR /workspace
CMD ["bash"]

49
Dockerfile.mcp-server Normal file
View File

@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
# Declared without defaults so BuildKit injects the real target platform;
# a default like TARGETARCH=amd64 would win and ship amd64 binaries to arm64.
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
RUN go mod download
COPY . .
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/gonavi-mcp-server ./cmd/gonavi-mcp-server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
&& chown -R gonavi:gonavi /data /var/lib/gonavi
COPY --from=builder /out/gonavi-mcp-server /usr/local/bin/gonavi-mcp-server
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
ENV HOME=/var/lib/gonavi \
GONAVI_DATA_ROOT=/data \
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \
GONAVI_MCP_HTTP_PATH=/mcp \
GONAVI_MCP_SCHEMA_ONLY=true
VOLUME ["/data"]
EXPOSE 8765
USER gonavi
ENTRYPOINT ["/usr/local/bin/gonavi-mcp-server"]
CMD ["http"]

65
Dockerfile.web-server Normal file
View File

@@ -0,0 +1,65 @@
# syntax=docker/dockerfile:1.7
#
# GoNavi Web Server浏览器访问版镜像。
# 复用主程序 web-server 模式 + 嵌入 frontend/dist不依赖桌面 WebView/CGO。
FROM --platform=$BUILDPLATFORM node:20-bookworm AS frontend
# 使用 monorepo 布局frontend 源码通过相对路径 ../../../shared/i18n 引用仓库 shared/
# (见 frontend/src/i18n/catalog.ts、shared/i18n/messages.ts、shared/i18n/translate.ts
# 仅 COPY frontend/ 会导致 tsc 报 Cannot find module '../../../shared/i18n/*'。
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/patches ./patches
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
COPY shared/ /src/shared/
# wailsjs 绑定已入库;容器内只构建静态前端
RUN npm run build
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
RUN go mod download
COPY . .
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
COPY --from=frontend /src/frontend/dist ./frontend/dist
# 纯 Go 构建主程序web-server 模式不需要 Wails WebView / CGO
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/gonavi .
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
&& chown -R gonavi:gonavi /data /var/lib/gonavi
COPY --from=builder /out/gonavi /usr/local/bin/gonavi
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
ENV HOME=/var/lib/gonavi \
GONAVI_DATA_ROOT=/data \
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
GONAVI_WEB_ADDR=0.0.0.0:34116
VOLUME ["/data"]
EXPOSE 34116
USER gonavi
# 未通过环境变量配置密码时,首次访问 /setup 初始化;认证状态落在数据目录 web_auth.json
ENTRYPOINT ["/usr/local/bin/gonavi"]
CMD ["web-server"]

212
NOTICE Normal file
View File

@@ -0,0 +1,212 @@
GoNavi
Copyright 2026 Syngnat
This product includes software developed by the following projects.
Apache RocketMQ
Copyright 2016-2022 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
Apache IoTDB
Copyright 2018-2020 The Apache Software Foundation.
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
============================================================================
IoTDB project uses 4 Chinese Patents:
* 201711384490X
* 201810111712.9
* 201711322631.5
* 201711319331.1
According to the Apache 2.0 License. The owner of the patents, Tsinghua University,
grant the users the right to the use of patent under the requirement of Apache 2.0 License.
============================================================================
Apache Thrift
Copyright (C) 2006 - 2019, The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
go-yaml
Copyright 2011-2016 Canonical Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Eclipse Paho MQTT Go
This content is produced and maintained by the Eclipse Paho project.
* Project home: https://www.eclipse.org/paho/
Note that a separate mqtt v5 client (https://github.com/eclipse/paho.golang) also
exists. It is a full rewrite and deliberately incompatible with this library.
Eclipse Mosquitto trademarks of the Eclipse Foundation. Eclipse, and the
Eclipse Logo are registered trademarks of the Eclipse Foundation.
Paho is a trademark of the Eclipse Foundation. Eclipse, and the Eclipse Logo are
registered trademarks of the Eclipse Foundation.
All content is the property of the respective authors or their employers.
For more information regarding authorship of content, please consult the
listed source code repository logs.
This program and the accompanying materials are made available under the terms of the
Eclipse Public License v2.0 and Eclipse Distribution License v1.0 which accompany this
distribution.
The Eclipse Public License is available at
https://www.eclipse.org/legal/epl-2.0/
and the Eclipse Distribution License is available at
http://www.eclipse.org/org/documents/edl-v10.php.
For an explanation of what dual-licensing means to you, see:
https://www.eclipse.org/legal/eplfaq.php#DUALLIC
SPDX-License-Identifier: EPL-2.0 or BSD-3-Clause
The project maintains the following source code repository:
* https://github.com/eclipse/paho.mqtt.golang
This project makes use of the following third-party projects:
Go Programming Language and Standard Library
* License: BSD-style license (https://golang.org/LICENSE)
Go Networking
* License: BSD 3-Clause style license and patent grant.
* Project: https://cs.opensource.google/go/x/net
Go Sync
* License: BSD 3-Clause style license and patent grant.
* Project: https://cs.opensource.google/go/x/sync/
Gorilla Websockets v1.4.2
* License: BSD 2-Clause "Simplified" License
* Project: https://github.com/gorilla/websocket
Content may contain encryption software. The country in which you are currently
may have restrictions on the import, possession, and use, and/or re-export to
another country. BEFORE using any encryption software, please check the country's
laws, regulations and policies concerning the import, possession, and use, and/or
re-export of encryption software, to see if this is permitted.
Vendored HighGo pq-sm3 (derived from lib/pq)
Copyright (c) 2011-2013, 'pq' Contributors
Portions Copyright (C) 2011 Blake Mizerany
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The vendored SCRAM implementation includes the following BSD 3-Clause notice:
Copyright (c) 2014 - Gustavo Niemeyer <gustavo@niemeyer.net>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Tailscale hujson
Project: https://github.com/tailscale/hujson
Copyright (c) 2019 Tailscale Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Vendored InterSystems IRIS native driver
Copyright (c) 2025 Dmitry Maslennikov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

515
README.md
View File

@@ -1,42 +1,182 @@
# GoNavi - A Modern Lightweight Database Client
<p align="center">
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
</p>
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
<h1 align="center">GoNavi</h1>
**Language**: English | [简体中文](README.zh-CN.md)
<p align="center">
<b>Navigate every data source — native speed, agent-ready, zero Electron bloat.</b>
</p>
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
It delivers native-like responsiveness with low resource usage.
<p align="center">
A high-performance cross-platform database client built with
<a href="https://wails.io">Wails</a> (Go) + <a href="https://react.dev">React</a>.
Desktop-first. MCP-ready. ~30MB class binaries.
</p>
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
<p align="center">
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
</p>
<p align="center">
<b>Language</b>: English · <a href="README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ Download</b></a>
·
<a href="#-quick-start"><b>⚡ Quick Start</b></a>
·
<a href="#-key-features"><b>✨ Features</b></a>
·
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
</p>
---
## Project Overview
## Why GoNavi?
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
Most database GUIs are Electron shells with megabytes of tax. GoNavi takes a different path:
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
| | Typical Electron client | **GoNavi** |
|---|---|---|
| Runtime | Chromium + Node | **Go + native WebView** |
| Binary size | Hundreds of MB | **~30MB class** |
| Startup | Heavy | **Fast** |
| Memory | High baseline | **Lean** |
| AI / Agents | Bolt-on or absent | **First-class MCP + multi-provider AI** |
| Data sources | Mostly RDBMS | **SQL · Cache · Vector · MQ · Search · Time-series · Domestic DBs** |
## Supported Data Sources
> **One cockpit for MySQL, Postgres, Redis, Kafka, Milvus, OceanBase, ClickHouse…**
> Query, edit, audit, sync — and hand structured context to coding agents without leaking passwords off-host.
> `Built-in`: available out of the box.
> `Optional driver agent`: install/enable via Driver Manager first.
---
## At a Glance
```text
┌──────────────────────────────────────────────────────────────────────┐
│ GoNavi Workbench │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
│ │ Connections │ │ Monaco SQL + AI │ │ Virtualized DataGrid │ │
│ │ SSH / Proxy │ │ Schema context │ │ Batch edit · Export │ │
│ │ Drivers │ │ Slash commands │ │ Txn submit / rollback │ │
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
│ │ │
│ ┌─────────────▼─────────────┐ │
│ │ Go core · Audit · Sync │ │
│ │ MCP HTTP · Web Server │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### Product screenshots
Each image is a **full GoNavi application window**, scaled proportionally for README display.
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi full window — connections, queries, and workbench" width="560" />
&nbsp;
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi full window — AI assistant with schema context" width="560" />
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi full window — new connection data-source selector" width="560" />
</p>
<p align="center"><sub>Real desktop captures · full window</sub></p>
---
## ✨ Key Features
<table>
<tr>
<td width="50%" valign="top">
### 🤖 AI that knows your schema
- OpenAI · Gemini · Claude · custom OpenAI-compatible APIs
- Attach live table schemas as context
- Slash commands: generate SQL, explain, optimize, review
- **MCP**: install into Claude Code / Codex, or Streamable HTTP for remote agents
- Secrets stay on the GoNavi host — agents get tools, not raw passwords
</td>
<td width="50%" valign="top">
### ⚡ Built for large data
- Virtualized DataGrid for heavy result sets
- In-place cell edit · batch CRUD · transaction submit/rollback
- Large-field popup editor · smart read/write modes
- Export: CSV · XLSX · JSON · Markdown
- Monaco editor with context-aware completion
</td>
</tr>
<tr>
<td width="50%" valign="top">
### 🔌 Connectivity without drama
- URI generate / parse
- SSH tunnel · proxy
- Connection JSON import / export
- On-demand optional driver agents
- Custom Driver + DSN extensibility
</td>
<td width="50%" valign="top">
### 🛡️ Observable & shippable
- SQL execution logs with timing
- Audit center (redacted by default, retention, export)
- Desktop + experimental **Web Server** mode
- Docker / K8s / Helm / Podman packaging
- Auto update checks · multi-arch releases
</td>
</tr>
</table>
### 🧩 Stack
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
---
## 🗄 Supported Data Sources
> **Built-in** — ready out of the box · **Optional agent** — install via Driver Manager
| | |
|---|---|
| **Built-in** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
| **Optional** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · Dameng · Kingbase · HighGo · Vastbase · OpenGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
<details>
<summary><b>Full capability matrix</b></summary>
| Category | Data Source | Driver Mode | Typical Capabilities |
|---|---|---|---|
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
| Domestic DB | GoldenDB | Built-in | MySQL-compatible query workflow and distributed transaction scenarios |
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
| Vector Database | Chroma | Built-in | Collection browsing, vector retrieval, metadata filtering |
| Vector Database | Qdrant | Built-in | Collection browsing, vector search, payload filtering |
| Vector Database | Milvus | Built-in | Collection browsing, vector search, scalar filtering |
| Message Queue | RocketMQ | Built-in | Topic browsing, consumer-group inspection, message-oriented workflow |
| Message Queue | MQTT | Built-in | Broker and topic-filter workflow with QoS-aware connection settings |
| Message Queue | Kafka | Built-in | Topic browsing, broker metadata, consumer-group workflow |
| Message Queue | RabbitMQ | Built-in | Queue/exchange browsing, virtual host inspection, management API workflow |
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
@@ -44,163 +184,234 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
| Domestic DB | OceanBase | Optional driver agent | MySQL / Oracle tenant access, object browsing, query workflow |
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
| Domestic DB | OpenGauss | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Domestic DB | GaussDB | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
| Multi-model | InterSystems IRIS | Optional driver agent | Namespace browsing, SQL query, object management |
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
| Time-series | Apache IoTDB | Optional driver agent | Storage group / device / timeseries browsing and querying |
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
</details>
---
## Key Features
### AI Assistant (New)
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
### Data Management (DataGrid)
- In-place cell editing.
- Batch insert/update/delete with transaction-oriented submit/rollback.
- Large-field popup editor.
- Context actions (set NULL, copy/export, etc.).
- Smart read/write mode switching based on query context.
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
### SQL Editor
- Monaco Editor core.
- Context-aware completion for databases/tables/columns.
- Multi-tab query workflow.
### Batch Export / Backup
- Database-level and table-level batch export/backup.
- Scope-aware operation flow to reduce mistakes.
### Connectivity
- URI generation/parsing.
- SSH tunnel support.
- Proxy support.
- Config import/export (JSON).
- Optional driver management and activation.
### Redis Tools
- Multi-view value rendering (auto/raw text/UTF-8/hex).
- Built-in command execution panel.
### Observability and Update
- SQL execution logs with timing information.
- Startup/scheduled/manual update checks.
### UI/UX
- Ant Design 5 based interface.
- Light/Dark themes.
- Flexible sidebar and layout behavior.
---
## Tech Stack
- **Backend**: Go 1.24 + Wails v2
- **Frontend**: React 18 + TypeScript + Vite
- **UI**: Ant Design 5
- **State Management**: Zustand
- **Editor**: Monaco Editor
---
## Installation and Run
## 🚀 Quick Start
### Prerequisites
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
- [Wails CLI](https://wails.io/docs/gettingstarted/installation)
### Development Mode
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
```
```shell
# Clone
### Develop
```bash
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# Start development with hot reload
wails dev
# Faster local startup when exported Go method signatures are unchanged
node tools/wails-fast-dev.mjs
# Refresh Wails JS bindings after changing exported Go method signatures
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
wails dev # full hot reload
node tools/wails-fast-dev.mjs # faster when Go exports unchanged
node tools/wails-fast-dev.mjs --refresh-bindings # after Go export signature changes
```
### Build
```bash
# Build for current platform
wails build
# Clean build (recommended before release)
wails build -clean
wails build -clean # clean build before release
```
Artifacts are generated in `build/bin`.
Artifacts `build/bin`.
### Cross-Platform Release (GitHub Actions)
### Prefer a binary?
The repository includes a release workflow.
Push a `v*` tag to trigger automated build and release.
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
Target artifacts include:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
Grab the latest build from **[Releases](https://github.com/Syngnat/GoNavi/releases)**
(macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1).
---
## Troubleshooting
## 🌐 Web Server (Experimental)
### macOS: "App is damaged and cant be opened"
Same Go backend + React UI over HTTP — **not** a containerized Wails window.
Without Apple notarization, Gatekeeper may block startup.
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
1. Move `GoNavi.app` to **Applications**.
2. Open **Terminal**.
3. Run:
- First visit → `/setup` (admin password; optional Google Authenticator)
- Bridge: `window.go.*` / `window.runtime.*` → HTTP / SSE
- Sessions, recovery codes, login rate limits
#### Docker / Podman
```bash
cp docker.web-server.env.example docker.web-server.env
# set GONAVI_HOST_DATA_ROOT (absolute path)
# optional: GONAVI_WEB_PASSWORD (min 6 chars)
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
Open `http://127.0.0.1:34116`. Mount the active data root at `/data`
(`connections.json`, `daily_secrets.json`, optional `drivers/`).
Auth state → `web_auth.json`.
> **Do not expose an unhardened web entry to the public internet.** Use reverse proxy + HTTPS in production.
Health: `GET /__gonavi/healthz`
Local source build: add `-f docker-compose.web-server.local.yml --build`.
After changing env passwords:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
(`docker restart` does **not** reload env files.)
---
## 🤖 MCP & Agents
Ship schema tools to agents without shipping your vault:
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
| Surface | Entry |
|---|---|
| MCP container | `docker-compose.mcp-server.yml``ghcr.io/syngnat/gonavi-mcp-server` |
| Web UI container | `docker-compose.web-server.yml``ghcr.io/syngnat/gonavi-web-server` |
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
| Build-only image | `Dockerfile.build-env``ghcr.io/syngnat/gonavi-build-env` |
Safety defaults: remote `schema-only` omits `execute_sql`; mutating SQL requires explicit `allowMutating=true`.
Details: [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md).
### Linux build environment only
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
---
## 📦 Release Pipeline
Push a `v*` tag → GitHub Actions builds multi-arch releases.
Notes auto-generated from merged PRs via `.github/release.yaml`.
---
## 🛠 Troubleshooting
<details>
<summary><b>Windows: missing Microsoft Edge WebView2 Runtime (common on intranet images)</b></summary>
The GoNavi desktop app on Windows depends on the **Microsoft Edge WebView2 Runtime** (a system component, not full Chrome).
Some intranet / thin / Server / LTSC images ship without it. Symptoms:
- Process exits immediately, blank/white window
- Errors about missing WebView2 / WebView2 Runtime
- Installer blocked by AV or group policy
### 1. Check whether Runtime is installed
In **PowerShell**:
```powershell
# Common Evergreen install path (64-bit Windows)
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
# Registry (a `pv` version usually means installed)
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
-ErrorAction SilentlyContinue |
Select-Object pv, name
```
If the path is missing and registry has no `pv`, install the Runtime.
### 2. Online install (simplest)
1. Open the official download page:
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
2. Download the **Evergreen Bootstrapper** (small; needs network during install)
3. Run as Administrator, then **restart GoNavi**
### 3. Offline / intranet: Standalone installer (recommended for IT)
The Bootstrapper fails on fully air-gapped machines. Use the **Evergreen Standalone Installer** instead:
1. On a machine with internet, download the matching architecture package, e.g.:
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` (most 64-bit PCs)
- `…X86.exe` / `…ARM64.exe` as needed
2. Copy the installer into the intranet (USB, software center, share)
3. Install **as Administrator** on the target PC:
```powershell
# Interactive
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
# Silent (batch / SCCM friendly)
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
```
4. Start GoNavi again. If it still fails, sign out or reboot Windows once.
### 4. Group policy / locked-down PCs
- Needs local admin, or IT push via SCCM / software center
- Ensure policies do not block Edge/WebView2 install or updates
- Enterprises may pin a [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) Runtime (most users should prefer Evergreen)
### 5. Temporary workaround: Web Server mode
If the desktop WebView cannot be installed yet, run the experimental Web Server and use a normal browser:
```powershell
.\GoNavi.exe web-server --addr 127.0.0.1:34116
```
Open `http://127.0.0.1:34116`. See the **Web Server** section above.
Do **not** expose an unhardened Web endpoint to the public internet.
Tracker / discussion: [#672](https://github.com/Syngnat/GoNavi/issues/672).
</details>
<details>
<summary><b>macOS: “App is damaged and cant be opened”</b></summary>
Without Apple notarization, Gatekeeper may block the app:
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
Or right-click the app in Finder and choose **Open** with Control key flow.
Or right-click → Open (Control-click flow). Move the app to **Applications** first.
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
</details>
GoNavi depends on WebKitGTK runtime libraries.
<details>
<summary><b>Linux: missing WebKitGTK</b></summary>
```bash
# Debian 13 / Ubuntu 24.04+
@@ -212,11 +423,12 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
Artifacts with `-WebKit41` prefer Debian 13 / Ubuntu 24.04+.
### Linux: Chinese text appears as square boxes
</details>
Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi:
<details>
<summary><b>Linux: Chinese glyphs as tofu boxes</b></summary>
```bash
sudo apt-get update
@@ -224,19 +436,32 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
fc-cache -fv
```
</details>
---
## Contributing
## 🤝 Contributing
Issues and pull requests are welcome.
For the full workflow, branch model, and maintainer sync rules, see:
Issues and PRs welcome. Branch from **`dev`**, PR against **`dev`**.
- [CONTRIBUTING.md](CONTRIBUTING.md)
- Call for contributors: [Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
External contributors should branch from `dev` and open pull requests against `dev`.
---
## 💬 Contact & Feedback
| Channel | Notes |
|---|---|
| **GitHub Issues** | Preferred for bugs, feature requests, and docs — easier to track and reproduce |
| **WeChat** | `ygf1140302783` (note “GoNavi”) — maintainer can invite you to the community group for discussion |
> For technical reports, include version, OS, and repro steps when possible. Windows blank/crash on intranet images: [WebView2 guide](https://github.com/Syngnat/GoNavi/issues/672).
---
## ⭐ Star History
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
@@ -245,6 +470,8 @@ External contributors should branch from `dev` and open pull requests against `d
</picture>
</a>
---
## Links
- [linux.do](https://linux.do/)
@@ -252,4 +479,8 @@ External contributors should branch from `dev` and open pull requests against `d
## License
Licensed under [Apache-2.0](LICENSE).
[Apache-2.0](LICENSE)
<p align="center">
<sub>Built for people who live in SQL, schemas, and agent loops.</sub>
</p>

View File

@@ -1,41 +1,181 @@
# GoNavi - 现代化轻量级数据库客户端
<p align="center">
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
</p>
[![Go Version](https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi)](https://go.dev/)
[![Wails Version](https://img.shields.io/badge/Wails-v2-red)](https://wails.io)
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
<h1 align="center">GoNavi</h1>
**语言**: [English](README.md) | 简体中文
<p align="center">
<b>一套客户端,航行全部数据源 — 原生性能 · Agent 就绪 · 告别 Electron 膨胀。</b>
</p>
GoNavi 是基于 **Wails (Go)****React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
<p align="center">
基于 <a href="https://wails.io">Wails</a>Go+ <a href="https://react.dev">React</a> 的跨平台数据库工作台。
桌面优先MCP 原生,二进制约 <b>30MB</b> 量级。
</p>
相比常见 Electron 客户端GoNavi 在体积、启动速度和内存占用上更轻量。
<p align="center">
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
</p>
<p align="center">
<b>语言</b><a href="README.md">English</a> · 简体中文
&nbsp;·&nbsp;
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ 下载</b></a>
·
<a href="#-快速开始"><b>⚡ 快速开始</b></a>
·
<a href="#-核心能力"><b>✨ 特性</b></a>
·
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
</p>
---
## 项目简介
## 为什么是 GoNavi
GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做到“快、稳、统一”。
市面上大量数据库 GUI 仍是 Electron 壳体积大、启动慢、内存高。GoNavi 换了一条路:
- **原生性能架构**WailsGo + WebView降低运行时开销。
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
| | 常见 Electron 客户端 | **GoNavi** |
|---|---|---|
| 运行时 | Chromium + Node | **Go + 系统 WebView** |
| 体积 | 动辄数百 MB | **约 30MB 量级** |
| 启动 | 偏重 | **更快** |
| 内存 | 基线高 | **更轻** |
| AI / Agent | 外挂或缺失 | **MCP + 多模型一等公民** |
| 数据源 | 以 RDBMS 为主 | **SQL · 缓存 · 向量 · 消息 · 搜索 · 时序 · 国产库** |
## 支持的数据源
> **MySQL、Postgres、Redis、Kafka、Milvus、OceanBase、ClickHouse… 一个工作台打通。**
> 查询、编辑、审计、同步;把结构化上下文交给编码 Agent密码仍留在本机。
> `内置`:主程序开箱即用。
> `可选驱动代理`:需在驱动管理中安装启用后可用。
---
## 一眼看懂
```text
┌──────────────────────────────────────────────────────────────────────┐
│ GoNavi 工作台 │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
│ │ 连接管理 │ │ Monaco SQL + AI │ │ 虚拟滚动 DataGrid │ │
│ │ SSH / 代理 │ │ 表结构上下文 │ │ 批量编辑 · 导出 │ │
│ │ 驱动代理 │ │ 快捷指令 │ │ 事务提交 / 回滚 │ │
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
│ │ │
│ ┌─────────────▼─────────────┐ │
│ │ Go 核心 · 审计 · 同步 │ │
│ │ MCP HTTP · Web Server │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### 产品截图
每张均为 **完整 GoNavi 应用窗口**,再通过 README 宽度控件等比例缩小展示。
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi 全窗口 — 连接、查询与工作台" width="560" />
&nbsp;
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi 全窗口 — 带表结构上下文的 AI 助手" width="560" />
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi 全窗口 — 新建连接的数据源选择器" width="560" />
</p>
<p align="center"><sub>真实桌面全窗口截图</sub></p>
---
## ✨ 核心能力
<table>
<tr>
<td width="50%" valign="top">
### 🤖 懂表结构的 AI
- OpenAI · Gemini · Claude · 自定义 OpenAI 兼容 API
- 将当前库表结构注入对话上下文
- 快捷指令:生成 SQL、解释、优化、表设计评审
- **MCP**:一键装到 Claude Code / Codex或 Streamable HTTP 给远端 Agent
- 连接与密码留在运行 GoNavi 的主机上
</td>
<td width="50%" valign="top">
### ⚡ 大数据仍流畅
- 虚拟滚动 DataGrid扛住大结果集
- 单元格编辑 · 批量增删改 · 事务提交/回滚
- 大字段弹窗 · 按查询智能切换读写
- 导出CSV · XLSX · JSON · Markdown
- Monaco + 库/表/字段上下文补全
</td>
</tr>
<tr>
<td width="50%" valign="top">
### 🔌 连接与驱动
- URI 生成 / 解析
- SSH 隧道 · 代理
- 连接配置 JSON 导入导出
- 可选驱动代理按需安装
- Custom Driver + DSN 扩展
</td>
<td width="50%" valign="top">
### 🛡️ 可观测 & 可交付
- SQL 执行日志(含耗时)
- 审计中心(默认脱敏、保留策略、导出)
- 桌面端 + 实验性 **Web Server**
- Docker / K8s / Helm / Podman
- 更新检查 · 多架构发布
</td>
</tr>
</table>
### 🧩 技术栈
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
---
## 🗄 支持的数据源
> **内置**:开箱即用 · **可选驱动代理**:在驱动管理中安装启用
| | |
|---|---|
| **内置** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
| **可选** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · 达梦 · 人大金仓 · 瀚高 · 海量 · openGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
<details>
<summary><b>完整能力矩阵</b></summary>
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|---|---|---|---|
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
| 国产数据库 | GoldenDB | 内置 | MySQL 兼容查询工作流、分布式事务场景 |
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
| 向量数据库 | Chroma | 内置 | Collection 浏览、向量检索、元数据过滤 |
| 向量数据库 | Qdrant | 内置 | Collection 浏览、向量搜索、Payload 过滤 |
| 向量数据库 | Milvus | 内置 | Collection 浏览、向量搜索、标量过滤 |
| 消息队列 | RocketMQ | 内置 | Topic 浏览、消费组检查、消息型工作流 |
| 消息队列 | MQTT | 内置 | Broker / Topic Filter 工作流与 QoS 连接配置 |
| 消息队列 | Kafka | 内置 | Topic 浏览、Broker 元数据、消费组工作流 |
| 消息队列 | RabbitMQ | 内置 | Queue / Exchange 浏览、Virtual Host 检查、Management API 工作流 |
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
@@ -43,149 +183,235 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
| 国产数据库 | OceanBase | 可选驱动代理 | MySQL / Oracle 租户接入、对象浏览、查询工作流 |
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
| 国产数据库 | OpenGauss | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 国产数据库 | GaussDB | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
| 多模型数据库 | InterSystems IRIS | 可选驱动代理 | Namespace 浏览、SQL 查询、对象管理 |
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
| 时序 | Apache IoTDB | 可选驱动代理 | Storage Group / Device / Timeseries 浏览与查询 |
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、SQL 执行 |
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
</details>
---
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
### 数据管理DataGrid
- 单元格所见即所得编辑。
- 批量新增/修改/删除,支持事务提交与回滚。
- 大字段弹窗编辑。
- 右键上下文操作NULL、复制、导出等
- 根据查询上下文智能切换读写模式。
- 支持 CSV / XLSX / JSON / Markdown 导出。
### SQL 编辑器
- 基于 Monaco Editor。
- 上下文补全(数据库/表/字段)。
- 多标签查询工作流。
### 连接与驱动
- URI 生成与解析。
- SSH 隧道、代理支持。
- 连接配置 JSON 导入/导出。
- 可选驱动安装与启用管理。
### Redis 工具
- 自动/原始文本/UTF-8/十六进制等视图模式。
- 内置命令执行面板。
### 可观测性与更新
- SQL 执行日志(含耗时)。
- 启动/定时/手动更新检查。
### UI 体验
- Ant Design 5 体系。
- 深色/浅色主题切换。
- 灵活布局与侧边栏行为。
---
## 技术栈
- **后端**: Go 1.24 + Wails v2
- **前端**: React 18 + TypeScript + Vite
- **UI 框架**: Ant Design 5
- **状态管理**: Zustand
- **编辑器**: Monaco Editor
---
## 安装与运行
## 🚀 快速开始
### 前置要求
- [Go](https://go.dev/dl/) 1.21+
- [Node.js](https://nodejs.org/) 18+
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
- [Wails CLI](https://wails.io/docs/gettingstarted/installation)
### 开发模式
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
```
```shell
# 克隆项目
### 开发
```bash
git clone https://github.com/Syngnat/GoNavi.git
cd GoNavi
# 启动开发(热重载
wails dev
# 本地快速启动:未修改 Go 导出方法签名时使用
node tools/wails-fast-dev.mjs
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
node tools/wails-fast-dev.mjs --refresh-bindings
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
wails dev # 完整热重载
node tools/wails-fast-dev.mjs # Go 导出签名未变时更快
node tools/wails-fast-dev.mjs --refresh-bindings # 修改导出方法签名后刷新绑定
```
### 编译构建
### 构建
```bash
# 构建当前平台
wails build
# 清理后构建(发布前推荐)
wails build -clean
wails build -clean # 发布前推荐
```
构建产物位于 `build/bin`
产物位于 `build/bin`
### 跨平台发布GitHub Actions
### 只想直接用?
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类
支持目标:
- macOS (AMD64 / ARM64)
- Windows (AMD64)
- Linux (AMD64含 WebKitGTK 4.0 / 4.1 变体)
前往 **[Releases](https://github.com/Syngnat/GoNavi/releases)** 下载
macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1
---
## 常见问题
## 🌐 Web Server实验中
### macOS 提示“应用已损坏,无法打开”
同一套 Go 后端 + React 前端提供浏览器访问(**不是**把 Wails 桌面窗口容器化)。
在未进行 Apple Notarization 时Gatekeeper 可能拦截应用。
```powershell
go build .
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
```
- 首次访问进入 `/setup`(管理员密码;可选 Google Authenticator
- 桥接:`window.go.*` / `window.runtime.*` → HTTP / SSE
- Session、恢复码、登录限流
#### Docker / Podman
```bash
cp docker.web-server.env.example docker.web-server.env
# 设置 GONAVI_HOST_DATA_ROOT 为绝对路径
# 可选GONAVI_WEB_PASSWORD至少 6 位)
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
```
打开 `http://127.0.0.1:34116`。将活动数据目录挂载到 `/data`
`connections.json``daily_secrets.json`,可选 `drivers/`)。
认证状态写入 `web_auth.json`
> **不要把未加固的 Web 入口直接暴露到公网。** 生产环境请配合反向代理与 HTTPS。
健康检查:`GET /__gonavi/healthz`
本地源码构建:叠加 `-f docker-compose.web-server.local.yml --build`
修改 env 密码后需重建容器:
```bash
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
```
`docker restart` **不会**重新加载 env 文件。)
---
## 🤖 MCP & Agents
把结构工具交给 Agent而不是把密钥交出去
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
| 形态 | 入口 |
|---|---|
| MCP 容器 | `docker-compose.mcp-server.yml``ghcr.io/syngnat/gonavi-mcp-server` |
| Web UI 容器 | `docker-compose.web-server.yml``ghcr.io/syngnat/gonavi-web-server` |
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
| 仅构建镜像 | `Dockerfile.build-env``ghcr.io/syngnat/gonavi-build-env` |
安全默认:远端 `schema-only` 不暴露 `execute_sql`;变更类 SQL 需显式 `allowMutating=true`
详见:[cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md)。
### 仅 Linux 构建环境
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
---
## 📦 发布流水线
推送 `v*` Tag → GitHub Actions 自动多架构构建。
Release 说明按 `.github/release.yaml` 从已合并 PR 生成。
---
## 🛠 常见问题
<details>
<summary><b>Windows缺少 Microsoft Edge WebView2 运行时(内网常见)</b></summary>
GoNavi 桌面版在 Windows 上依赖 **Microsoft Edge WebView2 Runtime**(系统级组件,不是完整 Chrome
部分内网 / 精简镜像 / Windows Server / LTSC 机器未预装,会出现:
- 双击后窗口一闪即关、白屏、空白窗口
- 提示找不到 WebView2 / WebView2 Runtime
- 杀软或组策略拦截了运行时安装
### 1. 先确认是否已安装
**PowerShell** 中执行:
```powershell
# Evergreen Runtime 常见安装目录64 位系统)
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
# 注册表(有显示名称通常表示已安装)
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
-ErrorAction SilentlyContinue |
Select-Object pv, name
```
若路径不存在且注册表无 `pv` 版本号,需要安装运行时。
### 2. 有外网:在线安装(最简单)
1. 打开微软官方下载页:
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
2. 下载 **Evergreen Bootstrapper**(体积小,安装时联网拉完整运行时)
3. 右键「以管理员身份运行」安装完成后,**重新打开 GoNavi**
### 3. 内网 / 离线:独立安装包(推荐企业分发)
Bootstrapper 在完全断网环境会失败。请改用 **Evergreen Standalone Installer**
1. 在一台能上网的电脑打开同一下载页,下载对应架构的独立安装包,例如:
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe`(绝大多数 64 位 Windows
- `…X86.exe` / `…ARM64.exe`(按机器架构选择)
2. 将安装包拷贝到内网U 盘、软件中心、文件共享均可)
3. 在目标机 **管理员权限** 安装:
```powershell
# 交互安装
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
# 静默安装(便于 IT 批量推送)
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
```
4. 安装完成后重新启动 GoNavi。若仍异常注销/重启一次 Windows 再试。
### 4. 组策略 / 权限受限时
- 需要本地管理员或由 IT 通过 SCCM / 软件中心推送 Standalone 安装包
- 确认未禁用 Edge / WebView2 相关更新与安装策略
- 企业可固定使用 [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) 运行时并由管理员统一维护(一般用户优先 Evergreen
### 5. 临时绕过Web Server 模式(仍需能跑 GoNavi 后端)
若桌面 WebView 短期无法装上,可在本机用浏览器访问实验性 Web Server**不是**把桌面窗口容器化):
```powershell
# 以发布包中的可执行文件为例
.\GoNavi.exe web-server --addr 127.0.0.1:34116
```
浏览器打开 `http://127.0.0.1:34116`。详见上文「Web Server」章节。
注意:未加固的 Web 入口不要直接暴露到公网。
更多说明与反馈入口见 Issue[#672](https://github.com/Syngnat/GoNavi/issues/672)。
</details>
<details>
<summary><b>macOS提示「应用已损坏无法打开」</b></summary>
未做 Apple Notarization 时Gatekeeper 可能拦截:
```bash
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
```
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
也可在 Finder 中右键打开。建议先移到「应用程序」。
</details>
<details>
<summary><b>Linux缺少 WebKitGTK</b></summary>
```bash
# Debian 13 / Ubuntu 24.04+
@@ -197,9 +423,12 @@ sudo apt-get update
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
```
### Linux 中文显示为方框
`-WebKit41` 后缀的产物更适配 Debian 13 / Ubuntu 24.04+。
Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi
</details>
<details>
<summary><b>Linux中文显示为方框</b></summary>
```bash
sudo apt-get update
@@ -207,19 +436,31 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
fc-cache -fv
```
</details>
---
## 贡献指南
## 🤝 贡献
欢迎提交 Issue 与 Pull Request
完整流程、分支模型与维护者同步规则请查看:
欢迎 Issue 与 PR。请从 **`dev`** 拉分支,并向 **`dev`** 提 PR
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
- 共建邀请:[Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
---
## Star History (Star 增长趋势)
## 💬 联系与反馈
| 渠道 | 说明 |
|---|---|
| **GitHub Issues** | Bug、功能建议、文档问题优先在此提交便于跟踪与复现 |
| **微信** | `ygf1140302783`备注「GoNavi」— 可拉交流群,适合使用讨论与快速沟通 |
> 技术问题尽量附版本号、系统与复现步骤;内网 Windows 启动白屏/闪退见 [WebView2 说明](https://github.com/Syngnat/GoNavi/issues/672)。
---
## ⭐ Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
@@ -229,6 +470,8 @@ fc-cache -fv
</picture>
</a>
---
## 友情链接
- [linux.do](https://linux.do/)
@@ -236,4 +479,8 @@ fc-cache -fv
## 开源协议
本项目采用 [Apache-2.0 协议](LICENSE)
[Apache-2.0](LICENSE)
<p align="center">
<sub>为泡在 SQL、表结构与 Agent 循环里的人而造。</sub>
</p>

BIN
assets/readme-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch)
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch trino)
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"
@@ -44,7 +44,7 @@ normalize_driver() {
open_gauss|open-gauss) echo "opengauss" ;;
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
elasticsearch|elastic) echo "elasticsearch" ;;
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
echo "$name"
;;
*)

View File

@@ -355,6 +355,9 @@ if command -v sha256sum &> /dev/null; then
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
case "$f" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
sha256sum "$f" >> SHA256SUMS
done
cd ..
@@ -363,6 +366,9 @@ elif command -v shasum &> /dev/null; then
: > SHA256SUMS
for f in *; do
[ -f "$f" ] || continue
case "$f" in
SHA256SUMS|latest.json|latest-dev.json) continue ;;
esac
shasum -a 256 "$f" >> SHA256SUMS
done
cd ..
@@ -370,6 +376,35 @@ else
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum跳过校验文件生成。${NC}"
fi
echo -e "${GREEN}📄 生成静态更新清单...${NC}"
if command -v python3 &> /dev/null; then
# 正式版latest.jsondev/test 版本latest-dev.json对应客户端 dev 通道)
case "$VERSION" in
dev-*|*-dev*|*dev*|*test*)
TAG_FOR_MANIFEST="dev-latest"
CHANNEL_FOR_MANIFEST="dev"
OUT_MANIFEST="$DIST_DIR/latest-dev.json"
;;
*)
TAG_FOR_MANIFEST="v${VERSION#v}"
CHANNEL_FOR_MANIFEST="latest"
OUT_MANIFEST="$DIST_DIR/latest.json"
;;
esac
if python3 tools/generate-update-latest-manifest.py \
--assets-dir "$DIST_DIR" \
--version "$VERSION" \
--tag "$TAG_FOR_MANIFEST" \
--channel "$CHANNEL_FOR_MANIFEST" \
--output "$OUT_MANIFEST"; then
echo -e "${GREEN} ✅ 已生成 $OUT_MANIFEST (channel=${CHANNEL_FOR_MANIFEST})${NC}"
else
echo -e "${YELLOW} ⚠️ 生成更新清单失败(不影响本地产物,正式/dev 发版由 CI 生成)${NC}"
fi
else
echo -e "${YELLOW} ⚠️ 未找到 python3跳过更新清单${NC}"
fi
echo ""
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 85 KiB

100
build/windows/installer.wxs Normal file
View File

@@ -0,0 +1,100 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package Name="$(var.ProductName)"
Manufacturer="Syngnat"
Language="1033"
Version="$(var.ProductVersion)"
ProductCode="*"
UpgradeCode="$(var.UpgradeCode)"
Scope="perMachine"
InstallerVersion="500"
Compressed="yes">
<MajorUpgrade AllowDowngrades="yes"
Schedule="afterInstallInitialize" />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="GoNaviIcon" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="GoNaviIcon" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/Syngnat/GoNavi" />
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" />
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseRtf)" />
<Feature Id="MainFeature" Title="$(var.ProductName)" Level="1">
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="DesktopShortcutComponent" />
</Feature>
<Component Id="MainExecutable"
Directory="INSTALLFOLDER"
Guid="$(var.ComponentGuid)"
Bitness="always64">
<File Id="GoNaviExe"
Source="$(var.SourceExe)"
Name="GoNavi.exe"
KeyPath="yes">
<!-- Keep shortcut icons bound to the stable executable path. Referencing the MSI Icon table
can leave Windows shell caches pointing at the removed product cache after a major upgrade. -->
<Shortcut Id="StartMenuShortcut"
Directory="ProgramMenuFolder"
Name="$(var.ProductName)"
WorkingDirectory="INSTALLFOLDER"
Advertise="no" />
</File>
<File Id="GoNaviMsiInstallMarker"
Source="$(var.InstallMarker)"
Name=".gonavi-msi-install" />
<File Id="GoNaviLicense"
Source="$(var.LicenseFile)"
Name="LICENSE.txt" />
<File Id="GoNaviNotice"
Source="$(var.NoticeFile)"
Name="NOTICE.txt" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="InstallType"
Value="MSI"
Type="string"
KeyPath="no" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="InstallPath"
Value="[INSTALLFOLDER]GoNavi.exe"
Type="string"
KeyPath="no" />
</Component>
<!-- Online updates pass the user's pre-update desktop shortcut state. Keeping this resource
in its own component lets a major upgrade remove an unwanted old shortcut without
recreating it, while first-time and manual installs retain the existing default. -->
<Component Id="DesktopShortcutComponent"
Directory="DesktopFolder"
Guid="$(var.DesktopShortcutComponentGuid)"
Bitness="always64"
Condition="INSTALLDESKTOPSHORTCUT = 1">
<Shortcut Id="DesktopShortcut"
Name="$(var.ProductName)"
Target="[#GoNaviExe]"
WorkingDirectory="INSTALLFOLDER"
Advertise="no" />
<RegistryValue Root="HKLM"
Key="Software\Syngnat\$(var.RegistryKeyName)"
Name="DesktopShortcutInstalled"
Value="1"
Type="integer"
KeyPath="yes" />
</Component>
<ui:WixUI Id="WixUI_InstallDir"
InstallDirectory="INSTALLFOLDER"
ExtendedPathValidation="yes" />
</Package>
<Fragment>
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="$(var.InstallFolderName)" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
</Fragment>
</Wix>

View File

@@ -10,6 +10,13 @@
- 入参:`connectionId`
- `get_tables`
- 入参:`connectionId`、可选 `dbName`
- 返回表列表,并在 `views` 字段附带视图列表,兼容旧客户端只调用 `get_tables` 的场景
- `get_views`
- 入参:`connectionId`、可选 `dbName`
- 返回视图列表
- `get_objects`
- 入参:`connectionId`、可选 `dbName`、可选 `objectTypes`
- 返回表、视图、触发器、函数、过程、序列、包、事件,以及消息队列类 `topic/queue/exchange` 等对象清单
- `get_columns`
- 入参:`connectionId`、可选 `dbName``tableName`
- `get_table_ddl`
@@ -20,7 +27,7 @@
- 如果 SQL 包含 DDL/DML必须显式传 `allowMutating=true`
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
远程 Agent 只需要结构元数据时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、对象清单、表/视图、字段、索引、外键、触发器和 DDL 工具。
## 运行方式
@@ -42,6 +49,187 @@ go run ./cmd/gonavi-mcp-server stdio
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
```
## Docker / Podman / Compose
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含 Wails 桌面 GUI。
当前支持矩阵:
- Docker Desktop / Linux 服务器 / NAS直接使用 Compose 或 `docker run`
- Podman / Quadlet使用 `deploy/podman/gonavi-mcp-server`
- Kubernetes使用 `deploy/k8s/gonavi-mcp-server`
- Helm使用 `deploy/helm/gonavi-mcp-server`
- 仅构建环境:使用仓库根目录 `Dockerfile.build-env`
- 桌面 GUI 浏览器访问版:当前不提供,此仓库主应用仍是 Wails 桌面程序,不是现成的 Web 服务
仓库根目录已提供以下文件:
- `Dockerfile.mcp-server`
- `docker-compose.mcp-server.yml`
- `docker.mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/*`
推荐流程:
```bash
cp docker.mcp-server.env.example docker.mcp-server.env
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
```
默认 Compose 会拉取 GHCR 预构建镜像。如果你要基于当前工作区源码本地构建,再叠加:
```bash
docker compose --env-file docker.mcp-server.env \
-f docker-compose.mcp-server.yml \
-f docker-compose.mcp-server.local.yml \
up -d --build
```
其中 `GONAVI_HOST_DATA_ROOT` 必须指向 GoNavi 当前活动数据目录。该目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
- `drivers/`(如果目标连接依赖可选 driver agent
容器内默认会设置:
- `GONAVI_DATA_ROOT=/data`
- `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765`
- `GONAVI_MCP_HTTP_PATH=/mcp`
`GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。
如果你只想手动构建镜像:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
gonavi-mcp-server:local http
```
如果你直接使用已发布镜像:
```bash
docker run --rm -p 8765:8765 \
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
-e GONAVI_MCP_SCHEMA_ONLY=true \
-e GONAVI_DATA_ROOT=/data \
-v /absolute/path/to/gonavi-data:/data \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
### Podman
仓库内还提供了 Podman 原生部署样例:
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example`
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container`
- `deploy/podman/gonavi-mcp-server/README.md`
直接运行已发布镜像:
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
如果你要基于当前源码本地构建:
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
其中:
- `gonavi-mcp-server.env``deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example` 初始化
- `:Z` 适用于开启 SELinux 的宿主机;未启用 SELinux 可去掉
- 更适合长期运行的方式见 [deploy/podman/gonavi-mcp-server/README.md](../../deploy/podman/gonavi-mcp-server/README.md) 中的 Quadlet 示例
`podman compose` 本身依赖外部 compose provider所以仓库对 Podman 的主支持路径是 `podman run` 与 Quadlet而不是假设所有环境都能直接复用 Compose。
## Kubernetes
仓库内置了最小 K8s 示例:
- `deploy/k8s/gonavi-mcp-server/kustomization.yaml`
- `deploy/k8s/gonavi-mcp-server/base/deployment.yaml`
- `deploy/k8s/gonavi-mcp-server/base/service.yaml`
- `deploy/k8s/gonavi-mcp-server/README.md`
- `deploy/k8s/gonavi-mcp-server/overlays/*`
推荐先从现有 GoNavi 数据目录生成 Secret
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果需要 NAS hostPath、可选 driver agent PVC、Ingress或两者组合可直接使用 `overlays/nas-hostpath``overlays/drivers-pvc``overlays/ingress``overlays/ingress-with-drivers-pvc`
更完整的说明见 [deploy/k8s/gonavi-mcp-server/README.md](../../deploy/k8s/gonavi-mcp-server/README.md)。
## Helm
如果你希望把镜像、Secret、Ingress、hostPath / PVC 挂载做成参数化部署,而不是维护多份 Kustomize overlay可直接使用
- `deploy/helm/gonavi-mcp-server`
快速安装:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
Chart 详细说明见 [deploy/helm/gonavi-mcp-server/README.md](../../deploy/helm/gonavi-mcp-server/README.md)。
## Docker / Podman Build Environment
如果你的目标不是运行 MCP而是给 Linux 服务器 / NAS / CI 准备一套可重复的 Wails 构建环境,可直接使用仓库根目录的 `Dockerfile.build-env`
```bash
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
```
如果你使用 Podman也可以直接执行
```bash
podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .
podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash
```
镜像内已预装 Go、Node、Wails CLI、GTK3 与 WebKitGTK 开发依赖,适合执行:
```bash
wails build
```
这个镜像默认安装 WebKitGTK 4.0 构建依赖,适合作为通用 Linux / NAS 构建环境。镜像基座支持多架构,`amd64` / `arm64` 会跟随容器平台。
预构建镜像会发布到 GHCR
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
- `ghcr.io/syngnat/gonavi-build-env:latest`
它只负责构建 Linux 产物,不会把 Wails 主程序变成浏览器版服务。
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token
```powershell
@@ -121,7 +309,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent不能直接
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
4. 在 OpenClaw / Hermans 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_tables``get_columns``get_table_ddl` 等工具读取结构。
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases``get_objects``get_tables``get_views``get_columns``get_table_ddl` 等工具读取结构。
如果目标 Agent 支持 `mcpServers` JSON可按下面的通用片段配置

View File

@@ -0,0 +1,7 @@
apiVersion: v2
name: gonavi-mcp-server
description: Helm chart for deploying GoNavi MCP Server
type: application
version: 0.1.0
appVersion: "latest"
kubeVersion: ">=1.24.0-0"

View File

@@ -0,0 +1,94 @@
# GoNavi MCP Server Helm Chart
这个 Chart 用来部署 `gonavi-mcp-server`,覆盖当前仓库 K8s 示例里的几类常见场景:
- 基于现有 Secret 直接部署
- 通过 Helm values 内联创建测试用 Secret
- 启用 Ingress
- 追加 `drivers/` PVC
- NAS / k3s 单机节点直接挂宿主机数据目录
Chart 目录:
- `Chart.yaml`
- `values.yaml`
- `values-examples/*.yaml`
- `templates/*.yaml`
## 1. 默认安装
默认值假设你已经有一个现成 Secret
- 名称:`gonavi-mcp-server-data`
- 至少包含:
- `connections.json`
- `daily_secrets.json`
- `GONAVI_MCP_HTTP_TOKEN`
安装命令:
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
```
## 2. 直接创建测试用 Secret
如果只是本地验证模板或快速试跑,可以使用示例 values
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/inline-secret.yaml
```
这个示例只适合最小联调,不适合真实生产连接数据。
## 3. 常用示例
启用 Ingress
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
```
挂载 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/drivers-pvc.yaml
```
同时启用 Ingress 与 drivers PVC
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/ingress-with-drivers-pvc.yaml
```
NAS / k3s hostPath
```bash
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
-n gonavi --create-namespace \
-f deploy/helm/gonavi-mcp-server/values-examples/nas-hostpath.yaml
```
## 4. 关键参数
- `image.repository` / `image.tag`
- `secret.create`
- `secret.name`
- `data.mode=secret|hostPath|pvc`
- `drivers.mode=none|pvc|hostPath`
- `ingress.enabled`
## 5. 约束
- `data.mode=secret`Secret 必须能提供 `connections.json``daily_secrets.json`
- `data.mode=hostPath` 时,宿主机目录需要包含 GoNavi 活动数据目录内容
- `drivers.mode!=none` 时,会在 `/data/drivers` 追加独立挂载
- Chart 默认只部署 MCP Server不部署桌面 GUI

View File

@@ -0,0 +1,49 @@
{{- define "gonavi-mcp-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "gonavi-mcp-server.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := include "gonavi-mcp-server.name" . -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
{{- end -}}
{{- define "gonavi-mcp-server.labels" -}}
helm.sh/chart: {{ include "gonavi-mcp-server.chart" . }}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "gonavi-mcp-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "gonavi-mcp-server.namespace" -}}
{{- default .Release.Namespace .Values.namespaceOverride -}}
{{- end -}}
{{- define "gonavi-mcp-server.secretName" -}}
{{- if .Values.secret.name -}}
{{- .Values.secret.name -}}
{{- else -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}
{{- end -}}
{{- define "gonavi-mcp-server.serviceName" -}}
{{- include "gonavi-mcp-server.fullname" . -}}
{{- end -}}

View File

@@ -0,0 +1,130 @@
{{- $dataMode := .Values.data.mode -}}
{{- $driversMode := .Values.drivers.mode -}}
{{- if not (or (eq $dataMode "secret") (eq $dataMode "hostPath") (eq $dataMode "pvc")) -}}
{{- fail "data.mode must be one of: secret, hostPath, pvc" -}}
{{- end -}}
{{- if not (or (eq $driversMode "none") (eq $driversMode "pvc") (eq $driversMode "hostPath")) -}}
{{- fail "drivers.mode must be one of: none, pvc, hostPath" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 8 }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: gonavi-mcp-server
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- http
{{- range .Values.mcp.extraArgs }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: {{ .Values.mcp.dataRoot | quote }}
- name: GONAVI_LOG_DIR
value: {{ .Values.mcp.logDir | quote }}
- name: GONAVI_MCP_HTTP_ADDR
value: {{ .Values.mcp.httpAddr | quote }}
- name: GONAVI_MCP_HTTP_PATH
value: {{ .Values.mcp.httpPath | quote }}
- name: GONAVI_MCP_SCHEMA_ONLY
value: {{ ternary "true" "false" .Values.mcp.schemaOnly | quote }}
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "gonavi-mcp-server.secretName" . }}
key: {{ .Values.secret.tokenKey }}
{{- with .Values.mcp.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: gonavi-data
mountPath: {{ .Values.data.mountPath | quote }}
readOnly: true
{{- if ne $driversMode "none" }}
- name: gonavi-drivers
mountPath: {{ .Values.drivers.mountPath | quote }}
readOnly: true
{{- end }}
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
volumes:
- name: gonavi-data
{{- if eq $dataMode "secret" }}
projected:
sources:
- secret:
name: {{ include "gonavi-mcp-server.secretName" . }}
items:
- key: {{ .Values.secret.connectionsJsonKey }}
path: connections.json
- key: {{ .Values.secret.dailySecretsJsonKey }}
path: daily_secrets.json
{{- else if eq $dataMode "hostPath" }}
hostPath:
path: {{ required "data.hostPath.path is required when data.mode=hostPath" .Values.data.hostPath.path | quote }}
type: {{ default "Directory" .Values.data.hostPath.type | quote }}
{{- else if eq $dataMode "pvc" }}
persistentVolumeClaim:
claimName: {{ required "data.pvc.claimName is required when data.mode=pvc" .Values.data.pvc.claimName | quote }}
{{- end }}
{{- if eq $driversMode "pvc" }}
- name: gonavi-drivers
persistentVolumeClaim:
claimName: {{ required "drivers.pvc.claimName is required when drivers.mode=pvc" .Values.drivers.pvc.claimName | quote }}
{{- else if eq $driversMode "hostPath" }}
- name: gonavi-drivers
hostPath:
path: {{ required "drivers.hostPath.path is required when drivers.mode=hostPath" .Values.drivers.hostPath.path | quote }}
type: {{ default "Directory" .Values.drivers.hostPath.type | quote }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,34 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "gonavi-mcp-server.fullname" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className | quote }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path | quote }}
pathType: {{ .pathType | quote }}
backend:
service:
name: {{ include "gonavi-mcp-server.serviceName" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,27 @@
{{- if .Values.secret.create }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "gonavi-mcp-server.secretName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
type: Opaque
stringData:
{{ .Values.secret.tokenKey }}: {{ required "secret.stringData.token is required when secret.create=true" .Values.secret.stringData.token | quote }}
{{- if eq .Values.data.mode "secret" }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ required "secret.stringData.connectionsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.connectionsJson | nindent 4 }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ required "secret.stringData.dailySecretsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- else }}
{{- if .Values.secret.stringData.connectionsJson }}
{{ .Values.secret.connectionsJsonKey }}: |
{{ .Values.secret.stringData.connectionsJson | nindent 4 }}
{{- end }}
{{- if .Values.secret.stringData.dailySecretsJson }}
{{ .Values.secret.dailySecretsJsonKey }}: |
{{ .Values.secret.stringData.dailySecretsJson | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "gonavi-mcp-server.serviceName" . }}
namespace: {{ include "gonavi-mcp-server.namespace" . }}
labels:
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP

View File

@@ -0,0 +1,4 @@
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -0,0 +1,19 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
drivers:
mode: pvc
pvc:
claimName: gonavi-drivers

View File

@@ -0,0 +1,14 @@
ingress:
enabled: true
className: nginx
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com

View File

@@ -0,0 +1,13 @@
secret:
create: true
stringData:
token: replace-with-a-random-token
connectionsJson: |
{
"connections": []
}
dailySecretsJson: |
{
"schemaVersion": 1,
"connections": {}
}

View File

@@ -0,0 +1,10 @@
data:
mode: hostPath
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
secret:
create: true
stringData:
token: replace-with-a-random-token

View File

@@ -0,0 +1,99 @@
nameOverride: ""
fullnameOverride: ""
namespaceOverride: ""
replicaCount: 1
image:
repository: ghcr.io/syngnat/gonavi-mcp-server
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8765
annotations: {}
mcp:
dataRoot: /data
logDir: /var/lib/gonavi/logs
httpAddr: 0.0.0.0:8765
httpPath: /mcp
schemaOnly: true
extraArgs: []
extraEnv: []
secret:
create: false
name: gonavi-mcp-server-data
tokenKey: GONAVI_MCP_HTTP_TOKEN
connectionsJsonKey: connections.json
dailySecretsJsonKey: daily_secrets.json
stringData:
token: ""
connectionsJson: ""
dailySecretsJson: ""
data:
mode: secret
mountPath: /data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory
pvc:
claimName: ""
drivers:
mode: none
mountPath: /data/drivers
pvc:
claimName: gonavi-drivers
hostPath:
path: /volume1/docker/gonavi/drivers
type: Directory
ingress:
enabled: false
className: nginx
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
hosts:
- host: gonavi-mcp.example.com
paths:
- path: /mcp
pathType: Prefix
- path: /healthz
pathType: Prefix
tls:
- secretName: gonavi-mcp-server-tls
hosts:
- gonavi-mcp.example.com
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,151 @@
# GoNavi MCP Server Kubernetes 示例
这个目录提供 `gonavi-mcp-server` 的最小 K8s 部署清单,适合:
- Linux 服务器上的单集群部署
- NAS 自带 K8s / k3s / k8s-lite 环境
- 需要把 GoNavi MCP 通过 Ingress / Gateway 暴露给远端 Agent 的场景
如果你希望通过 values 参数统一控制镜像、Secret、Ingress、PVC/hostPath而不是维护多份 overlay请直接使用 [deploy/helm/gonavi-mcp-server](../../helm/gonavi-mcp-server)。
目录结构:
- `kustomization.yaml`:基础部署入口
- `base/kustomization.yaml`:基础资源集合
- `overlays/nas-hostpath`:直接挂 NAS / 单机节点上的 GoNavi 数据目录
- `base/deployment.yaml` / `base/service.yaml`:基础资源
- `overlays/drivers-pvc`:为 `/data/drivers` 增加 PVC 挂载
- `overlays/ingress`:增加 Ingress 暴露 `/mcp`
- `overlays/ingress-with-drivers-pvc`:同时启用 Ingress 和 drivers PVC
## 前提
容器仍然依赖 GoNavi 的活动数据目录。至少要准备:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还需要额外挂载 `/data/drivers`
`base/deployment.yaml` 默认镜像指向:
```text
ghcr.io/syngnat/gonavi-mcp-server:latest
```
如果你要跟随 `dev` 分支,可以改成:
```text
ghcr.io/syngnat/gonavi-mcp-server:dev-latest
```
如果你要使用自建镜像,再按下面方式覆盖 `image`
- 在当前节点可访问的 Docker / Podman / containerd 环境中构建并导入这个镜像
- 把镜像推送到你的私有仓库,并同步修改 `base/deployment.yaml` 里的 `image`
例如:
```bash
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
# or
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
```
## 1. 创建 Secret
推荐直接从现有 GoNavi 数据文件生成:
```bash
kubectl create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
如需单独 namespace请先执行
```bash
kubectl create namespace gonavi
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
```
## 2. 应用清单
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
如果使用独立 namespace请先把 YAML 里的 `namespace` 改成你的目标值,或自行用 `kustomize` / Helm 做二次封装。
清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`
### 常用 Overlay
仅基础部署:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server
```
需要可选 driver agent
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/drivers-pvc
```
NAS / k3s 单机节点直接挂宿主机目录:
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/nas-hostpath
```
需要对外暴露 `/mcp`
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress
```
同时需要 Ingress 与 drivers PVC
```bash
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress-with-drivers-pvc
```
应用 overlay 前,记得先改里面的占位值,例如:
- `overlays/ingress/ingress.yaml` 里的域名、TLS Secret、`ingressClassName`
- `overlays/drivers-pvc/patch-deployment.yaml` 里的 PVC 名称 `gonavi-drivers`
- `overlays/nas-hostpath/patch-deployment.yaml` 里的宿主机目录 `/volume1/docker/gonavi/data`
`nas-hostpath` overlay 会直接把整个 GoNavi 数据目录挂到 `/data`,因此 `connections.json``daily_secrets.json``drivers/` 都从宿主机目录读取;此时仍建议保留 `GONAVI_MCP_HTTP_TOKEN` 的 Secret 注入,不要把 token 直接硬编码进 Deployment。
## 3. 校验
```bash
kubectl get pods -n gonavi
kubectl get svc -n gonavi
kubectl port-forward -n gonavi svc/gonavi-mcp-server 8765:8765
curl -H "Authorization: Bearer replace-with-a-random-token" http://127.0.0.1:8765/healthz
```
`/healthz` 返回 `ok` 说明 Pod 已对外提供 HTTP 服务。
## 4. 暴露给远端 Agent
- 集群内使用:直接访问 `http://gonavi-mcp-server.gonavi.svc.cluster.local:8765/mcp`
- 集群外使用:通过 Ingress / Gateway / 反向代理暴露 `/mcp`
- 远端 Agent 请求头:`Authorization: Bearer <你的 token>`
## 5. drivers 挂载
当前示例默认只挂载 `connections.json``daily_secrets.json`。如果保存连接里包含 Dameng、ClickHouse、DuckDB 等依赖 driver agent 的数据源,请在 Deployment 中额外补一个卷,把宿主机或 PVC 中的 `drivers/` 目录挂载到:
```text
/data/drivers
```
否则对应连接会因为缺少 driver agent 无法使用。

View File

@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: gonavi-mcp-server
template:
metadata:
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: gonavi-mcp-server
# 如需跟随 dev 分支,可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
image: ghcr.io/syngnat/gonavi-mcp-server:latest
imagePullPolicy: IfNotPresent
args:
- http
ports:
- name: http
containerPort: 8765
protocol: TCP
env:
- name: GONAVI_DATA_ROOT
value: /data
- name: GONAVI_LOG_DIR
value: /var/lib/gonavi/logs
- name: GONAVI_MCP_HTTP_ADDR
value: 0.0.0.0:8765
- name: GONAVI_MCP_HTTP_PATH
value: /mcp
- name: GONAVI_MCP_SCHEMA_ONLY
value: "true"
- name: GONAVI_MCP_HTTP_TOKEN
valueFrom:
secretKeyRef:
name: gonavi-mcp-server-data
key: GONAVI_MCP_HTTP_TOKEN
volumeMounts:
- name: gonavi-data
mountPath: /data
readOnly: true
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumes:
- name: gonavi-data
projected:
sources:
- secret:
name: gonavi-mcp-server-data
items:
- key: connections.json
path: connections.json
- key: daily_secrets.json
path: daily_secrets.json

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: gonavi-mcp-server
namespace: gonavi
labels:
app.kubernetes.io/name: gonavi-mcp-server
spec:
selector:
app.kubernetes.io/name: gonavi-mcp-server
ports:
- name: http
port: 8765
targetPort: http
protocol: TCP

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
containers:
- name: gonavi-mcp-server
volumeMounts:
- name: gonavi-drivers
mountPath: /data/drivers
readOnly: true
volumes:
- name: gonavi-drivers
persistentVolumeClaim:
claimName: gonavi-drivers

View File

@@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gonavi-mcp-server
namespace: gonavi
annotations:
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- gonavi-mcp.example.com
secretName: gonavi-mcp-server-tls
rules:
- host: gonavi-mcp.example.com
http:
paths:
- path: /mcp
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765
- path: /healthz
pathType: Prefix
backend:
service:
name: gonavi-mcp-server
port:
number: 8765

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
- ingress.yaml

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch-deployment.yaml

View File

@@ -0,0 +1,14 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gonavi-mcp-server
namespace: gonavi
spec:
template:
spec:
volumes:
- $patch: replace
name: gonavi-data
hostPath:
path: /volume1/docker/gonavi/data
type: Directory

View File

@@ -0,0 +1,101 @@
# GoNavi MCP Server Podman 示例
这个目录提供 `gonavi-mcp-server` 的 Podman 原生部署入口,覆盖两类常见场景:
- 直接用 `podman run` 在 Linux 服务器 / NAS 上启动
- 用 rootless Quadlet + systemd 做常驻服务
目录内容:
- `gonavi-mcp-server.env.example`:给 `podman run` / Quadlet 共用的容器环境变量示例
- `gonavi-mcp-server.container`Quadlet 示例
前提仍然一样:宿主机 GoNavi 活动数据目录内至少应包含:
- `connections.json`
- `daily_secrets.json`
如果目标连接依赖可选 driver agent还要保证同一数据目录下已有 `drivers/`
## 1. 直接运行已发布镜像
```bash
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
```
`GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行:
```bash
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
ghcr.io/syngnat/gonavi-mcp-server:latest http
```
查看状态:
```bash
podman ps
podman logs -f gonavi-mcp-server
```
如果你的宿主机没有启用 SELinux可把挂载参数末尾的 `:Z` 去掉;如果启用了 SELinux建议保留它。
## 2. 本地源码构建镜像
仓库根目录已经提供 `.containerignore`Podman 会优先读取它来裁剪构建上下文。
```bash
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
podman run -d --name gonavi-mcp-server --replace \
-p 8765:8765 \
--env-file ./gonavi-mcp-server.env \
-v /absolute/path/to/gonavi-data:/data:Z \
localhost/gonavi-mcp-server:local http
```
## 3. Rootless Quadlet 常驻服务
Podman Quadlet 适合 Linux 服务器 / NAS 上做 rootless 常驻服务。
把文件放到用户级目录:
```bash
mkdir -p ~/.config/containers/systemd
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container ~/.config/containers/systemd/
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ~/.config/containers/systemd/gonavi-mcp-server.env
```
然后修改两处:
- `~/.config/containers/systemd/gonavi-mcp-server.container` 里的 `Volume=/absolute/path/to/gonavi-data:/data:Z`
- `~/.config/containers/systemd/gonavi-mcp-server.env` 里的 `GONAVI_MCP_HTTP_TOKEN`
启动服务:
```bash
systemctl --user daemon-reload
systemctl --user enable --now gonavi-mcp-server.service
systemctl --user status gonavi-mcp-server.service
journalctl --user -u gonavi-mcp-server.service -f
```
如果你希望用户退出登录后服务仍然保持运行,再执行:
```bash
loginctl enable-linger "$USER"
```
Rootless Quadlet 默认搜索路径是 `~/.config/containers/systemd/`;如果你要做系统级 rootful 部署,可改放到 `/etc/containers/systemd/`
## 4. 关于 Compose
Podman 的 `podman compose` 依赖外部 compose provider。也就是说是否能直接复用仓库根目录的 `docker-compose.mcp-server.yml`,取决于你的 Podman 环境是否已经安装并配置了对应 provider。
因此,这个仓库对 Podman 的主支持路径是:
- `podman run`
- Quadlet推荐长期运行
如果你的环境已经有可用的 compose provider再去复用根目录 Compose 文件即可。

View File

@@ -0,0 +1,22 @@
[Unit]
Description=GoNavi MCP Server (rootless Podman)
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=gonavi-mcp-server
Image=ghcr.io/syngnat/gonavi-mcp-server:latest
Pull=newer
EnvironmentFile=./gonavi-mcp-server.env
# 修改成你的 GoNavi 活动数据目录SELinux 主机建议保留 :Z
Volume=/absolute/path/to/gonavi-data:/data:Z
PublishPort=8765:8765
Exec=http
LogDriver=journald
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
# 容器内 GoNavi 活动数据根目录。通常不需要修改Volume= 已把宿主机目录挂到 /data。
GONAVI_DATA_ROOT=/data
# 日志目录。默认与镜像内目录保持一致。
GONAVI_LOG_DIR=/var/lib/gonavi/logs
# MCP HTTP 监听地址与路径。
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765
GONAVI_MCP_HTTP_PATH=/mcp
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -0,0 +1,7 @@
services:
gonavi-mcp-server:
build:
context: .
dockerfile: Dockerfile.mcp-server
image: ${GONAVI_MCP_IMAGE_LOCAL:-gonavi-mcp-server:local}
pull_policy: never

View File

@@ -0,0 +1,20 @@
services:
gonavi-mcp-server:
image: ${GONAVI_MCP_IMAGE:-ghcr.io/syngnat/gonavi-mcp-server:latest}
pull_policy: ${GONAVI_MCP_PULL_POLICY:-missing}
container_name: gonavi-mcp-server
restart: unless-stopped
ports:
- "${GONAVI_MCP_HTTP_PORT:-8765}:8765"
environment:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765
GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp}
GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN}
GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}
target: /data
command: ["http"]

View File

@@ -0,0 +1,7 @@
services:
gonavi-web-server:
build:
context: .
dockerfile: Dockerfile.web-server
image: ${GONAVI_WEB_IMAGE_LOCAL:-gonavi-web-server:local}
pull_policy: never

View File

@@ -0,0 +1,24 @@
services:
gonavi-web-server:
image: ${GONAVI_WEB_IMAGE:-ghcr.io/syngnat/gonavi-web-server:latest}
pull_policy: ${GONAVI_WEB_PULL_POLICY:-missing}
container_name: gonavi-web-server
restart: unless-stopped
ports:
- "${GONAVI_WEB_HTTP_PORT:-34116}:34116"
environment:
GONAVI_DATA_ROOT: /data
GONAVI_LOG_DIR: /var/lib/gonavi/logs
GONAVI_WEB_ADDR: 0.0.0.0:34116
GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}
volumes:
- type: bind
source: ${GONAVI_HOST_DATA_ROOT}
target: /data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:34116/__gonavi/healthz"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
command: ["web-server"]

View File

@@ -0,0 +1,20 @@
# 宿主机 GoNavi 活动数据目录,目录内至少应包含 connections.json 与 daily_secrets.json
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
GONAVI_MCP_IMAGE=ghcr.io/syngnat/gonavi-mcp-server:latest
# 预构建镜像拉取策略always / missing / never
GONAVI_MCP_PULL_POLICY=missing
# 远程 MCP 客户端访问时必须携带的 Bearer Token
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
# 宿主机暴露端口
GONAVI_MCP_HTTP_PORT=8765
# MCP HTTP 路径
GONAVI_MCP_HTTP_PATH=/mcp
# true = 只暴露结构查询工具,不注册 execute_sql
GONAVI_MCP_SCHEMA_ONLY=true

View File

@@ -0,0 +1,19 @@
# 宿主机 GoNavi 活动数据目录。
# 建议至少包含connections.json、daily_secrets.json
# 如需可选驱动代理,一并挂载 drivers/。
# Web 认证状态会写入 web_auth.json未设置环境密码时首次访问 /setup 初始化。
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
# 预构建镜像地址。稳定版通常使用 latest跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-web-server:dev-latest
GONAVI_WEB_IMAGE=ghcr.io/syngnat/gonavi-web-server:latest
# 预构建镜像拉取策略always / missing / never
GONAVI_WEB_PULL_POLICY=missing
# 宿主机暴露端口(映射到容器 34116
GONAVI_WEB_HTTP_PORT=34116
# Web 管理员密码(至少 6 位)。留空时首次访问 /setup 初始化;公网部署请使用更强密码。
# 设置或修改后请执行 docker compose up -d --force-recreate请限制本文件的读取权限。
# 新部署如需启用 2FA请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。
GONAVI_WEB_PASSWORD=

View File

@@ -0,0 +1,355 @@
# Cmd/Ctrl+W 标签关闭快捷键设计
- 日期2026-07-20
- 状态:已确认,待实施
- 适用范围GoNavi 主窗口的工作区标签和 SQL 查询结果区标签
## 1. 背景
GoNavi 当前没有统一的 `Cmd+W` / `Ctrl+W` 应用内关闭语义。工作区标签由
`TabManager` 管理,查询结果标签由每个 `QueryEditor` 的本地状态管理,两套关闭
流程拥有不同的保护规则。若分别注册全局键盘监听,同一次按键可能同时关闭结果
标签和工作区标签。
本设计增加一个可配置动作 `closeActiveTab`,由唯一的全局入口根据用户最后一次
明确交互的区域,将关闭请求路由到结果区或工作区。
## 2. 目标
1. macOS 默认使用 `Meta+W`Windows/Linux 默认使用 `Ctrl+W`
2. 快捷键在设置中心可修改、启用或禁用。
3. 同一次按键最多执行一个关闭动作,结果区与工作区之间不允许穿透。
4. 复用现有关闭流程,保留未保存 SQL、运行中导入任务等保护。
5. 结果区日志标签保持常驻;日志标签活跃时,快捷键隐藏整个结果区。
6. 结果区被快捷键隐藏后,连续按键不得继续关闭工作区标签。
## 3. 非目标
1. 不新增“分别关闭结果标签”和“分别关闭工作区标签”两个可配置动作。
2. 不改变鼠标关闭按钮、中键关闭或右键批量关闭的产品语义。
3. 不关闭或清空 SQL 日志数据。
4. 不改变后台导出、同步任务在工作区标签关闭后的运行方式。
5. 不统一重构所有历史浮动窗口的关闭入口。
6. 不让主窗口快捷键关闭已经拆出的独立窗口标签或独立结果窗口。
## 4. 方案选择
### 4.1 采用方案:单一全局路由器
`App` 保持唯一的全局 `keydown` capture 监听入口,维护最后一次明确交互区域,
并向目标组件派发单一、明确的关闭命令。
采用该方案的原因:
- 键盘事件只匹配和消费一次,天然避免双重关闭。
- `TabManager``QueryEditor` 继续拥有各自的状态与保护逻辑。
- 路由状态可以独立测试,不需要把查询结果状态上移到全局 store。
- 后续自定义快捷键仍复用现有快捷键设置与冲突检测体系。
### 4.2 未采用方案
- 分区独立监听:工作区和结果区分别监听 `window.keydown`。该方案存在监听顺序、
冒泡阻断和同一 `window` 多监听器重复执行问题。
- 完全依赖 DOM 焦点Monaco、DataGrid、Ant Design Portal 和 `body` 级事件目标会
让焦点不足以可靠表达用户的区域意图。
## 5. 快捷键配置
### 5.1 动作定义
新增动作 `closeActiveTab`,纳入:
- `ShortcutAction`
- `SHORTCUT_ACTION_ORDER`
- `SHORTCUT_ACTION_META`
- `DEFAULT_SHORTCUT_OPTIONS`
- 快捷键设置界面和全部语言目录
动作允许在 Monaco、输入框和 DataGrid 等可编辑目标中触发,因为焦点位于编辑器
时仍应能关闭当前工作区标签。
默认绑定:
| 平台 | 组合键 | 默认状态 |
| --- | --- | --- |
| macOS | `Meta+W` | 启用 |
| Windows/Linux | `Ctrl+W` | 启用 |
GoNavi 正式接管该组合键,因此删除现有“浏览器关闭标签页”的 `Ctrl+W` 保留项;
其他浏览器、Monaco 和 DataGrid 保留项不变。
### 5.2 旧配置迁移
旧持久化配置中不存在 `closeActiveTab` 时,按平台分别迁移:
1. 若该平台没有任何已启用动作占用新默认组合键,补入启用的新动作。
2. 若已有动作占用 `Meta+W``Ctrl+W`,保留原动作及绑定,补入组合键相同但
`enabled: false` 的新动作。
3. 用户可在快捷键设置中修改冲突绑定并重新启用新动作。
4. “恢复默认值”和全新安装始终得到启用的默认绑定。
迁移只处理缺少新动作的旧配置,不覆盖用户已经保存的 `closeActiveTab` 设置。
禁用动作或把动作改绑到其他组合键后,默认 `Meta+W` / `Ctrl+W` 不再执行标签
关闭,但主窗口仍必须消费该平台原生关闭组合键,防止 WebView 把它解释为关闭应用
窗口或浏览器标签。
## 6. 路由状态模型
全局路由器维护 `CloseShortcutScope`
```ts
type CloseShortcutScope = 'workspace' | 'result' | 'blocked';
```
初始状态为 `workspace`
| 当前/触发条件 | 下一状态 | 含义 |
| --- | --- | --- |
| 用户点击或聚焦结果区 | `result` | 关闭请求只发送给当前查询结果区 |
| 用户点击或聚焦编辑器、工作区标签栏或侧边栏 | `workspace` | 关闭请求只发送给主工作区 |
| 快捷键在日志标签上隐藏结果区 | `blocked` | 后续重复按键只消费,不关闭任何标签 |
| `blocked` 后用户明确交互工作区 | `workspace` | 允许重新关闭工作区标签 |
| `blocked` 后用户明确交互重新显示的结果区 | `result` | 允许关闭结果标签或再次隐藏结果区 |
| 用户点击或聚焦独立结果窗口 | `blocked` | 独立窗口交互不得沿用后台工作区归属 |
SQL 执行后程序化展示结果、程序化切换结果标签、store 激活标签等行为不得修改
该状态。只有真实的 `pointerdown``focusin` 才能改变区域归属。
结果区根节点增加 `data-gonavi-close-shortcut-scope="result"`。路由器在 document
capture 阶段监听 `pointerdown``focusin`,通过该标记识别结果区;编辑器、
工作区标签栏和侧边栏归入工作区。
交互浮层与独立结果窗口使用稳定的
`data-gonavi-close-shortcut-guard="true"` 标记或统一 guard class。guard 包含当前可见
的模态框、抽屉、Dropdown、上下文菜单、Select/Picker 弹层、自建交互 Portal 和
`.gn-detached-result-window`。标记仅在浮层打开时处于 active 状态;若组件会保留
隐藏 DOM公共 helper 还必须检查元素的可见性。Tooltip、拖拽预览等不可获得输入
焦点的纯展示 Portal 不属于 guard。
普通浮层 guard 内的 `pointerdown``focusin` 不改变后台区域归属。关闭快捷键
来自 guard 内部时,只消费事件,不向工作区或结果区派发命令。
独立结果窗口是例外:其根节点同时标记
`data-gonavi-close-shortcut-scope="blocked"`。可信的 `pointerdown``focusin`
发生在窗口内部时,立即把路由 ref 写为 `blocked`。这覆盖标题栏拖动等不会夺取
后台 Monaco 焦点的交互;之后即使 `keydown.target` 仍是 Monaco也不得关闭主
工作区。只有用户再次明确交互主工作区或主结果区才恢复对应归属。
会遮挡后台交互的浮层另加
`data-gonavi-close-shortcut-blocks-background="true"`;只要此标记的节点当前可见,
即使键盘事件目标落在 `body`,也只消费、不关闭后台标签。模态框、抽屉、交互
Dropdown/上下文菜单、Select/Picker 弹层使用该标记。独立结果窗口只使用普通
guard不使用 background blocker避免窗口存在时永久禁用主工作区快捷键。
## 7. 键盘事件流
```text
全局 keydown capture
-> 不受 IME 过滤的底层组合键解析
-> 是否命中平台原生关闭键,或已启用的 closeActiveTab 绑定
-> 快捷键录制器优先接管
-> 命中平台原生关闭键时无条件 preventDefault
-> IME/guard 命中时只消费,不派发
-> 解析当前组合键的已启用动作 owner
其他动作 -> 交给原动作处理一次
closeActiveTab -> 读取 CloseShortcutScope 并路由
无 owner 且为平台原生关闭键 -> 消费为 no-op
```
事件处理约束:
1. `App` 是主窗口唯一的全局关闭快捷键入口。
2. 增加一个只做键位与修饰键比较、不会因 IME 状态返回 false 的底层 matcher。
它仅用于判断事件是否需要被应用接管;正常动作执行仍使用现有
`isShortcutMatch` 语义。
3. 路由器分别计算“平台原生关闭组合键”和“当前已启用动作绑定”的底层匹配,
同时按 `SHORTCUT_ACTION_ORDER` 解析当前组合键的已启用 owner。快捷键设置继续
保证同一平台不会新增多个启用 owner遇到历史重复数据时按现有动作顺序只取
第一个。
4. 快捷键录制器拥有优先权。录制期间全局路由器直接退出,由录制器负责
`preventDefault` 和保存绑定,不执行关闭动作。
5. 除录制器分支外,底层 matcher 一旦命中平台原生关闭组合键,必须立即
`preventDefault`,再判断 owner 和当前上下文。绑定 owner 存在但因可编辑目标、
当前标签类型或组件未挂载而无法执行时,动作可以 no-opWebView 默认关闭仍然
必须被抑制。
6. IME composing、全局 composition 状态或 keyCode/which 229 命中时,底层 matcher
仍能识别组合键并消费,但不得派发关闭。
7. 事件目标位于 guard 内,或存在可见的 background blocker 时,只消费、不关闭
后台标签。
8. 若 owner 是 `closeActiveTab`,才进入本设计的工作区/结果区路由。若 owner 是
其他历史动作,关闭路由不得抢占或改成 no-op全局动作继续由 App switch 执行,
查询编辑器动作继续交给其现有 owner并且整条链路只执行该动作一次。
9. 只有平台原生关闭键没有任何已启用 owner 时,才由保护分支消费为 no-op。
10. 关闭路由、IME/guard 保护和无 owner 的平台 no-op 在 `preventDefault` 后使用
`stopImmediatePropagation`,避免 WebView 或同一 `window` 上的其他监听器再次
处理。交给其他 owner 的事件不得在 owner 执行前被关闭路由
`stopImmediatePropagation`,但第 5 条的 `preventDefault` 仍已生效。
11. 一次事件只允许派发一个目标命令,不允许在目标无效时回退到另一区域。
12. `document.body``document.documentElement` 等文档级目标按已记录状态路由。
13. `closeActiveTab` 禁用或改绑,且没有其他已启用 owner 时,平台原生
`Meta+W` / `Ctrl+W` 仍被消费为 no-op自定义绑定只有在动作启用时才被接管。
14. 若真机验证发现 WebView 不向前端交付 `Cmd/Ctrl+W`,才增加 Wails 原生事件
fallbackfallback 必须汇入同一路由器并具备单次事件去重,不能形成第二套关闭
流程。
## 8. 工作区关闭契约
工作区命令由 `TabManager` 处理,目标必须是 `dockedActiveTabId`,不得使用全局
`activeTabId`。不存在主窗口活动标签时,命令为 no-op。
关闭必须复用:
```ts
closeTabsWithSQLFilePrompt(
[dockedActiveTabId],
() => closeTab(dockedActiveTabId),
);
```
由此保留:
- 外部 SQL 文件未保存时的保存、丢弃、取消确认。
- 外部 SQL 文件被移动或删除时的确认。
- 防止重复打开关闭确认。
- 运行中的 `data-import` 工作台关闭保护。
- store 已有的关闭后活动标签选择和关联资源清理。
`table-export``data-sync` 的任务状态独立于标签组件,仍允许关闭工作区标签并
在后台继续运行。
## 9. 结果区关闭契约
结果区命令只发送给主窗口当前活动的 `QueryEditor`,由它读取本地
`activeResultKey``resultSets`。组件提取一个共享的纯函数
`resolveEffectiveActiveResultKey`,供结果区渲染和快捷键命令共同使用,不能一方
读取原始 key、另一方使用视觉回退 key。
在支持嵌入 SQL 日志标签的结果区中,日志标签只要结果区可见就无条件存在,日志
数量为 0 时也显示;其 key 统一使用 `QUERY_EDITOR_SQL_LOG_TAB_KEY`
具体行为:
1. 当前键对应真实结果标签时,调用现有 `handleCloseResult`
2. 关闭后由现有逻辑选择相邻结果标签,不在路由器中重复计算。
3. 关闭最后一个真实结果标签后,活动状态明确切到日志 key结果区继续显示常驻
日志标签;即使历史状态中原始 key 为空,共享 resolver 也必须得到同一日志 key。
4. 当前键为日志标签时,不删除日志;隐藏整个结果区,并向路由器回报
`hidden`,使路由状态进入 `blocked`
5. 当前键缺失、过期或不属于真实结果与日志时,消费请求并回报 `ignored`,不得
回退关闭工作区。
6. 结果区已经隐藏时,结果命令为 no-op仍不得穿透到工作区。
结果命令需要一个明确的完成回报 `closed | hidden | ignored`。命令派发和回报必须
在当前 `keydown` 调用栈内同步完成,不使用 Promise 或等待 React render。可通过
同步 `dispatchEvent` 的可变 request detail 或等价的同步命令注册表返回 outcome。
路由状态以 ref/原子值为准,键盘监听不得读取可能滞后的 React state。收到
`hidden` 后,必须在监听器返回前写入 `scopeRef.current = 'blocked'``closed`
`ignored` 保持 `result`。因此即使同一 task 内连续派发两个 `keydown`,第二次也会
读到 `blocked`
结果命令同样读取 `resultSetsRef``activeResultKeyRef`。关闭动作在提交 React
state 更新的同时同步写入这两个 ref使同一 task 内的下一次命令能看到相邻结果
或日志 key而不是重复处理已经关闭的结果。
独立结果窗口不向主窗口发送工作区关闭命令。主窗口路由器也不控制独立结果窗口
的生命周期。
## 10. 错误与竞态处理
- 快速重复按键:工作区沿用关闭确认的互斥保护;结果区每次根据最新本地状态
处理。日志隐藏后立即切换为 `blocked`
- 结果标签关闭与查询完成并发:以命令处理时的最新 `activeResultKey` 为准;无效
key 返回 `ignored`
- 活动工作区标签在事件间切换:`TabManager` 在命令处理时读取最新
`dockedActiveTabId`
- 组件卸载:命令没有有效接收者时 no-op不向另一区域补发。
- 自定义绑定冲突:继续使用现有冲突提示;运行时不同时执行两个动作。
## 11. 测试设计
### 11.1 快捷键工具测试
- 新动作默认绑定、动作顺序、元信息和平台显示。
- 新动作允许在可编辑目标触发。
- `Ctrl+W` 不再报告浏览器保留冲突。
- 自定义绑定、禁用和恢复默认值。
- 动作禁用或改绑后,平台原生关闭组合键仍被消费但不关闭标签或窗口。
- 旧配置无冲突时自动启用新动作。
- 旧配置占用默认组合键时保留旧动作并禁用新动作,且按平台隔离。
- 迁移后按原生关闭组合键仍只执行原动作一次不执行关闭动作WebView 也不关闭。
- 历史 owner 因可编辑目标、非查询标签或组件未挂载而无法执行时,不执行任何动作,
但仍阻止 WebView 原生关闭。
- 所有语言包含新动作的 label/description。
### 11.2 路由器测试
- 初始状态关闭工作区,不触发结果区。
- 明确点击结果区后只关闭结果标签。
- 点击 Monaco、工作区标签栏或侧边栏后只关闭工作区标签。
- SQL 执行后程序化激活结果不抢占归属。
- 日志标签隐藏结果区并进入 `blocked`
- `blocked` 状态连续按键不关闭工作区;明确交互工作区后才恢复。
- 无效结果状态只消费,不穿透工作区。
- `body``documentElement` 目标使用最后明确区域。
- 快捷键录制期间不执行关闭。
- IME composing / keyCode 229 下,底层 matcher 仍消费默认或自定义关闭组合键,但
不执行关闭。
- 模态框、抽屉、下拉菜单和 Portal 活跃时不关闭后台标签。
- Tooltip 和拖拽预览等纯展示 Portal 不触发 guard。
- 保持后台 Monaco 焦点,初始状态为 `workspace` 时点击不可聚焦的独立结果窗口
标题栏,再按快捷键也不得关闭主工作区。
- 独立结果窗口仅仅存在时,不阻止主工作区内正常使用关闭快捷键。
- 可见 background blocker 存在且事件目标为 `body` 时也不得关闭后台标签。
- 同一个键盘事件最多派发一个关闭命令。
- `hidden` 回报后不等待重渲染,立即连续派发第二个事件仍保持 blocked。
### 11.3 工作区集成测试
- 使用 `dockedActiveTabId`,独立窗口处于全局 active 时也不关错标签。
- 外部 SQL 脏文件保留保存/丢弃/取消流程。
- 外部 SQL 文件丢失确认仍生效。
- 运行中数据导入工作台无法被快捷键关闭。
- 导出、同步工作台标签允许关闭,后台任务继续。
- 没有 docked 标签时 no-op。
### 11.4 结果区集成测试
- 关闭真实结果标签并正确选择相邻标签。
- 关闭最后一个真实结果后显示日志标签。
- 关闭最后一个真实结果后,命令和渲染共同解析为日志 key下一次快捷键隐藏
结果区。
- 日志标签不会被删除,结果区整体隐藏。
- 计数请求等现有清理逻辑仍由 `handleCloseResult` 执行。
- 结果区隐藏、active key 过期、组件卸载时均不关闭工作区。
- 独立结果窗口不会把命令路由回主工作区。
### 11.5 真机验收
- macOS 触摸板、Monaco、DataGrid 和普通面板焦点下验证 `Cmd+W`
- Windows/Linux 对应验证 `Ctrl+W`
- 验证 WebView 不执行原生窗口或浏览器标签关闭。
- 验证自定义组合键、完全禁用状态及 IME 组合输入;改绑或禁用后默认平台关闭键
仍是应用内 no-op。
## 12. 验收标准
1. 默认快捷键、自定义和禁用均按平台生效。
2. 结果区、工作区和 `blocked` 三种状态的路由符合本设计。
3. 一次按键不会同时关闭结果标签和工作区标签。
4. 日志标签始终保留;日志活跃时隐藏的是整个结果区。
5. 隐藏结果区后连续按键不会误关工作区标签。
6. 现有 SQL 文件和数据导入保护没有被绕过。
7. 旧用户配置不会因升级被静默覆盖或产生双动作。
8. 自动化测试和 macOS/Windows 真机验证通过。
## 13. 预计影响文件
- `frontend/src/utils/shortcuts.ts`
- `frontend/src/App.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/QueryEditor.tsx`
- `frontend/src/components/QueryEditorResultsPanel.tsx`
- `frontend/src/i18n/catalog.ts` 及目录完整性测试
- 快捷键、App、TabManager、QueryEditor 相关测试文件

View File

@@ -0,0 +1,45 @@
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
## 1. 需求摘要
- 需求名称Oracle对象跳转与存储过程修改执行
- 提出日期2026-06-25
- 负责人Codex
- 目标SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
## 2. 范围与验收
- 范围QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
- [x] 阶段 2影响分析影响前端 QueryEditor、DefinitionViewer、i18n、测试后端拆分已有基础保护。
- [x] 阶段 3方案设计补齐元数据与跳转类型对象编辑 SQL 保留 SQLPlus `/` 语义。
- [x] 阶段 4实施计划先补导航闭环再修 PL/SQL 生成/执行,最后跑定向测试。
- [x] 阶段 5实现与自检已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
- [ ] 阶段 6评审与交付待提交推送。
- [ ] 阶段 7发布与观察发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
## 4. 变更清单
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`
- 进行中:提交并推送。
- 待处理:发布后验证 Oracle 实库。
## 5. 风险与阻塞
- 风险:三段名称 `schema.package.proc``db.schema.table` 存在歧义。
- 阻塞:无。
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
## 6. 决策记录
- 决策 1序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
- 决策 2Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter避免前端用分号重组造成 ORA-00900。
## 7. 验证记录
- 验证项QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
- 结果:通过。
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx``npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts``go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'``npm run build`
## 8. 下一步
- 下一步行动:提交并推送到 dev。
- 负责人Codex

View File

@@ -4,24 +4,24 @@
- 需求名称:生产连接只读保护
- 提出日期2026-06-23
- 负责人Codex
- 目标:为 SQL 类数据库与 MongoDB 连接增加类似 DBeaver 的生产连接保护,支持按项勾选数据编辑、结构编辑、脚本执行、数据导入四类限制
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护启用后仅允许查询阻止写入、DDL、导入和同步目标操作
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
## 2. 范围与验收
- 范围:
- 连接配置模型、保存/回填、持久化与 RPC 序列化链路
- 连接弹窗生产保护多选项 UI、查询编辑器本地拦截、DataGrid 导入与表设计器只读态
- 后端 SQL/Mongo 查询判定与分类型写操作守卫
- 连接配置模型、保存/回填与 RPC 序列化链路
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
- 后端 SQL/Mongo 查询判定与写操作统一守卫
- 验收标准:
- 支持的数据源出现可多选的“生产连接保护”限制项
- 勾选“限制脚本执行”后,普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
- 勾选“限制数据编辑 / 结构编辑 / 数据导入”后,对应 UI 与后端入口分别按类别拒绝
- 支持的数据源出现“生产连接/只允许查询”开关
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
- 依赖与约束:
- 保持现有数据源能力判定与 QueryEditor 执行链路
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认采用多限制项保护,而不是单一 `readOnly` 开关
- [x] 阶段 1需求澄清确认采用连接级 `readOnly` 布尔字段,不新建环境系统
- [x] 阶段 2影响分析梳理前端能力面板、查询执行、导入与后端写入口
- [x] 阶段 3方案设计确定“前端预拦截 + 后端最终守卫”双层保护
- [x] 阶段 4实施计划接入配置链路、能力判定、查询判定与写入口守卫
@@ -31,23 +31,21 @@
## 4. 变更清单
- 已完成:
- 新增连接级 `protection` 多限制项配置,并保留旧 `readOnly` 兼容映射
- 连接弹窗为 SQL 类数据库与 MongoDB 改为 DBeaver 风格的生产连接保护多选项
- 修复生产连接保护限制项卡片的 checkbox 对齐与选中态漂移问题,并保留整卡点击切换
- QueryEditor 改为仅在“限制脚本执行”下拦截非查询 SQL / Mongo 写命令
- DataGrid / TableDesigner / Sidebar 能力分别按数据编辑、结构编辑、数据导入限制收口
- 后端为查询、DDL、导入、清表、同步等写入口增加分类型守卫
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
- QueryEditor 增加本地非查询拦截DataGrid 导入入口在只读连接下禁用
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
- 补充前端/后端定向测试与需求追踪文档
- 进行中:
- 等待体验包验证多限制项 UI、分类型限制是否与实际入口完全一致
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
- 待处理:
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
## 5. 风险与阻塞
- 风险:
- 前端 SQL/Mongo 脚本判定是保守策略,边界命令仍需后端兜底
- 现有部分侧栏写菜单仍可能显示,但会按新的结构编辑限制在执行时被后端拒绝
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
- 现有部分侧栏写菜单仍可能显示,但执行时被后端拒绝
- 阻塞:
- 暂无
- 缓解措施:
@@ -55,25 +53,22 @@
## 6. 决策记录
- 决策 1只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
- 决策 2新增 `protection` 多限制项配置,旧 `readOnly=true` 自动映射为四项全开
- 决策 3脚本执行、数据编辑、结构编辑、数据导入分别在不同前后端入口生效
- 决策 4MongoDB 只读判定按命令白名单处理,防止把写命令误放行
- 决策 2采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
- 决策 3MongoDB 只读判定按命令白名单处理,防止把写命令误放行
## 7. 验证记录
- 验证项:
- 前端数据源能力判定、RPC 配置、连接配置测试
- 后端生产保护守卫与 SQL/Mongo 查询判定测试
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
- 结果:
- 通过
- 证据(日志/截图/链接):
- `go test ./internal/app -run 'TestEnsureReadOnlyConnectionAllowsQuery|TestEnsureReadOnlyConnectionAllowsAction|TestEnsureConnectionProtectionSeparatesActionCategories'`
- `npm --prefix frontend run build`
- `npm --prefix frontend test -- src/utils/connectionReadOnly.test.ts src/utils/dataSourceCapabilities.test.ts src/i18n/i18n.test.ts src/components/ConnectionModal.i18n.test.tsx -t 'readOnly|production-guard'`
- 说明:`src/components/ConnectionModal.i18n.test.tsx` 全量仍有 2 条既有基线红测,与本次生产连接保护改动无关
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
## 8. 下一步
- 下一步行动:
- 用体验包回归验证四类限制项在查询、结果编辑、表设计、导入、同步目标和 Mongo 命令上的实际表现
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
- 如需更完整 UX再补侧栏写菜单的只读态收口
- 负责人:
- Codex

View File

@@ -25,6 +25,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/bootstrap.ts"></script>
</body>
</html>

View File

@@ -7,6 +7,7 @@
"": {
"name": "gonavi-client",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@dnd-kit/core": "^6.3.1",
@@ -40,6 +41,7 @@
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"patch-package": "^8.0.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
@@ -2339,6 +2341,13 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2351,6 +2360,22 @@
"node": ">=0.4.0"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/antd": {
"version": "5.29.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -2452,6 +2477,19 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2496,6 +2534,56 @@
"node": ">=8"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -2544,6 +2632,23 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -2620,6 +2725,22 @@
"chevrotain": "^11.0.0"
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
@@ -2641,6 +2762,26 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2694,6 +2835,21 @@
"layout-base": "^1.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3270,6 +3426,24 @@
"node": ">=6"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
@@ -3317,6 +3491,21 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.283",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
@@ -3324,6 +3513,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -3331,6 +3540,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
@@ -3481,6 +3703,29 @@
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -3489,6 +3734,21 @@
"node": ">=0.4.x"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3504,6 +3764,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3514,6 +3784,65 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
@@ -3529,6 +3858,55 @@
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
@@ -3695,6 +4073,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
@@ -3705,6 +4099,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -3717,6 +4121,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3736,6 +4167,26 @@
"node": ">=6"
}
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json2mq": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
@@ -3758,6 +4209,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/katex": {
"version": "0.16.40",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz",
@@ -3788,6 +4262,16 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/langium": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
@@ -3909,6 +4393,16 @@
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@@ -4805,6 +5299,43 @@
],
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -4897,6 +5428,33 @@
"node": ">=0.10.0"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-manager-detector": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -4928,12 +5486,65 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
@@ -6124,6 +6735,47 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
@@ -6131,6 +6783,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6248,6 +6910,19 @@
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -6324,6 +6999,29 @@
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
@@ -6472,6 +7170,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -6780,6 +7488,22 @@
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -6804,6 +7528,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",

View File

@@ -2,12 +2,14 @@
"name": "gonavi-client",
"private": true,
"version": "0.6.5",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"postinstall": "patch-package --error-on-fail",
"i18n:scan": "go run ../tools/i18n-scan --root .."
},
"dependencies": {
@@ -43,6 +45,7 @@
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"patch-package": "^8.0.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",

View File

@@ -1 +1 @@
84ec3a6d42105c92f224232f0d83a33b
339d5c974e6ab5cba2d5def171f5ce0a

View File

@@ -0,0 +1,461 @@
diff --git a/node_modules/rc-table/es/VirtualTable/BodyGrid.js b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
index 42dcde8..2cca743 100644
--- a/node_modules/rc-table/es/VirtualTable/BodyGrid.js
+++ b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
@@ -23,6 +23,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
sticky = _useContext2.sticky,
scrollY = _useContext2.scrollY,
listItemHeight = _useContext2.listItemHeight,
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
getComponent = _useContext2.getComponent,
onTablePropScroll = _useContext2.onScroll;
@@ -113,8 +115,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
var extraRender = function extraRender(info) {
+ // The column-window fast path explicitly requires rows without spans.
+ // Avoid scanning every column on each vertical window update.
+ if (listItemColumnVirtual) {
+ return null;
+ }
var start = info.start,
end = info.end,
getSize = info.getSize,
- offsetY = info.offsetY;
+ offsetY = info.offsetY,
+ offsetX = info.offsetX;
// Do nothing if no data
if (end < 0) {
@@ -198,6 +200,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
style: {
top: -offsetY + sizeInfo.top
},
+ offsetX: offsetX,
extra: true,
getHeight: getHeight
});
@@ -239,6 +242,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
className: tblPrefixCls,
height: scrollY,
itemHeight: listItemHeight || 24,
+ itemHeightFixed: listItemHeightFixed,
data: flattenData,
itemKey: function itemKey(item) {
return getRowKey(item.record);
@@ -262,6 +266,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
data: item,
rowKey: rowKey,
index: index,
+ offsetX: itemProps.offsetX,
style: itemProps.style
});
}));
diff --git a/node_modules/rc-table/es/VirtualTable/BodyLine.js b/node_modules/rc-table/es/VirtualTable/BodyLine.js
index c760819..1a6c396 100644
--- a/node_modules/rc-table/es/VirtualTable/BodyLine.js
+++ b/node_modules/rc-table/es/VirtualTable/BodyLine.js
@@ -2,7 +2,7 @@ import _extends from "@babel/runtime/helpers/esm/extends";
import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
import { useContext } from '@rc-component/context';
import classNames from 'classnames';
import * as React from 'react';
@@ -10,7 +10,7 @@ import Cell from "../Cell";
import TableContext, { responseImmutable } from "../context/TableContext";
import useRowInfo from "../hooks/useRowInfo";
import VirtualCell from "./VirtualCell";
-import { StaticContext } from "./context";
+import { GridContext, StaticContext } from "./context";
import { computedExpandedClassName } from "../utils/expandUtil";
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
var data = props.data,
@@ -18,20 +18,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
className = props.className,
rowKey = props.rowKey,
style = props.style,
+ offsetX = props.offsetX,
extra = props.extra,
getHeight = props.getHeight,
restProps = _objectWithoutProperties(props, _excluded);
var record = data.record,
indent = data.indent,
renderIndex = data.index;
- var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
+ var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
scrollX = _useContext.scrollX,
flattenColumns = _useContext.flattenColumns,
prefixCls = _useContext.prefixCls,
fixColumn = _useContext.fixColumn,
- componentWidth = _useContext.componentWidth;
- var _useContext2 = useContext(StaticContext, ['getComponent']),
- getComponent = _useContext2.getComponent;
+ componentWidth = _useContext.componentWidth,
+ direction = _useContext.direction;
+ var _useContext2 = useContext(StaticContext, ['getComponent', 'listItemColumnVirtual']),
+ getComponent = _useContext2.getComponent,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
+ var _useContext3 = useContext(GridContext, ['columnsOffset']),
+ columnsOffset = _useContext3.columnsOffset;
var rowInfo = useRowInfo(record, rowKey, index, indent);
var RowComponent = getComponent(['body', 'row'], 'div');
var cellComponent = getComponent(['body', 'cell'], 'div');
@@ -71,12 +76,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
rowStyle.position = 'absolute';
rowStyle.pointerEvents = 'none';
}
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
- "data-row-key": rowKey,
- ref: rowSupportExpand ? null : ref,
- className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
- style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
- }), flattenColumns.map(function (column, colIndex) {
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
return /*#__PURE__*/React.createElement(VirtualCell, {
key: colIndex,
component: cellComponent,
@@ -90,7 +90,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
inverse: extra,
getHeight: getHeight
});
- }));
+ };
+ var columnNodes;
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
+ var mergedOffsetX = Math.max(0, offsetX || 0);
+ var viewportWidth = Math.max(0, componentWidth || 0);
+ var overscanWidth = Math.max(320, viewportWidth);
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
+ var nodes = [];
+ var spacerStart = -1;
+ var spacerWidth = 0;
+ var flushSpacer = function flushSpacer() {
+ if (spacerWidth <= 0) return;
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
+ "aria-hidden": true,
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
+ key: "virtual-spacer-".concat(spacerStart),
+ style: {
+ border: 0,
+ flex: "0 0 ".concat(spacerWidth, "px"),
+ minWidth: 0,
+ padding: 0,
+ pointerEvents: 'none',
+ visibility: 'hidden',
+ width: "".concat(spacerWidth, "px")
+ }
+ }));
+ spacerStart = -1;
+ spacerWidth = 0;
+ };
+ flattenColumns.forEach(function (column, colIndex) {
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
+ var columnEnd = columnsOffset[colIndex] || columnStart;
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
+ if (visibleColumn) {
+ flushSpacer();
+ nodes.push(renderVirtualCell(column, colIndex));
+ } else {
+ if (spacerStart < 0) spacerStart = colIndex;
+ spacerWidth += Math.max(0, columnEnd - columnStart);
+ }
+ });
+ flushSpacer();
+ columnNodes = nodes;
+ } else {
+ columnNodes = flattenColumns.map(renderVirtualCell);
+ }
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
+ "data-row-key": rowKey,
+ ref: rowSupportExpand ? null : ref,
+ className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
+ style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
+ }), columnNodes);
if (rowSupportExpand) {
return /*#__PURE__*/React.createElement("div", {
ref: ref
diff --git a/node_modules/rc-table/es/VirtualTable/context.d.ts b/node_modules/rc-table/es/VirtualTable/context.d.ts
index e558165..ccd74fc 100644
--- a/node_modules/rc-table/es/VirtualTable/context.d.ts
+++ b/node_modules/rc-table/es/VirtualTable/context.d.ts
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
export interface StaticContextProps {
scrollY: number;
listItemHeight: number;
+ listItemHeightFixed?: boolean;
+ listItemColumnVirtual?: boolean;
sticky: boolean | TableSticky;
getComponent: GetComponent;
onScroll?: React.UIEventHandler<HTMLDivElement>;
diff --git a/node_modules/rc-table/es/VirtualTable/index.d.ts b/node_modules/rc-table/es/VirtualTable/index.d.ts
index 31fe8e0..c5097b2 100644
--- a/node_modules/rc-table/es/VirtualTable/index.d.ts
+++ b/node_modules/rc-table/es/VirtualTable/index.d.ts
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
y: number;
};
listItemHeight?: number;
+ listItemHeightFixed?: boolean;
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
+ listItemColumnVirtual?: boolean;
}
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
displayName?: string;
diff --git a/node_modules/rc-table/es/VirtualTable/index.js b/node_modules/rc-table/es/VirtualTable/index.js
index fb00e9b..a778aa6 100644
--- a/node_modules/rc-table/es/VirtualTable/index.js
+++ b/node_modules/rc-table/es/VirtualTable/index.js
@@ -27,6 +27,8 @@ function VirtualTable(props, ref) {
prefixCls = _props$prefixCls === void 0 ? DEFAULT_PREFIX : _props$prefixCls,
className = props.className,
listItemHeight = props.listItemHeight,
+ listItemHeightFixed = props.listItemHeightFixed,
+ listItemColumnVirtual = props.listItemColumnVirtual,
components = props.components,
onScroll = props.onScroll;
var _ref = scroll || {},
@@ -61,10 +63,12 @@ function VirtualTable(props, ref) {
sticky: sticky,
scrollY: scrollY,
listItemHeight: listItemHeight,
+ listItemHeightFixed: listItemHeightFixed,
+ listItemColumnVirtual: listItemColumnVirtual,
getComponent: getComponent,
onScroll: onInternalScroll
};
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
// ========================== Render ==========================
return /*#__PURE__*/React.createElement(StaticContext.Provider, {
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
index dee2539..b077789 100644
--- a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
+++ b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
@@ -33,6 +33,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
sticky = _useContext2.sticky,
scrollY = _useContext2.scrollY,
listItemHeight = _useContext2.listItemHeight,
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
getComponent = _useContext2.getComponent,
onTablePropScroll = _useContext2.onScroll;
@@ -123,8 +125,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
var extraRender = function extraRender(info) {
+ // The column-window fast path explicitly requires rows without spans.
+ // Avoid scanning every column on each vertical window update.
+ if (listItemColumnVirtual) {
+ return null;
+ }
var start = info.start,
end = info.end,
getSize = info.getSize,
- offsetY = info.offsetY;
+ offsetY = info.offsetY,
+ offsetX = info.offsetX;
// Do nothing if no data
if (end < 0) {
@@ -208,6 +210,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
style: {
top: -offsetY + sizeInfo.top
},
+ offsetX: offsetX,
extra: true,
getHeight: getHeight
});
@@ -249,6 +252,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
className: tblPrefixCls,
height: scrollY,
itemHeight: listItemHeight || 24,
+ itemHeightFixed: listItemHeightFixed,
data: flattenData,
itemKey: function itemKey(item) {
return getRowKey(item.record);
@@ -272,6 +276,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
data: item,
rowKey: rowKey,
index: index,
+ offsetX: itemProps.offsetX,
style: itemProps.style
});
}));
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyLine.js b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
index e1275fb..d962380 100644
--- a/node_modules/rc-table/lib/VirtualTable/BodyLine.js
+++ b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
@@ -19,7 +19,7 @@ var _useRowInfo = _interopRequireDefault(require("../hooks/useRowInfo"));
var _VirtualCell = _interopRequireDefault(require("./VirtualCell"));
var _context2 = require("./context");
var _expandUtil = require("../utils/expandUtil");
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
@@ -28,20 +28,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
className = props.className,
rowKey = props.rowKey,
style = props.style,
+ offsetX = props.offsetX,
extra = props.extra,
getHeight = props.getHeight,
restProps = (0, _objectWithoutProperties2.default)(props, _excluded);
var record = data.record,
indent = data.indent,
renderIndex = data.index;
- var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
+ var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
scrollX = _useContext.scrollX,
flattenColumns = _useContext.flattenColumns,
prefixCls = _useContext.prefixCls,
fixColumn = _useContext.fixColumn,
- componentWidth = _useContext.componentWidth;
- var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent']),
- getComponent = _useContext2.getComponent;
+ componentWidth = _useContext.componentWidth,
+ direction = _useContext.direction;
+ var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent', 'listItemColumnVirtual']),
+ getComponent = _useContext2.getComponent,
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
+ var _useContext3 = (0, _context.useContext)(_context2.GridContext, ['columnsOffset']),
+ columnsOffset = _useContext3.columnsOffset;
var rowInfo = (0, _useRowInfo.default)(record, rowKey, index, indent);
var RowComponent = getComponent(['body', 'row'], 'div');
var cellComponent = getComponent(['body', 'cell'], 'div');
@@ -81,12 +86,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
rowStyle.position = 'absolute';
rowStyle.pointerEvents = 'none';
}
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
- "data-row-key": rowKey,
- ref: rowSupportExpand ? null : ref,
- className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
- style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
- }), flattenColumns.map(function (column, colIndex) {
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
return /*#__PURE__*/React.createElement(_VirtualCell.default, {
key: colIndex,
component: cellComponent,
@@ -100,7 +100,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
inverse: extra,
getHeight: getHeight
});
- }));
+ };
+ var columnNodes;
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
+ var mergedOffsetX = Math.max(0, offsetX || 0);
+ var viewportWidth = Math.max(0, componentWidth || 0);
+ var overscanWidth = Math.max(320, viewportWidth);
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
+ var nodes = [];
+ var spacerStart = -1;
+ var spacerWidth = 0;
+ var flushSpacer = function flushSpacer() {
+ if (spacerWidth <= 0) return;
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
+ "aria-hidden": true,
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
+ key: "virtual-spacer-".concat(spacerStart),
+ style: {
+ border: 0,
+ flex: "0 0 ".concat(spacerWidth, "px"),
+ minWidth: 0,
+ padding: 0,
+ pointerEvents: 'none',
+ visibility: 'hidden',
+ width: "".concat(spacerWidth, "px")
+ }
+ }));
+ spacerStart = -1;
+ spacerWidth = 0;
+ };
+ flattenColumns.forEach(function (column, colIndex) {
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
+ var columnEnd = columnsOffset[colIndex] || columnStart;
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
+ if (visibleColumn) {
+ flushSpacer();
+ nodes.push(renderVirtualCell(column, colIndex));
+ } else {
+ if (spacerStart < 0) spacerStart = colIndex;
+ spacerWidth += Math.max(0, columnEnd - columnStart);
+ }
+ });
+ flushSpacer();
+ columnNodes = nodes;
+ } else {
+ columnNodes = flattenColumns.map(renderVirtualCell);
+ }
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
+ "data-row-key": rowKey,
+ ref: rowSupportExpand ? null : ref,
+ className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
+ style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
+ }), columnNodes);
if (rowSupportExpand) {
return /*#__PURE__*/React.createElement("div", {
ref: ref
diff --git a/node_modules/rc-table/lib/VirtualTable/context.d.ts b/node_modules/rc-table/lib/VirtualTable/context.d.ts
index e558165..ccd74fc 100644
--- a/node_modules/rc-table/lib/VirtualTable/context.d.ts
+++ b/node_modules/rc-table/lib/VirtualTable/context.d.ts
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
export interface StaticContextProps {
scrollY: number;
listItemHeight: number;
+ listItemHeightFixed?: boolean;
+ listItemColumnVirtual?: boolean;
sticky: boolean | TableSticky;
getComponent: GetComponent;
onScroll?: React.UIEventHandler<HTMLDivElement>;
diff --git a/node_modules/rc-table/lib/VirtualTable/index.d.ts b/node_modules/rc-table/lib/VirtualTable/index.d.ts
index 31fe8e0..c5097b2 100644
--- a/node_modules/rc-table/lib/VirtualTable/index.d.ts
+++ b/node_modules/rc-table/lib/VirtualTable/index.d.ts
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
y: number;
};
listItemHeight?: number;
+ listItemHeightFixed?: boolean;
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
+ listItemColumnVirtual?: boolean;
}
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
displayName?: string;
diff --git a/node_modules/rc-table/lib/VirtualTable/index.js b/node_modules/rc-table/lib/VirtualTable/index.js
index 8e31a9b..24ab161 100644
--- a/node_modules/rc-table/lib/VirtualTable/index.js
+++ b/node_modules/rc-table/lib/VirtualTable/index.js
@@ -38,6 +38,8 @@ function VirtualTable(props, ref) {
prefixCls = _props$prefixCls === void 0 ? _Table.DEFAULT_PREFIX : _props$prefixCls,
className = props.className,
listItemHeight = props.listItemHeight,
+ listItemHeightFixed = props.listItemHeightFixed,
+ listItemColumnVirtual = props.listItemColumnVirtual,
components = props.components,
onScroll = props.onScroll;
var _ref = scroll || {},
@@ -72,10 +74,12 @@ function VirtualTable(props, ref) {
sticky: sticky,
scrollY: scrollY,
listItemHeight: listItemHeight,
+ listItemHeightFixed: listItemHeightFixed,
+ listItemColumnVirtual: listItemColumnVirtual,
getComponent: getComponent,
onScroll: onInternalScroll
};
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
// ========================== Render ==========================
return /*#__PURE__*/React.createElement(_context.StaticContext.Provider, {

View File

@@ -0,0 +1,336 @@
diff --git a/node_modules/rc-virtual-list/es/List.d.ts b/node_modules/rc-virtual-list/es/List.d.ts
index c517366..e414513 100644
--- a/node_modules/rc-virtual-list/es/List.d.ts
+++ b/node_modules/rc-virtual-list/es/List.d.ts
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
data: T[];
height?: number;
itemHeight?: number;
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
+ itemHeightFixed?: boolean;
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight?: boolean;
itemKey: React.Key | ((item: T) => React.Key);
diff --git a/node_modules/rc-virtual-list/es/List.js b/node_modules/rc-virtual-list/es/List.js
index 2926056..e917ba1 100644
--- a/node_modules/rc-virtual-list/es/List.js
+++ b/node_modules/rc-virtual-list/es/List.js
@@ -4,7 +4,7 @@ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
import classNames from 'classnames';
import ResizeObserver from 'rc-resize-observer';
import { useEvent } from 'rc-util';
@@ -35,6 +35,7 @@ export function RawList(props, ref) {
className = props.className,
height = props.height,
itemHeight = props.itemHeight,
+ itemHeightFixed = props.itemHeightFixed,
_props$fullHeight = props.fullHeight,
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
style = props.style,
@@ -65,7 +66,7 @@ export function RawList(props, ref) {
}, [itemKey]);
// ================================ Height ================================
- var _useHeights = useHeights(getKey, null, null),
+ var _useHeights = useHeights(getKey, null, null, itemHeightFixed),
_useHeights2 = _slicedToArray(_useHeights, 4),
setInstanceRef = _useHeights2[0],
collectHeight = _useHeights2[1],
@@ -74,11 +75,12 @@ export function RawList(props, ref) {
// ================================= MISC =================================
var useVirtual = !!(virtual !== false && height && itemHeight);
- var containerHeight = React.useMemo(function () {
+ var measuredContainerHeight = React.useMemo(function () {
return Object.values(heights.maps).reduce(function (total, curr) {
return total + curr;
}, 0);
}, [heights.id, heights.maps]);
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
var isRTL = direction === 'rtl';
var mergedClassName = classNames(prefixCls, _defineProperty({}, "".concat(prefixCls, "-rtl"), isRTL), className);
@@ -159,6 +161,28 @@ export function RawList(props, ref) {
offset: undefined
};
}
+ if (itemHeightFixed && itemHeight > 0) {
+ var fixedDataLen = mergedData.length;
+ var fixedScrollHeight = fixedDataLen * itemHeight;
+ if (fixedDataLen === 0) {
+ return {
+ scrollHeight: 0,
+ start: 0,
+ end: -1,
+ offset: 0
+ };
+ }
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
+ return {
+ scrollHeight: fixedScrollHeight,
+ start: fixedStartIndex,
+ end: fixedEndIndex,
+ offset: fixedStartIndex * itemHeight
+ };
+ }
var itemTop = 0;
var startIndex;
var startOffset;
@@ -201,7 +225,7 @@ export function RawList(props, ref) {
end: endIndex,
offset: startOffset
};
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
scrollHeight = _React$useMemo.scrollHeight,
start = _React$useMemo.start,
end = _React$useMemo.end,
@@ -213,6 +237,10 @@ export function RawList(props, ref) {
// Which will make scroll jump.
// Let's sync scroll top to avoid jump
React.useLayoutEffect(function () {
+ if (itemHeightFixed) {
+ heights.resetRecord();
+ return;
+ }
var changedRecord = heights.getRecord();
if (changedRecord.size === 1) {
var recordKey = Array.from(changedRecord.keys())[0];
@@ -232,7 +260,7 @@ export function RawList(props, ref) {
}
}
heights.resetRecord();
- }, [scrollHeight]);
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
// ================================= Size =================================
var _React$useState = React.useState({
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
@@ -1,6 +1,6 @@
import type { GetKey } from '../interface';
import CacheMap from '../utils/CacheMap';
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
setInstanceRef: (item: T, instance: HTMLElement) => void,
collectHeight: (sync?: boolean) => void,
cacheMap: CacheMap,
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.js b/node_modules/rc-virtual-list/es/hooks/useHeights.js
index 0323aae..38e6e75 100644
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.js
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.js
@@ -6,7 +6,7 @@ function parseNumber(value) {
var num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
-export default function useHeights(getKey, onItemAdd, onItemRemove) {
+export default function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
var _React$useState = React.useState(0),
_React$useState2 = _slicedToArray(_React$useState, 2),
updatedMark = _React$useState2[0],
@@ -14,12 +14,17 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
var instanceRef = useRef(new Map());
var heightsRef = useRef(new CacheMap());
var promiseIdRef = useRef(0);
+ var disabledRef = useRef(disabled);
+ disabledRef.current = disabled;
function cancelRaf() {
promiseIdRef.current += 1;
}
function collectHeight() {
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
cancelRaf();
+ if (disabledRef.current) {
+ return;
+ }
var doCollect = function doCollect() {
var changed = false;
instanceRef.current.forEach(function (element, key) {
@@ -79,5 +84,10 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
useEffect(function () {
return cancelRaf;
}, []);
+ useEffect(function () {
+ if (!disabled) {
+ collectHeight(true);
+ }
+ }, [disabled]);
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}
diff --git a/node_modules/rc-virtual-list/lib/List.d.ts b/node_modules/rc-virtual-list/lib/List.d.ts
index c517366..e414513 100644
--- a/node_modules/rc-virtual-list/lib/List.d.ts
+++ b/node_modules/rc-virtual-list/lib/List.d.ts
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
data: T[];
height?: number;
itemHeight?: number;
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
+ itemHeightFixed?: boolean;
/** If not match virtual scroll condition, Set List still use height of container. */
fullHeight?: boolean;
itemKey: React.Key | ((item: T) => React.Key);
diff --git a/node_modules/rc-virtual-list/lib/List.js b/node_modules/rc-virtual-list/lib/List.js
index f0cb64d..d24a9ad 100644
--- a/node_modules/rc-virtual-list/lib/List.js
+++ b/node_modules/rc-virtual-list/lib/List.js
@@ -32,7 +32,7 @@ var _useScrollDrag = _interopRequireDefault(require("./hooks/useScrollDrag"));
var _useScrollTo = _interopRequireDefault(require("./hooks/useScrollTo"));
var _ScrollBar = _interopRequireDefault(require("./ScrollBar"));
var _scrollbarUtil = require("./utils/scrollbarUtil");
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
var EMPTY_DATA = [];
var ScrollStyle = {
overflowY: 'auto',
@@ -44,6 +44,7 @@ function RawList(props, ref) {
className = props.className,
height = props.height,
itemHeight = props.itemHeight,
+ itemHeightFixed = props.itemHeightFixed,
_props$fullHeight = props.fullHeight,
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
style = props.style,
@@ -74,7 +75,7 @@ function RawList(props, ref) {
}, [itemKey]);
// ================================ Height ================================
- var _useHeights = (0, _useHeights3.default)(getKey, null, null),
+ var _useHeights = (0, _useHeights3.default)(getKey, null, null, itemHeightFixed),
_useHeights2 = (0, _slicedToArray2.default)(_useHeights, 4),
setInstanceRef = _useHeights2[0],
collectHeight = _useHeights2[1],
@@ -83,11 +84,12 @@ function RawList(props, ref) {
// ================================= MISC =================================
var useVirtual = !!(virtual !== false && height && itemHeight);
- var containerHeight = React.useMemo(function () {
+ var measuredContainerHeight = React.useMemo(function () {
return Object.values(heights.maps).reduce(function (total, curr) {
return total + curr;
}, 0);
}, [heights.id, heights.maps]);
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
var isRTL = direction === 'rtl';
var mergedClassName = (0, _classnames.default)(prefixCls, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-rtl"), isRTL), className);
@@ -168,6 +170,28 @@ function RawList(props, ref) {
offset: undefined
};
}
+ if (itemHeightFixed && itemHeight > 0) {
+ var fixedDataLen = mergedData.length;
+ var fixedScrollHeight = fixedDataLen * itemHeight;
+ if (fixedDataLen === 0) {
+ return {
+ scrollHeight: 0,
+ start: 0,
+ end: -1,
+ offset: 0
+ };
+ }
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
+ return {
+ scrollHeight: fixedScrollHeight,
+ start: fixedStartIndex,
+ end: fixedEndIndex,
+ offset: fixedStartIndex * itemHeight
+ };
+ }
var itemTop = 0;
var startIndex;
var startOffset;
@@ -210,7 +234,7 @@ function RawList(props, ref) {
end: endIndex,
offset: startOffset
};
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
scrollHeight = _React$useMemo.scrollHeight,
start = _React$useMemo.start,
end = _React$useMemo.end,
@@ -222,6 +246,10 @@ function RawList(props, ref) {
// Which will make scroll jump.
// Let's sync scroll top to avoid jump
React.useLayoutEffect(function () {
+ if (itemHeightFixed) {
+ heights.resetRecord();
+ return;
+ }
var changedRecord = heights.getRecord();
if (changedRecord.size === 1) {
var recordKey = Array.from(changedRecord.keys())[0];
@@ -241,7 +269,7 @@ function RawList(props, ref) {
}
}
heights.resetRecord();
- }, [scrollHeight]);
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
// ================================= Size =================================
var _React$useState = React.useState({
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
@@ -1,6 +1,6 @@
import type { GetKey } from '../interface';
import CacheMap from '../utils/CacheMap';
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
setInstanceRef: (item: T, instance: HTMLElement) => void,
collectHeight: (sync?: boolean) => void,
cacheMap: CacheMap,
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.js b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
index 471ffc6..a6d2ce5 100644
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.js
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
@@ -14,7 +14,7 @@ function parseNumber(value) {
var num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
-function useHeights(getKey, onItemAdd, onItemRemove) {
+function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
var _React$useState = React.useState(0),
_React$useState2 = (0, _slicedToArray2.default)(_React$useState, 2),
updatedMark = _React$useState2[0],
@@ -22,12 +22,17 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
var instanceRef = (0, _react.useRef)(new Map());
var heightsRef = (0, _react.useRef)(new _CacheMap.default());
var promiseIdRef = (0, _react.useRef)(0);
+ var disabledRef = (0, _react.useRef)(disabled);
+ disabledRef.current = disabled;
function cancelRaf() {
promiseIdRef.current += 1;
}
function collectHeight() {
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
cancelRaf();
+ if (disabledRef.current) {
+ return;
+ }
var doCollect = function doCollect() {
var changed = false;
instanceRef.current.forEach(function (element, key) {
@@ -87,5 +92,10 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
(0, _react.useEffect)(function () {
return cancelRaf;
}, []);
+ (0, _react.useEffect)(function () {
+ if (!disabled) {
+ collectHeight(true);
+ }
+ }, [disabled]);
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -0,0 +1,86 @@
{
"version": 9,
"defaultId": "02",
"icons": [
{
"id": "01",
"slug": "database-hug",
"titleZh": "抱库小狗",
"titleEn": "Database hug",
"iconPath": "/brand-icons/01-database-hug.webp",
"fullPath": "/brand-icons/01-database-hug.webp"
},
{
"id": "02",
"slug": "database-search",
"titleZh": "搜库小狗",
"titleEn": "Database search",
"iconPath": "/brand-icons/02-database-search.webp",
"fullPath": "/brand-icons/02-database-search.webp"
},
{
"id": "03",
"slug": "bandana-badge",
"titleZh": "头巾徽章",
"titleEn": "Bandana badge",
"iconPath": "/brand-icons/03-bandana-badge.webp",
"fullPath": "/brand-icons/03-bandana-badge.webp"
},
{
"id": "04",
"slug": "magnifier-wink",
"titleZh": "放大镜眨眼",
"titleEn": "Magnifier wink",
"iconPath": "/brand-icons/04-magnifier-wink.webp",
"fullPath": "/brand-icons/04-magnifier-wink.webp"
},
{
"id": "05",
"slug": "window-peek",
"titleZh": "窗口探头",
"titleEn": "Window peek",
"iconPath": "/brand-icons/05-window-peek.webp",
"fullPath": "/brand-icons/05-window-peek.webp"
},
{
"id": "06",
"slug": "hex-collar",
"titleZh": "六边项圈",
"titleEn": "Hex collar",
"iconPath": "/brand-icons/06-hex-collar.webp",
"fullPath": "/brand-icons/06-hex-collar.webp"
},
{
"id": "07",
"slug": "graph-sit",
"titleZh": "关系图",
"titleEn": "Graph sit",
"iconPath": "/brand-icons/07-graph-sit.webp",
"fullPath": "/brand-icons/07-graph-sit.webp"
},
{
"id": "08",
"slug": "cloud-banner",
"titleZh": "云朵横幅",
"titleEn": "Cloud banner",
"iconPath": "/brand-icons/08-cloud-banner.webp",
"fullPath": "/brand-icons/08-cloud-banner.webp"
},
{
"id": "09",
"slug": "terminal-sit",
"titleZh": "终端旁坐",
"titleEn": "Terminal sit",
"iconPath": "/brand-icons/09-terminal-sit.webp",
"fullPath": "/brand-icons/09-terminal-sit.webp"
},
{
"id": "10",
"slug": "compass-bandana",
"titleZh": "罗盘头巾",
"titleEn": "Compass bandana",
"iconPath": "/brand-icons/10-compass-bandana.webp",
"fullPath": "/brand-icons/10-compass-bandana.webp"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#00A1EA"><title>Milvus</title><path d="M15.536 11.997a4.005 4.005 0 0 1-4.015 4.018c-2.36-.005-4.05-1.954-4.01-4.08a4.013 4.013 0 0 1 8.025.061zm8.463-.04a.823.823 0 0 1-.25.616q-1.122 1.128-2.248 2.25c-.082.083-.165.1-.25.054-.091-.048-.124-.121-.098-.242a12.764 12.764 0 0 0 .25-1.626 12.153 12.153 0 0 0 .028-1.49 11.56 11.56 0 0 0-.27-2.114.255.255 0 0 1 .017-.213.194.194 0 0 1 .273-.052.628.628 0 0 1 .086.075l2.174 2.174c.171.17.29.366.289.567zM11.226 20c-1.89-.002-3.488-.565-4.926-1.611a8.317 8.317 0 0 1-.952-.816q-1.125-1.112-2.253-2.221l-1.931-1.899c-.291-.285-.583-.57-.873-.857-.393-.39-.387-.83.012-1.217.296-.288.59-.579.885-.868q1.433-1.41 2.867-2.818c.532-.522 1.057-1.051 1.604-1.555a7.996 7.996 0 0 1 3.786-1.95A7.668 7.668 0 0 1 11.135 4c1.89.015 3.612.546 5.113 1.723a7.987 7.987 0 0 1 2.282 2.77 7.567 7.567 0 0 1 .76 2.556c.255 2.404-.414 4.518-2.046 6.312a7.772 7.772 0 0 1-3.927 2.366 8.995 8.995 0 0 1-2.091.274zm.364-2.137a5.819 5.819 0 0 0 .605-.036 5.35 5.35 0 0 0 .38-.05c1.525-.28 2.76-1.051 3.704-2.263.92-1.178 1.28-2.542 1.168-4.028a5.626 5.626 0 0 0-.631-2.168c-.627-1.213-1.596-2.067-2.817-2.646-1.096-.519-2.248-.649-3.443-.464A5.814 5.814 0 0 0 7.33 7.8c-.858.83-1.707 1.669-2.56 2.504-.426.419-.85.841-1.28 1.257-.274.266-.286.588-.013.857q.804.793 1.61 1.583c.722.709 1.44 1.42 2.165 2.126 1.184 1.153 2.604 1.737 4.339 1.738z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,845 @@
/* ── Theme settings (v2 only): clean desktop settings ─── */
body .gonavi-theme-settings {
--gn-settings-fg: #0f172a;
--gn-settings-muted: #64748b;
--gn-settings-line: #e2e8f0;
--gn-settings-track: #e2e8f0;
--gn-settings-chip-bg: #f8fafc;
--gn-settings-chip-fg: #0f172a;
--gn-settings-seg-bg: #f8fafc;
--gn-settings-seg-border: #e2e8f0;
--gn-settings-seg-item: #64748b;
--gn-settings-seg-selected-bg: #fff;
--gn-settings-seg-selected-fg: #0f172a;
--gn-settings-seg-shadow: none;
--gn-settings-accent: #16a34a;
--gn-settings-accent-soft: #dcfce7;
--gn-settings-accent-border: #86efac;
--gn-settings-card-bg: #ffffff;
}
body[data-theme='dark'] .gonavi-theme-settings {
--gn-settings-fg: rgba(248, 250, 252, 0.94);
--gn-settings-muted: rgba(226, 232, 240, 0.58);
--gn-settings-line: rgba(255, 255, 255, 0.10);
--gn-settings-track: rgba(255, 255, 255, 0.12);
--gn-settings-chip-bg: rgba(255, 255, 255, 0.06);
--gn-settings-chip-fg: rgba(248, 250, 252, 0.88);
--gn-settings-seg-bg: rgba(255, 255, 255, 0.04);
--gn-settings-seg-border: rgba(148, 163, 184, 0.18);
--gn-settings-seg-item: rgba(226, 232, 240, 0.58);
--gn-settings-seg-selected-bg: rgba(30, 41, 59, 0.96);
--gn-settings-seg-selected-fg: #f8fafc;
--gn-settings-accent: #22c55e;
--gn-settings-accent-soft: rgba(34, 197, 94, 0.18);
--gn-settings-accent-border: rgba(74, 222, 128, 0.55);
--gn-settings-card-bg: rgba(255, 255, 255, 0.02);
}
body .gonavi-theme-settings .gonavi-settings-section {
padding-left: 0;
border-left: none;
margin-bottom: 4px;
}
body .gonavi-theme-settings .gonavi-settings-section + .gonavi-settings-section {
margin-top: 20px;
padding-top: 0;
border-top: none;
}
body .gonavi-theme-settings .gonavi-settings-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
color: var(--gn-settings-fg);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
}
body .gonavi-theme-settings .gonavi-settings-section-hint {
margin: -2px 0 10px;
color: var(--gn-settings-muted);
font-size: 12px;
line-height: 1.5;
}
body .gonavi-theme-settings .gonavi-settings-row {
display: grid;
grid-template-columns: minmax(120px, 168px) minmax(0, 1fr);
gap: 12px 16px;
align-items: center;
min-height: 40px;
padding: 8px 0;
}
body .gonavi-theme-settings .gonavi-settings-row.is-stacked {
grid-template-columns: 1fr;
gap: 8px;
align-items: stretch;
}
body .gonavi-theme-settings .gonavi-settings-row + .gonavi-settings-row {
border-top: 1px solid var(--gn-settings-line);
}
body .gonavi-theme-settings .gonavi-settings-label {
color: var(--gn-settings-fg);
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
body .gonavi-theme-settings .gonavi-settings-label-hint {
margin-top: 3px;
color: var(--gn-settings-muted);
font-size: 12px;
font-weight: 400;
line-height: 1.45;
}
body .gonavi-theme-settings .gonavi-settings-control {
min-width: 0;
}
body .gonavi-theme-settings .gonavi-settings-slider-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 2px 0;
overflow: visible;
}
body .gonavi-theme-settings .gonavi-settings-slider-row.has-marks {
align-items: flex-start;
padding-bottom: 2px;
}
body .gonavi-theme-settings .gonavi-settings-slider-row.has-marks .gonavi-settings-slider-value {
margin-top: 6px;
}
body .gonavi-theme-settings .gonavi-settings-slider-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
/*
* antd Slider 手柄用 translateX(-50%),在 0%/100% 会各伸出半个圆 + 描边阴影。
* 外层 padding 需 ≥ 半宽手柄(9) + 描边(2~3),否则仍会缺一截。
*/
body .gonavi-theme-settings .gonavi-settings-slider-track-wrap {
flex: 0 0 auto;
width: 100%;
min-width: 0;
padding-inline: 14px;
overflow: visible;
box-sizing: border-box;
}
body .gonavi-theme-settings .gonavi-settings-slider-track-wrap .ant-slider {
width: 100%;
margin: 0 !important;
padding-block: 14px !important;
padding-inline: 0 !important;
overflow: visible !important;
}
/* 预设刻度:独立一行,严格在进度条下方,不叠在轨道上 */
body .gonavi-theme-settings .gonavi-settings-slider-presets {
position: relative;
height: 18px;
margin: 0 14px;
box-sizing: border-box;
}
body .gonavi-theme-settings .gonavi-settings-slider-preset {
position: absolute;
top: 0;
transform: translateX(-50%);
margin: 0;
padding: 0;
border: none;
background: transparent;
color: var(--gn-settings-muted);
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 18px;
white-space: nowrap;
cursor: pointer;
}
body .gonavi-theme-settings .gonavi-settings-slider-preset:hover:not(:disabled) {
color: var(--gn-settings-fg);
}
body .gonavi-theme-settings .gonavi-settings-slider-preset.is-active {
color: var(--gn-settings-accent);
font-weight: 700;
}
body .gonavi-theme-settings .gonavi-settings-slider-preset:disabled {
cursor: not-allowed;
opacity: 0.55;
}
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number,
body .gonavi-theme-settings .ant-input-number-group.ant-input-number-group-wrapper.gonavi-settings-slider-value-input,
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-group-wrapper {
flex: 0 0 auto !important;
width: 64px !important;
min-width: 56px !important;
max-width: 72px !important;
}
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number,
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number {
width: 100% !important;
border-radius: 999px 0 0 999px !important;
border-color: color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
background: color-mix(in srgb, var(--gn-settings-accent) 8%, transparent) !important;
}
/* 无 addon 时整颗胶囊 */
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number:not(.ant-input-number-group-wrapper) {
width: 52px !important;
min-width: 48px !important;
max-width: 56px !important;
border-radius: 999px !important;
}
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number-input {
height: 28px !important;
padding: 0 2px 0 6px !important;
color: var(--gn-settings-fg) !important;
font-size: 12px !important;
font-weight: 700 !important;
font-variant-numeric: tabular-nums;
text-align: center !important;
}
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number-group-addon {
min-width: 18px !important;
width: 18px !important;
padding: 0 6px 0 0 !important;
border: 1px solid color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
border-left: none !important;
border-radius: 0 999px 999px 0 !important;
background: color-mix(in srgb, var(--gn-settings-accent) 8%, transparent) !important;
color: var(--gn-settings-muted) !important;
font-size: 11px !important;
font-weight: 700 !important;
line-height: 26px !important;
}
/* 带 % / px 的整体圆角胶囊外观 */
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-group-wrapper .ant-input-number {
border-right: none !important;
}
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-disabled {
opacity: 0.6;
}
body .gonavi-theme-settings .ant-slider-rail {
height: 8px !important;
border-radius: 999px !important;
background: var(--gn-settings-track) !important;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
}
body .gonavi-theme-settings .ant-slider-track {
height: 8px !important;
border-radius: 999px !important;
background: linear-gradient(90deg, color-mix(in srgb, var(--gn-settings-accent) 78%, #fff) 0%, var(--gn-settings-accent) 100%) !important;
}
body .gonavi-theme-settings .ant-slider-handle {
width: 16px !important;
height: 16px !important;
inset-block-start: 10px !important;
z-index: 2;
}
/* 关掉 antd 默认 before 光晕,避免叠成空心环 */
body .gonavi-theme-settings .ant-slider-handle::before {
display: none !important;
}
body .gonavi-theme-settings .ant-slider-handle::after {
width: 16px !important;
height: 16px !important;
inset-inline-start: 0 !important;
inset-block-start: 0 !important;
border-radius: 50% !important;
/* 实心白底 + 绿描边,禁用时也不靠整控件 opacity 变空心 */
background: #fff !important;
box-shadow:
0 0 0 2px var(--gn-settings-accent),
0 1px 4px rgba(15, 23, 42, 0.14) !important;
opacity: 1 !important;
transition: box-shadow 0.12s ease !important;
}
body .gonavi-theme-settings .ant-slider-handle:hover::after,
body .gonavi-theme-settings .ant-slider-handle:focus::after {
box-shadow:
0 0 0 2px var(--gn-settings-accent),
0 0 0 4px color-mix(in srgb, var(--gn-settings-accent) 22%, transparent),
0 1px 4px rgba(15, 23, 42, 0.14) !important;
}
body .gonavi-theme-settings .ant-slider:hover .ant-slider-track,
body .gonavi-theme-settings .ant-slider:focus-within .ant-slider-track {
background: linear-gradient(90deg, color-mix(in srgb, var(--gn-settings-accent) 78%, #fff) 0%, var(--gn-settings-accent) 100%) !important;
}
/* 禁用:不要整条 opacity否则白心被透掉只剩绿环 */
body .gonavi-theme-settings .ant-slider-disabled {
opacity: 1 !important;
}
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-rail {
background: color-mix(in srgb, var(--gn-settings-track) 70%, transparent) !important;
}
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-track {
background: color-mix(in srgb, var(--gn-settings-accent) 35%, var(--gn-settings-track)) !important;
}
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-handle {
cursor: not-allowed;
}
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-handle::after {
background: #fff !important;
box-shadow:
0 0 0 2px color-mix(in srgb, var(--gn-settings-accent) 40%, #94a3b8),
0 1px 3px rgba(15, 23, 42, 0.10) !important;
opacity: 1 !important;
}
/* 选项 Segmented小胶囊is-compact 不拉满整行 */
body .gonavi-theme-settings .gonavi-settings-segmented-choice.ant-segmented {
width: 100%;
height: auto !important;
min-height: 0 !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact.ant-segmented {
width: auto !important;
max-width: 100%;
display: inline-flex !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-group {
display: flex !important;
width: 100%;
gap: 6px !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact .ant-segmented-group {
width: auto !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item {
flex: 1 1 0;
min-width: 0;
border-radius: 8px !important;
border: 1px solid var(--gn-settings-line) !important;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact .ant-segmented-item {
flex: 0 0 auto !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:not(.ant-segmented-item-selected) {
background: transparent !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:hover:not(.ant-segmented-item-selected) {
background: var(--gn-settings-seg-bg) !important;
border-color: color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item-label {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 30px !important;
padding: 0 12px !important;
font-size: 12.5px !important;
font-weight: 600 !important;
line-height: 30px !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:not(.ant-segmented-item-selected) .ant-segmented-item-label {
color: var(--gn-settings-muted) !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-thumb {
display: none !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected,
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] {
border-color: var(--gn-settings-accent) !important;
background: var(--gn-settings-accent-soft) !important;
box-shadow: none !important;
}
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected .ant-segmented-item-label,
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] .ant-segmented-item-label {
color: var(--gn-settings-accent) !important;
font-weight: 700 !important;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected,
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] {
border-color: color-mix(in srgb, var(--gn-settings-accent) 70%, rgba(255, 255, 255, 0.2)) !important;
background: color-mix(in srgb, var(--gn-settings-accent) 22%, transparent) !important;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected .ant-segmented-item-label,
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] .ant-segmented-item-label {
color: #86efac !important;
}
/* 界面版本预览卡(紧凑双列) */
body .gonavi-theme-settings .gonavi-settings-ui-version-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-tile {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 10px;
border: 1px solid var(--gn-settings-line);
border-radius: 12px;
background: var(--gn-settings-card-bg);
color: var(--gn-settings-fg);
text-align: left;
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-tile:hover {
border-color: #cbd5e1;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-tile.is-active {
border-color: var(--gn-settings-accent);
box-shadow: 0 0 0 1px var(--gn-settings-accent-border);
background: var(--gn-settings-card-bg);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview {
position: relative;
height: 58px;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--gn-settings-line);
background: #f1f5f9;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview {
background: #0f172a;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-legacy {
display: grid;
grid-template-columns: 34% 1fr;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 {
display: grid;
grid-template-columns: 18px 1fr;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-side {
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px 6px;
border-right: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.72);
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-side {
border-right-color: rgba(255, 255, 255, 0.08);
background: rgba(30, 41, 59, 0.85);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-side {
align-items: center;
padding: 8px 3px;
background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
border-right: none;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.18);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-dot {
width: 7px;
height: 7px;
background: rgba(255, 255, 255, 0.88);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-main {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar {
height: 8px;
border-radius: 4px;
background: rgba(15, 23, 42, 0.10);
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar {
background: rgba(248, 250, 252, 0.12);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar.is-accent {
width: 42%;
background: color-mix(in srgb, var(--gn-settings-accent) 55%, rgba(15, 23, 42, 0.08));
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-bar.is-accent {
width: 48%;
background: color-mix(in srgb, var(--gn-settings-accent) 70%, #fff);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
flex: 1;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-card {
border-radius: 5px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(15, 23, 42, 0.06);
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-card {
background: rgba(30, 41, 59, 0.9);
border-color: rgba(255, 255, 255, 0.08);
}
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-legacy .uv-card {
border-radius: 2px;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-title {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: var(--gn-settings-muted);
font-size: 10px;
font-weight: 700;
}
body .gonavi-theme-settings .gonavi-settings-ui-version-badge.is-new {
background: rgba(2, 132, 199, 0.12);
color: #0284c7;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-badge {
background: rgba(255, 255, 255, 0.08);
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-badge.is-new {
background: rgba(56, 189, 248, 0.18);
color: #7dd3fc;
}
/* 顶部分段:原生 button Tab不依赖 antd Segmented */
body .gonavi-theme-settings .gonavi-settings-tabs {
display: flex;
width: 100%;
border-bottom: 1px solid var(--gn-settings-line);
}
body .gonavi-theme-settings .gonavi-settings-tab {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0 0 -1px;
padding: 10px 8px 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--gn-settings-muted);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
}
body .gonavi-theme-settings .gonavi-settings-tab:hover {
color: var(--gn-settings-fg);
}
body .gonavi-theme-settings .gonavi-settings-tab.is-active {
color: var(--gn-settings-accent);
border-bottom-color: var(--gn-settings-accent);
font-weight: 700;
}
body .gonavi-theme-settings .gonavi-settings-tab-icon {
display: inline-flex;
font-size: 14px;
}
body .gonavi-theme-settings .gonavi-settings-inline-choice-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--gn-settings-line);
}
body .gonavi-theme-settings .gonavi-settings-pills {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
body .gonavi-theme-settings .gonavi-settings-pill {
margin: 0;
padding: 6px 12px;
border: 1px solid var(--gn-settings-line);
border-radius: 8px;
background: var(--gn-settings-card-bg);
color: var(--gn-settings-muted);
font: inherit;
font-size: 12.5px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
}
body .gonavi-theme-settings .gonavi-settings-pill:hover {
border-color: #cbd5e1;
color: var(--gn-settings-fg);
}
body .gonavi-theme-settings .gonavi-settings-pill.is-active {
border-color: var(--gn-settings-accent);
background: var(--gn-settings-accent-soft);
color: var(--gn-settings-accent);
font-weight: 700;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-pill.is-active {
color: #86efac;
border-color: color-mix(in srgb, var(--gn-settings-accent) 70%, rgba(255, 255, 255, 0.2));
background: color-mix(in srgb, var(--gn-settings-accent) 22%, transparent);
}
body .gonavi-theme-settings .gonavi-settings-inline-meta {
margin-top: 8px;
color: var(--gn-settings-muted);
font-size: 12px;
line-height: 1.5;
}
/* Beta弱提示不要大黄条 */
body .gonavi-theme-settings .gonavi-settings-beta-chip {
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--gn-settings-accent) 12%, transparent);
color: var(--gn-settings-accent);
font-size: 10px;
font-weight: 700;
line-height: 18px;
}
body .gonavi-theme-settings .gonavi-settings-alert {
margin-top: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--gn-settings-muted);
font-size: 11.5px;
line-height: 1.45;
}
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-alert {
border: none;
background: transparent;
color: var(--gn-settings-muted);
}
/* Compact theme-mode previews */
body .gonavi-theme-settings .gonavi-settings-mode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
body .gonavi-theme-settings .gonavi-settings-mode-tile {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 10px;
border: 1px solid var(--gn-settings-line);
border-radius: 12px;
background: var(--gn-settings-card-bg);
color: var(--gn-settings-fg);
text-align: left;
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
body .gonavi-theme-settings .gonavi-settings-mode-tile:hover {
border-color: #cbd5e1;
}
body .gonavi-theme-settings .gonavi-settings-mode-tile.is-active {
border-color: var(--gn-settings-accent);
box-shadow: 0 0 0 1px var(--gn-settings-accent-border);
background: var(--gn-settings-card-bg);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview {
height: 44px;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--gn-settings-line);
display: grid;
}
body .gonavi-theme-settings .gonavi-settings-mode-preview.is-system {
grid-template-columns: 1fr 1fr;
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane {
display: grid;
gap: 4px;
align-content: center;
padding: 7px 8px;
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light {
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark {
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-line {
height: 5px;
border-radius: 999px;
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(1) {
width: 58%;
background: rgba(15, 23, 42, 0.14);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(2) {
width: 78%;
background: rgba(22, 163, 74, 0.38);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(3) {
width: 42%;
background: rgba(15, 23, 42, 0.10);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(1) {
width: 58%;
background: rgba(248, 250, 252, 0.22);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(2) {
width: 78%;
background: rgba(74, 222, 128, 0.45);
}
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(3) {
width: 42%;
background: rgba(248, 250, 252, 0.12);
}
body .gonavi-theme-settings .gonavi-settings-mode-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}
body .gonavi-theme-settings .gonavi-settings-mode-label {
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
body .gonavi-theme-settings .gonavi-settings-mode-check {
color: var(--gn-settings-accent);
font-size: 12px;
flex-shrink: 0;
}
/* Control-only stacked row: no duplicate label column */
body .gonavi-theme-settings .gonavi-settings-row.is-control-only {
grid-template-columns: 1fr;
padding-top: 2px;
}
body .gonavi-theme-settings .gonavi-settings-row.is-control-only .gonavi-settings-label {
display: none;
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="utf-8" />
<title>Real DOM Theme Settings Check</title>
<link rel="stylesheet" href="./gonavi-theme-settings.extract.css" />
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #eef2f6; padding: 24px; }
.frame { width: 720px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px 20px 24px; border: 1px solid #e2e8f0; }
h1 { margin: 0 0 4px; font-size: 16px; }
.sub { margin: 0 0 12px; color: #64748b; font-size: 12px; }
</style>
</head>
<body>
<div class="frame">
<h1>主题与外观</h1>
<p class="sub">切换亮暗主题并调整界面观感。</p>
<div class="gonavi-theme-settings">
<div class="gonavi-settings-tabs" role="tablist">
<button type="button" class="gonavi-settings-tab is-active" role="tab" aria-selected="true">
<span class="gonavi-settings-tab-icon"></span><span>主题与界面</span>
</button>
<button type="button" class="gonavi-settings-tab" role="tab" aria-selected="false">
<span class="gonavi-settings-tab-icon"></span><span>显示与字体</span>
</button>
<button type="button" class="gonavi-settings-tab" role="tab" aria-selected="false">
<span class="gonavi-settings-tab-icon"></span><span>工作区</span>
</button>
</div>
<div class="gonavi-settings-inline-meta">更改将立即生效,无需保存。</div>
<section class="gonavi-settings-section" style="margin-top:16px">
<div class="gonavi-settings-section-title">亮暗模式</div>
<div class="gonavi-settings-mode-grid">
<button type="button" class="gonavi-settings-mode-tile"><div class="gonavi-settings-mode-preview"><div class="gonavi-settings-mode-preview-pane is-light"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">亮色主题</span></div></button>
<button type="button" class="gonavi-settings-mode-tile"><div class="gonavi-settings-mode-preview"><div class="gonavi-settings-mode-preview-pane is-dark"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">暗色主题</span></div></button>
<button type="button" class="gonavi-settings-mode-tile is-active"><div class="gonavi-settings-mode-preview is-system"><div class="gonavi-settings-mode-preview-pane is-light"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div><div class="gonavi-settings-mode-preview-pane is-dark"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">跟随系统</span><span class="gonavi-settings-mode-check"></span></div></button>
</div>
</section>
<section class="gonavi-settings-section" style="margin-top:20px">
<div class="gonavi-settings-section-title">界面版本 <span class="gonavi-settings-beta-chip">Beta</span></div>
<div class="gonavi-settings-ui-version-grid">
<button type="button" class="gonavi-settings-ui-version-tile"><div class="gonavi-settings-ui-version-preview is-legacy"><div class="uv-side"><span class="uv-dot"></span><span class="uv-dot"></span><span class="uv-dot"></span></div><div class="uv-main"><div class="uv-bar is-accent"></div><div class="uv-cards"><div class="uv-card"></div><div class="uv-card"></div></div></div></div><div class="gonavi-settings-ui-version-meta"><span class="gonavi-settings-ui-version-title"><span>旧版 UI</span><span class="gonavi-settings-ui-version-badge">默认</span></span></div></button>
<button type="button" class="gonavi-settings-ui-version-tile is-active"><div class="gonavi-settings-ui-version-preview is-v2"><div class="uv-side"><span class="uv-dot"></span><span class="uv-dot"></span><span class="uv-dot"></span></div><div class="uv-main"><div class="uv-bar is-accent"></div><div class="uv-cards"><div class="uv-card"></div><div class="uv-card"></div></div></div></div><div class="gonavi-settings-ui-version-meta"><span class="gonavi-settings-ui-version-title"><span>新版 UI</span><span class="gonavi-settings-ui-version-badge is-new">Beta</span></span><span class="gonavi-settings-mode-check"></span></div></button>
</div>
<div class="gonavi-settings-inline-choice-row">
<span class="gonavi-settings-label">新版左侧搜索模式</span>
<div class="gonavi-settings-pills">
<button type="button" class="gonavi-settings-pill is-active">新版命令搜索</button>
<button type="button" class="gonavi-settings-pill">旧版侧栏筛选</button>
</div>
</div>
</section>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,386 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GoNavi Theme Settings Visual</title>
<style>
:root {
--fg: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--bg: #f8fafc;
--panel: #ffffff;
--accent: #16a34a;
--accent-soft: #dcfce7;
--accent-border: #86efac;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: var(--font);
background: #e8edf3;
color: var(--fg);
display: grid;
place-items: center;
padding: 24px;
}
.shell {
width: min(920px, 100%);
height: 640px;
background: var(--panel);
border-radius: 16px;
border: 1px solid rgba(15,23,42,.08);
box-shadow: 0 20px 50px rgba(15,23,42,.10);
display: grid;
grid-template-columns: 200px 1fr;
overflow: hidden;
}
.side {
background: #fbfcfd;
border-right: 1px solid var(--line);
padding: 18px 12px;
}
.side .item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.side .item.active {
background: #ecfdf5;
color: #166534;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
background: #fff;
}
.header {
padding: 18px 22px 10px;
border-bottom: 1px solid var(--line);
}
.header h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.body {
flex: 1;
overflow: auto;
padding: 16px 22px 24px;
}
/* underline tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--line);
margin-bottom: 18px;
}
.tab {
flex: 1;
border: 0;
background: transparent;
padding: 10px 8px 12px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 700;
}
.hint {
margin: -8px 0 16px;
color: var(--muted);
font-size: 12px;
}
.section {
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
}
.chip {
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
}
.chip.beta {
background: #ecfdf5;
color: #15803d;
}
.meta {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
background: #fff;
cursor: pointer;
text-align: left;
font: inherit;
}
.card:hover { border-color: #cbd5e1; }
.card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-border);
}
.card .preview {
height: 44px;
border-radius: 8px;
border: 1px solid var(--line);
overflow: hidden;
margin-bottom: 8px;
}
.preview.light {
background: linear-gradient(180deg, #f8fafc, #eef2f7);
padding: 8px;
display: grid;
gap: 4px;
}
.preview.dark {
background: linear-gradient(180deg, #0f172a, #111827);
padding: 8px;
display: grid;
gap: 4px;
}
.preview.system {
display: grid;
grid-template-columns: 1fr 1fr;
}
.bar {
height: 5px;
border-radius: 99px;
background: rgba(15,23,42,.12);
}
.preview.dark .bar { background: rgba(248,250,252,.18); }
.bar.accent { background: rgba(22,163,74,.4); width: 72%; }
.preview.dark .bar.accent { background: rgba(74,222,128,.5); width: 72%; }
.card .label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 700;
}
.check { color: var(--accent); font-size: 12px; }
.ui-preview {
height: 58px;
border-radius: 8px;
border: 1px solid var(--line);
overflow: hidden;
display: grid;
margin-bottom: 8px;
background: #f1f5f9;
}
.ui-preview.legacy { grid-template-columns: 34% 1fr; }
.ui-preview.v2 { grid-template-columns: 16px 1fr; }
.ui-side {
background: #fff;
border-right: 1px solid var(--line);
padding: 8px 6px;
display: grid;
gap: 5px;
align-content: start;
}
.ui-preview.v2 .ui-side {
background: linear-gradient(180deg, #16a34a, #15803d);
border: 0;
place-items: center;
padding: 8px 2px;
}
.dot {
width: 7px; height: 7px; border-radius: 99px;
background: rgba(15,23,42,.16);
}
.ui-preview.v2 .dot { background: rgba(255,255,255,.9); }
.ui-main { padding: 8px; display: grid; gap: 6px; }
.ui-main .row { height: 8px; border-radius: 4px; background: rgba(15,23,42,.08); }
.ui-main .row.a { width: 46%; background: rgba(22,163,74,.35); }
.ui-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; height: 24px; }
.ui-cards span {
border-radius: 5px;
background: #fff;
border: 1px solid rgba(15,23,42,.06);
}
.row-inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.row-inline .name {
font-size: 13px;
font-weight: 600;
}
.pills {
display: inline-flex;
gap: 6px;
}
.pill {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 6px 12px;
font: inherit;
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.pill.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
</style>
</head>
<body>
<div class="shell" id="fixture">
<aside class="side">
<div class="item active">偏好设置</div>
<div class="item">服务配置</div>
<div class="item">关于</div>
</aside>
<section class="main">
<div class="header">
<h1>主题与外观</h1>
<p>切换亮暗主题并调整界面观感。</p>
</div>
<div class="body">
<div class="tabs">
<button class="tab active" type="button">主题与界面</button>
<button class="tab" type="button">显示与字体</button>
<button class="tab" type="button">工作区</button>
</div>
<p class="hint">更改将立即生效,无需保存。</p>
<div class="section">
<div class="section-title">亮暗模式</div>
<div class="grid-3">
<button class="card" type="button">
<div class="preview light">
<div class="bar" style="width:55%"></div>
<div class="bar accent"></div>
<div class="bar" style="width:40%"></div>
</div>
<div class="label"><span>亮色主题</span></div>
</button>
<button class="card" type="button">
<div class="preview dark">
<div class="bar" style="width:55%"></div>
<div class="bar accent"></div>
<div class="bar" style="width:40%"></div>
</div>
<div class="label"><span>暗色主题</span></div>
</button>
<button class="card active" type="button">
<div class="preview system">
<div class="preview light" style="border:0;height:100%;border-radius:0;margin:0">
<div class="bar" style="width:55%"></div>
<div class="bar accent"></div>
<div class="bar" style="width:40%"></div>
</div>
<div class="preview dark" style="border:0;height:100%;border-radius:0;margin:0">
<div class="bar" style="width:55%"></div>
<div class="bar accent"></div>
<div class="bar" style="width:40%"></div>
</div>
</div>
<div class="label"><span>跟随系统</span><span class="check"></span></div>
</button>
</div>
</div>
<div class="section">
<div class="section-title">
界面版本
<span class="chip">NEW</span>
<span class="chip beta">Beta</span>
</div>
<div class="grid-2">
<button class="card" type="button">
<div class="ui-preview legacy">
<div class="ui-side"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<div class="ui-main">
<div class="row a"></div>
<div class="ui-cards"><span></span><span></span></div>
</div>
</div>
<div class="label"><span>旧版 UI · 默认</span></div>
</button>
<button class="card active" type="button">
<div class="ui-preview v2">
<div class="ui-side"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
<div class="ui-main">
<div class="row a"></div>
<div class="ui-cards"><span></span><span></span></div>
</div>
</div>
<div class="label"><span>新版 UI · Beta</span><span class="check"></span></div>
</button>
</div>
<p class="meta">全平台可切换,立即生效;部分弹窗下次打开时应用新样式。</p>
<div class="row-inline">
<div class="name">左侧搜索</div>
<div class="pills">
<button class="pill active" type="button">命令搜索</button>
<button class="pill" type="button">侧栏筛选</button>
</div>
</div>
</div>
</div>
</section>
</div>
</body>
</html>

View File

@@ -2,7 +2,7 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,6 +10,7 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const frontendDir = path.resolve(scriptDir, '..');
const packageJsonPath = path.join(frontendDir, 'package.json');
const packageLockPath = path.join(frontendDir, 'package-lock.json');
const patchesDirPath = path.join(frontendDir, 'patches');
const nodeModulesPath = path.join(frontendDir, 'node_modules');
const npmHiddenLockPath = path.join(nodeModulesPath, '.package-lock.json');
const installStatePath = path.join(nodeModulesPath, '.gonavi-install-state.json');
@@ -39,9 +40,27 @@ const hashFile = (filePath) => {
return hash.digest('hex');
};
const patchFiles = () => {
if (!existsSync(patchesDirPath)) return [];
return readdirSync(patchesDirPath)
.filter((name) => name.endsWith('.patch'))
.sort()
.map((name) => path.join(patchesDirPath, name));
};
const hashPatches = () => {
const hash = createHash('sha256');
for (const filePath of patchFiles()) {
hash.update(path.basename(filePath));
hash.update(readFileSync(filePath));
}
return hash.digest('hex');
};
const currentState = () => ({
packageJson: hashFile(packageJsonPath),
packageLock: existsSync(packageLockPath) ? hashFile(packageLockPath) : '',
patches: hashPatches(),
});
const readInstalledState = () => {
@@ -60,7 +79,7 @@ const writeInstalledState = (state) => {
const packageInputsAreOlderThanNpmLock = () => {
if (!existsSync(npmHiddenLockPath)) return false;
const markerTime = statSync(npmHiddenLockPath).mtimeMs;
return [packageJsonPath, packageLockPath]
return [packageJsonPath, packageLockPath, ...patchFiles()]
.filter(existsSync)
.every((filePath) => statSync(filePath).mtimeMs <= markerTime);
};
@@ -102,7 +121,8 @@ const forceInstall = process.env.GONAVI_FORCE_FRONTEND_INSTALL === '1';
if (!forceInstall && existsSync(nodeModulesPath)) {
if (
installedState?.packageJson === state.packageJson &&
installedState?.packageLock === state.packageLock
installedState?.packageLock === state.packageLock &&
installedState?.patches === state.patches
) {
console.log('Frontend dependencies are up to date; skipping npm install.');
process.exit(0);

View File

@@ -0,0 +1,54 @@
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const brandIconsSource = readFileSync(
fileURLToPath(new globalThis.URL('./brand/brandIcons.ts', import.meta.url)),
'utf8',
);
const brandIconsDirectory = fileURLToPath(
new globalThis.URL('../public/brand-icons/', import.meta.url),
);
const defaultTitlebarMark = fileURLToPath(
new globalThis.URL('../public/brand-marks/02-database-search-transparent.png', import.meta.url),
);
describe('about brand lockup', () => {
it('uses a transparent lockup without a tile background on the about page', () => {
expect(appSource).toContain('resolveBrandAboutSrc');
expect(appSource).toContain('src={resolveBrandAboutSrc(brandIconId)}');
const aboutLogoStart = appSource.indexOf('src={resolveBrandAboutSrc(brandIconId)}');
const aboutLogoSnippet = appSource.slice(aboutLogoStart, aboutLogoStart + 640);
expect(aboutLogoSnippet).toContain("background: 'transparent'");
expect(aboutLogoSnippet).toContain("boxShadow: 'none'");
});
it('uses the transparent compact mark without a forced titlebar tile', () => {
expect(appSource).toContain('src={resolveBrandTitlebarSrc(brandIconId)}');
const titlebarLogoStart = appSource.indexOf('src={resolveBrandTitlebarSrc(brandIconId)}');
const titlebarLogoSnippet = appSource.slice(titlebarLogoStart, titlebarLogoStart + 640);
expect(titlebarLogoSnippet).toContain("background: 'transparent'");
expect(readFileSync(defaultTitlebarMark, 'base64')).not.toHaveLength(0);
});
it('keeps one shared lossless WebP asset for every selectable mascot', () => {
const iconAssetPaths = brandIconsSource.match(/iconPath: '\/brand-icons\/\d{2}-.+\.webp'/g) || [];
expect(iconAssetPaths).toHaveLength(10);
for (const declaration of iconAssetPaths) {
const assetPath = declaration.match(/'([^']+)'/)?.[1];
expect(assetPath).toBeTruthy();
expect(readFileSync(`${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, 'utf8')).not.toBe('');
}
const webpFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.webp'));
expect(webpFiles).toHaveLength(10);
const legacyPngFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.png'));
expect(legacyPngFiles).toEqual([]);
});
});

View File

@@ -10,18 +10,37 @@ const aiPanelBoundarySource = readFileSync(
fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)),
'utf8',
);
const floatingAIWindowSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/FloatingAIChatWindow.tsx', import.meta.url)),
'utf8',
);
describe('AI panel lazy-load guard', () => {
it('keeps AI panel failures scoped to the panel area with retry support', () => {
expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';");
expect(appSource).not.toContain("import AIChatPanel from './components/AIChatPanel';");
expect(appSource).not.toContain("import { AISettingsContent } from './components/AISettingsModal';");
expect(appSource).toContain("const createLazyAIChatPanel = () => React.lazy(() => import('./components/AIChatPanel'));");
expect(appSource).toContain("const module = await import('./components/AISettingsModal');");
expect(floatingAIWindowSource).not.toContain("import AIChatPanel from './AIChatPanel';");
expect(floatingAIWindowSource).toContain("const createLazyAIChatPanel = () => React.lazy(() => import('./AIChatPanel'));");
expect(floatingAIWindowSource).toContain('const LazyAIChatPanel = useMemo(createLazyAIChatPanel, [renderNonce]);');
expect(floatingAIWindowSource).toContain('<React.Suspense');
expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';");
expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component');
expect(appSource).toContain('<AIPanelErrorBoundary');
expect(appSource).toContain('key={aiPanelRenderNonce}');
expect(appSource).toContain('AI 面板加载失败');
expect(appSource).toContain('重新加载');
expect(appSource).toContain('key={`ai-settings-${aiSettingsRenderNonce}`}');
expect(appSource).toContain("t('app.ai_panel.error.title')");
expect(appSource).toContain("t('app.ai_panel.action.reload')");
expect(appSource).toContain('setAiPanelRenderNonce((current) => current + 1)');
expect(appSource).toContain('<AIChatPanel width={aiPanelRenderWidth}');
expect(appSource).toContain('setAiSettingsRenderNonce((current) => current + 1)');
expect(appSource).toContain('const LazyAIChatPanel = useMemo(createLazyAIChatPanel, [aiPanelRenderNonce]);');
expect(appSource).toContain('const LazyAISettingsContent = useMemo(createLazyAISettingsContent, [aiSettingsRenderNonce]);');
expect(appSource).toContain('<Button size="small" onClick={handleRetryAISettingsRender}>');
expect(appSource).not.toContain('const LazyAISettingsContent = useMemo(createLazyAISettingsContent, [aiPanelRenderNonce]);');
expect(appSource).toContain('<LazyAIChatPanel');
expect(appSource).toContain('<LazyAISettingsContent');
expect(appSource).toContain('<React.Suspense');
expect(appSource).not.toContain('const loadAIChatPanelModule = async (retryNonce: number) => {');
});
});

View File

@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const appSource = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8');
const modalSource = readFileSync(
new URL('./components/common/ResizableDraggableModal.tsx', import.meta.url),
'utf8',
);
const floatingResultSource = readFileSync(
new URL('./components/FloatingQueryResultWindows.tsx', import.meta.url),
'utf8',
);
describe('App close-tab shortcut routing', () => {
it('tracks only explicit workspace, result, and blocked interaction scopes', () => {
expect(appSource).toContain("const closeShortcutScopeRef = useRef<CloseShortcutScope>('workspace');");
expect(appSource).toContain('resolveCloseShortcutScopeFromTarget(event.target)');
expect(appSource).toContain("document.addEventListener('pointerdown', handleExplicitCloseShortcutScope, true);");
expect(appSource).toContain("document.addEventListener('focusin', handleExplicitCloseShortcutScope, true);");
expect(appSource).toContain('data-gonavi-close-shortcut-scope="workspace"');
});
it('gives shortcut recording priority over every global action', () => {
const recorderGuardIndex = appSource.indexOf('if (capturingShortcutAction) {');
const closeDecisionIndex = appSource.indexOf('const closeDecision = resolveCloseShortcutKeydownDecision({');
expect(recorderGuardIndex).toBeGreaterThan(-1);
expect(closeDecisionIndex).toBeGreaterThan(recorderGuardIndex);
expect(appSource).toContain('setGlobalShortcutCaptureActive(Boolean(capturingShortcutAction));');
});
it('uses a single close decision before dispatching exactly one scoped command', () => {
expect(appSource).toContain('interactionBlocked: isCloseShortcutInteractionBlocked(event.target, document)');
expect(appSource).toContain("if (closeDecision.kind === 'consume') {");
expect(appSource).toContain('event.stopImmediatePropagation();');
expect(appSource).toContain("if (closeShortcutScopeRef.current === 'workspace') {");
expect(appSource).toContain('dispatchCloseActiveWorkspaceTab();');
expect(appSource).toContain("} else if (closeShortcutScopeRef.current === 'result') {");
expect(appSource).toContain('const targetTabId = resolveDockedActiveTabId(');
expect(appSource).toContain('const outcome = dispatchCloseActiveResultTab(targetTabId);');
});
it('enters blocked synchronously when the log tab hides the result area', () => {
const dispatchIndex = appSource.indexOf('const outcome = dispatchCloseActiveResultTab(targetTabId);');
const hiddenIndex = appSource.indexOf("if (outcome === 'hidden') {", dispatchIndex);
const blockedIndex = appSource.indexOf("closeShortcutScopeRef.current = 'blocked';", hiddenIndex);
expect(dispatchIndex).toBeGreaterThan(-1);
expect(hiddenIndex).toBeGreaterThan(dispatchIndex);
expect(blockedIndex).toBeGreaterThan(hiddenIndex);
});
it('does not let the close router steal a migrated shortcut from its prior owner', () => {
expect(appSource).toContain("const delegatedAction = closeDecision.kind === 'delegate'");
expect(appSource).toContain('if (delegatedAction && action !== delegatedAction) {');
expect(appSource).toContain("if (action === 'closeActiveTab') {");
});
});
describe('close shortcut interaction guards', () => {
it('marks active reusable modals as background blockers', () => {
expect(modalSource).toContain("data-gonavi-close-shortcut-guard={active ? 'true' : undefined}");
expect(modalSource).toContain("data-gonavi-close-shortcut-blocks-background={active ? 'true' : undefined}");
expect(modalSource).toContain('data-gonavi-close-shortcut-blocks-background="true"');
});
it('marks detached result windows as blocked without globally blocking their existence', () => {
expect(floatingResultSource).toContain('data-gonavi-close-shortcut-guard="true"');
expect(floatingResultSource).toContain('data-gonavi-close-shortcut-scope="blocked"');
expect(floatingResultSource).not.toContain('data-gonavi-close-shortcut-blocks-background="true"');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,303 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const appCssSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
'utf8',
);
const aiSettingsModalSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/AISettingsModal.tsx', import.meta.url)),
'utf8',
);
describe('settings center layout', () => {
it('hosts settings and tools in one split navigation shell', () => {
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | ToolCenterGroupKey | 'about';");
expect(appSource).toContain('type SettingsCenterPaneKey =');
expect(appSource).toContain('| ToolCenterPaneKey');
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
expect(appSource).toContain("title: t('app.settings.group.preferences.title')");
expect(appSource).toContain("title: t('app.settings.group.services.title')");
expect(appSource).toContain("title: t('app.tools.group.config.title')");
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
expect(appSource).toContain("title: t('app.settings.group.about.title')");
expect(appSource).toContain('const combinedSettingsCenterGroups = [');
expect(appSource).not.toContain('const [isToolsModalOpen');
expect(appSource).not.toContain('{isToolsModalOpen &&');
});
it('moves sidebar table metadata configuration into the settings center', () => {
expect(appSource).toContain("key: 'sidebar-metadata'");
expect(appSource).toContain("title: t('app.settings.sidebar_metadata.title')");
expect(appSource).toContain("description: t('app.settings.sidebar_metadata.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-metadata')");
expect(appSource).toContain("setSidebarTableMetadataFieldSelected(");
expect(appSource).toContain('DndContext');
expect(appSource).toContain('SortableContext');
expect(appSource).toContain('handleSidebarMetadataDragEnd');
expect(appSource).toContain('sidebarTableMetadataFieldOrder');
expect(appSource).toContain('data-sidebar-metadata-field={field}');
expect(appSource).toContain("sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS");
expect(appSource).toContain("t('sidebar.v2_table_group_menu.display_table_rows')");
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
});
it('adds persistent sidebar object visibility controls to preferences', () => {
expect(appSource).toContain("key: 'sidebar-objects'");
expect(appSource).toContain("title: t('app.settings.sidebar_objects.title')");
expect(appSource).toContain("description: t('app.settings.sidebar_objects.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-objects')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'sidebar-objects')");
expect(appSource).toContain('renderSidebarObjectVisibilitySettingsPane();');
expect(appSource).toContain('sidebarHiddenObjectGroups');
expect(appSource).toContain('SIDEBAR_OBJECT_GROUP_KEYS.filter((key) => key !== \'tables\')');
});
it('adds browser auth management into the services settings group', () => {
expect(appSource).toContain("key: 'web-auth' as const");
expect(appSource).toContain("title: t('app.settings.entry.web_auth.title')");
expect(appSource).toContain("description: t('app.settings.entry.web_auth.description')");
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'web-auth')");
expect(appSource).toContain("<WebAuthSettingsPanel");
});
it('adds global proxy connection testing controls', () => {
expect(appSource).toContain("const DEFAULT_GLOBAL_PROXY_TEST_URL = 'https://api.github.com/';");
expect(appSource).toContain('const [proxyTestUrl, setProxyTestUrl]');
expect(appSource).toContain('const [proxyTesting, setProxyTesting]');
expect(appSource).toContain('const [proxyTestResult, setProxyTestResult]');
expect(appSource).toContain('handleTestGlobalProxyDraft');
expect(appSource).toContain('TestGlobalProxyConnection');
expect(appSource).toContain('https://github.com/Syngnat/GoNavi/releases/latest');
expect(appSource).toContain("t('app.proxy.test.action')");
expect(appSource).toContain("t('app.proxy.test.target_placeholder')");
});
it('adds close and back-to-settings actions to settings center detail panes', () => {
expect(appSource).toContain('const handleBackFromSettingsCenterPane = useCallback(() => {');
expect(appSource).toContain('const handleCancelSettingsCenterPane = useCallback(() => {');
expect(appSource).toContain('onClick={handleCancelSettingsCenterPane}');
expect(appSource).toContain("t('common.close')");
expect(appSource).toContain('onClick={handleBackFromSettingsCenterPane}');
expect(appSource).toContain("t('common.back_to_settings')");
});
it('clears embedded tool transient state before switching settings groups or panes', () => {
const cleanupStart = appSource.indexOf('const clearSettingsCenterTransientPaneState = useCallback(() => {');
const cleanupSource = appSource.slice(
cleanupStart,
appSource.indexOf('const handleOpenToolsModal', cleanupStart),
);
expect(cleanupStart).toBeGreaterThan(-1);
expect(cleanupSource).toContain('setCapturingShortcutAction(null);');
expect(cleanupSource).toContain("activeSettingsCenterPaneRef.current?.key === 'connection-package'");
expect(cleanupSource).toContain('closeConnectionPackageDialog();');
expect(cleanupSource).toContain("activeSettingsCenterPaneRef.current?.key === 'ai'");
expect(cleanupSource).toContain('setFocusedAIProviderId(undefined);');
expect(cleanupSource).toContain('setSecurityUpdateRepairSource(null);');
expect(appSource).toContain('const handleOpenSettingsModal = useCallback');
expect(appSource).toContain('const handleOpenToolCenterPane = useCallback');
expect(appSource.match(/clearSettingsCenterTransientPaneState\(\);/g)?.length).toBeGreaterThanOrEqual(4);
});
it('routes every security-update detail and repair return through settings center', () => {
const openDetailsStart = appSource.indexOf('const openSecurityUpdateSettings = useCallback(');
const openDetailsSource = appSource.slice(
openDetailsStart,
appSource.indexOf('const handleOpenSecurityUpdateSettings', openDetailsStart),
);
expect(openDetailsStart).toBeGreaterThan(-1);
expect(openDetailsSource).toContain("setActiveSettingsCenterGroupKey('config');");
expect(openDetailsSource).toContain("setActiveSettingsCenterPane({ key: 'security-update', group: 'config' });");
expect(openDetailsSource).toContain('setIsSettingsModalOpen(true);');
expect(appSource).toContain("const detailsWereOpen = isSettingsModalOpen && activeSettingsCenterPane?.key === 'security-update';");
expect(appSource.match(/<SecurityUpdateSettingsModal/g)?.length).toBe(1);
expect(appSource).toMatch(/<SecurityUpdateSettingsModal\r?\n\s+embedded/);
expect(appSource).not.toContain('isSecurityUpdateSettingsOpen');
expect(appSource).not.toContain('setIsSecurityUpdateSettingsOpen');
});
it('uses a consistent close footer while keeping the theme instant-apply hint', () => {
expect(appSource).toContain("t('common.back_to_settings')");
expect(appSource).toContain("t('common.close')");
expect(appSource).toContain("t('app.theme.instant_apply_hint')");
});
it('gates the new theme layout to v2 and keeps legacy side nav for old UI', () => {
expect(appSource).toContain('renderThemeSettingsContentV2');
expect(appSource).toContain('renderThemeSettingsContentLegacy');
expect(appSource).toContain('isV2Ui ? renderThemeSettingsContentV2() : renderThemeSettingsContentLegacy()');
expect(aiSettingsModalSource).toContain("gridTemplateColumns: '168px minmax(0, 1fr)', gap: 0, padding: '10px 0'");
expect(aiSettingsModalSource).toContain('className="ai-settings-body gonavi-ai-settings-flat"');
expect(appSource).toContain('className="gonavi-theme-settings"');
expect(appSource).toContain('ThemeSettingsSlider');
expect(appSource).toContain("t('app.theme.custom.title')");
expect(appSource).toContain('<CustomThemeManager />');
expect(appSource).toContain('<CustomThemeManager legacyMode />');
expect(appSource).toContain("value: 'workspace'");
expect(appSource).toContain('gonavi-settings-tabs');
expect(appSource).toContain('setThemeModalSection(item.value)');
});
it('resolves custom-theme base mode synchronously and bridges its surfaces into Ant Design', () => {
expect(appSource).toContain("const resolvedThemeMode = effectiveThemePreference === 'system'");
expect(appSource).toContain("const darkMode = resolvedThemeMode === 'dark';");
expect(appSource).toContain('const customThemeStyleContextKey = `${resolvedThemeMode}:${appearance.uiVersion}`;');
expect(appSource).toContain('colorBgContainer: (isV2Ui ? v2AntBgContainer : undefined)');
expect(appSource).toContain('colorBgElevated: (isV2Ui ? v2AntBgElevated : undefined)');
expect(appSource).toContain('colorTextSecondary: v2AntTextSecondary');
expect(appSource).toContain('rowHoverBg: (isV2Ui ? v2AntRowHoverBg : undefined)');
});
it('opens theme, AI, and about entries inside settings center detail panes', () => {
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'theme')");
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'ai')");
expect(appSource).toContain("handleOpenSettingsCenterPane('about', 'about-go-navi')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'theme')");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'ai')");
expect(appSource).toContain('<LazyAISettingsContent');
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'about-go-navi')");
expect(appSource).toContain('renderSettingsCenterAboutPane()');
});
it('opens AI settings from the chat panel via settings center instead of a standalone modal', () => {
expect(appSource).toContain('const handleOpenAISettings = useCallback((providerId?: string) => {');
expect(appSource).toContain("setActiveSettingsCenterPane({ key: 'ai', group: 'services' })");
expect(appSource).toContain('setIsSettingsModalOpen(true)');
expect(appSource).not.toContain('setIsAISettingsOpen(true)');
expect(appSource).not.toContain('<AISettingsModal');
});
it('keeps the settings center above the in-webview detached AI fallback', () => {
const settingsModalStart = appSource.indexOf(
"title={renderUtilityModalTitle(<SettingOutlined />, t('app.settings.title')",
);
const settingsModalSource = appSource.slice(settingsModalStart, settingsModalStart + 900);
expect(settingsModalStart).toBeGreaterThan(-1);
expect(appSource).toContain('APP_FOREGROUND_MODAL_Z_INDEX,');
expect(appSource).toContain('APP_NESTED_MODAL_Z_INDEX,');
expect(appSource).toContain('const settingsCenterModalZIndex = Math.max(');
expect(appSource).toContain('Number.isFinite(detachedAIChatZIndex) ? detachedAIChatZIndex + 1 : APP_FOREGROUND_MODAL_Z_INDEX');
expect(appSource).toContain('const settingsChildModalZIndex = Math.max(');
expect(appSource).toContain('settingsCenterModalZIndex + 100');
expect(settingsModalSource).toContain('zIndex={settingsCenterModalZIndex}');
});
it('opens the about group directly instead of showing a one-item list', () => {
expect(appSource).toContain('const resolveSettingsCenterGroupInitialPane = (group: SettingsCenterGroupKey): SettingsCenterPaneState | null => (');
expect(appSource).toContain("group === 'about' ? { key: 'about-go-navi', group: 'about' } : null");
expect(appSource).toContain('setActiveSettingsCenterPane(resolveSettingsCenterGroupInitialPane(group));');
expect(appSource).toContain('handleOpenSettingsModal(group.key);');
});
it('routes silent update discovery to the settings center about pane via bridge', () => {
expect(appSource).toContain('const updateCenterBridgeRef = useRef<{');
expect(appSource).toContain('updateCenterBridgeRef,');
expect(appSource).toContain('updateCenterBridgeRef.current = {');
expect(appSource).toContain("handleOpenSettingsCenterPane('about', 'about-go-navi')");
expect(appSource).toContain('prepareAboutSurface');
expect(appSource).toContain('isSettingsAboutPaneOpen');
});
it('renders the settings center about page as flat sections without nested cards', () => {
const projectEntryStart = appSource.indexOf('const renderSettingsCenterAboutProjectEntry = ({');
const aboutPaneStart = appSource.indexOf('const renderSettingsCenterAboutPane = () => {');
const aboutPaneEnd = appSource.indexOf('const renderSettingsCenterAboutFooter = () => (', aboutPaneStart);
const projectEntrySource = appSource.slice(projectEntryStart, aboutPaneStart);
const aboutPaneSource = appSource.slice(aboutPaneStart, aboutPaneEnd);
expect(projectEntryStart).toBeGreaterThan(-1);
expect(aboutPaneStart).toBeGreaterThan(projectEntryStart);
expect(aboutPaneEnd).toBeGreaterThan(aboutPaneStart);
expect(appSource).toContain('const renderSettingsCenterAboutPane = () => {');
expect(appSource).toContain('const renderSettingsCenterAboutProjectEntry = ({');
expect(appSource).toContain("activeSettingsCenterPane?.key === 'about-go-navi'");
expect(appSource).toContain("padding: '0 4px 0 0'");
expect(appSource).toContain("border: 'none'");
expect(appSource).toContain("background: 'transparent'");
expect(aboutPaneSource).toContain('className="gonavi-about-pane"');
expect(aboutPaneSource).toContain('aria-labelledby="gonavi-about-version-heading"');
expect(aboutPaneSource).toContain('aria-labelledby="gonavi-about-project-heading"');
expect(aboutPaneSource).toContain("gridTemplateColumns: 'minmax(0, 1.15fr) minmax(260px, 0.85fr)'");
expect(aboutPaneSource).not.toContain('cardBorder');
expect(aboutPaneSource).not.toContain('cardBg');
expect(aboutPaneSource).not.toContain('borderRadius');
expect(projectEntrySource).toContain('className="gonavi-about-project-entry"');
expect(projectEntrySource).toContain("border: 'none'");
expect(projectEntrySource).toContain("background: 'transparent'");
expect(projectEntrySource).not.toContain('borderRadius');
expect(appCssSource).toContain('.gonavi-about-project-entry:hover:not(:disabled)');
expect(appSource).toContain('width={92}');
expect(appSource).toContain('height={92}');
expect(appSource).toContain('width: 92');
expect(appSource).toContain('height: 92');
expect(appSource).toContain('const releaseTimeText = formatAboutReleaseTime(lastUpdateInfo?.releasePublishedAt);');
expect(appSource).toContain("[t('app.about.version.release_time'), releaseTimeText]");
expect(appSource).toContain("installMode === 'msi' || installMode === 'portable'");
expect(appSource).toContain("[t('app.about.version.install_mode'), t(`app.about.install_mode.${installMode}`)]");
expect(appSource).toContain("hasUpdate && packageType !== 'unknown'");
expect(appSource).toContain("[t('app.about.version.package_type'), t(`app.about.package_type.${packageType}`)]");
expect(appSource).toContain('className="gonavi-about-update-channel"');
expect(appSource).toContain('<Segmented');
expect(appSource).toContain("t('app.about.field.auto_check_updates')");
expect(appSource).toContain("t('app.about.field.auto_check_interval')");
expect(appSource).toContain("t('app.about.version_update.auto_check_hint')");
expect(appSource).toContain("t('app.about.version_update.auto_check_disabled_hint')");
expect(appSource).toContain('className="gonavi-about-auto-check-interval"');
expect(appSource).toContain('checked={autoCheckForUpdates}');
expect(appSource).toContain('setAutoCheckForUpdates(checked)');
expect(appSource).toContain('setAutoCheckForUpdatesIntervalMinutes(Number(value))');
expect(appSource).toContain('maxWidth: 360');
expect(appSource).toContain("alignItems: 'start'");
expect(appSource).toContain("overflowWrap: 'anywhere'");
expect(appSource).toContain("t('app.about.version_update.title')");
expect(appSource).toContain("t('app.about.project.github.title')");
expect(appSource).toContain("t('app.about.project.issues.title')");
expect(appSource).toContain("t('app.about.project.releases.title')");
expect(appSource).toContain('const renderSettingsCenterAboutFooter = () => (');
expect(appSource).toContain("t('app.about.last_checked_at', { time: aboutLastCheckedAt })");
expect(appSource).toContain('renderAboutUpdateActions()');
});
it('uses one flat detail shell across every settings page', () => {
expect(appSource).toContain('const activeSettingsCenterDetailPanelStyle: React.CSSProperties = {');
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
expect(appSource).toContain("padding: '0 4px 0 0'");
expect(appSource).toContain("background: 'transparent'");
expect(aiSettingsModalSource).toContain('className="ai-settings-body gonavi-ai-settings-flat"');
expect(aiSettingsModalSource).toContain("gap: 0, padding: '10px 0'");
expect(aiSettingsModalSource).toContain("padding: '0 6px 24px 22px'");
});
it('keeps embedded split-pane settings stable at scroll boundaries', () => {
expect(appSource).toContain('const isSettingsCenterContainedScrollPane =');
expect(appSource).toContain("activeSettingsCenterPane?.key === 'theme' || activeSettingsCenterPane?.key === 'ai'");
expect(appSource).toContain('const settingsCenterDetailBodyStyle: React.CSSProperties = isSettingsCenterContainedScrollPane');
expect(appSource).toContain("overflowY: 'hidden'");
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
expect(appSource).toContain("boxSizing: 'border-box'");
expect(appSource).toContain("overscrollBehavior: 'contain'");
expect(aiSettingsModalSource).toContain("boxSizing: 'border-box'");
expect(aiSettingsModalSource).toContain("overscrollBehavior: 'contain'");
});
});

View File

@@ -10,10 +10,26 @@ const appCss = readFileSync(
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
'utf8',
);
const v2ThemeCss = readFileSync(
fileURLToPath(new globalThis.URL('./v2-theme.css', import.meta.url)),
'utf8',
);
const linuxCJKFontBannerSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
'utf8',
);
const appUtilityStylesSource = readFileSync(
fileURLToPath(new globalThis.URL('./hooks/useAppUtilityStyles.tsx', import.meta.url)),
'utf8',
);
const appSidebarResizeSource = readFileSync(
fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)),
'utf8',
);
const sidebarLayoutSource = readFileSync(
fileURLToPath(new globalThis.URL('./utils/sidebarLayout.ts', import.meta.url)),
'utf8',
);
const getGlobalShortcutCaseBlock = (action: string) => {
const caseToken = `case '${action}':`;
@@ -29,12 +45,26 @@ const getGlobalShortcutCaseBlock = (action: string) => {
return afterCase.slice(0, endIndex);
};
describe('tool center menu entries', () => {
describe('settings center tool entries', () => {
it('opens data workflows as stable workspace tabs instead of settings-center modals', () => {
expect(appSource).toContain("import { buildDataSyncWorkbenchTab } from './utils/dataSyncTab';");
expect(appSource).toContain('const handleOpenDataSyncWorkbench = useCallback((entryMode: DataSyncEntryMode) => {');
expect(appSource).toContain('addTab(buildDataSyncWorkbenchTab({ entryMode }));');
expect(appSource).toContain("handleOpenDataSyncWorkbench('schemaCompare');");
expect(appSource).toContain("handleOpenDataSyncWorkbench('dataCompare');");
expect(appSource).toContain("handleOpenDataSyncWorkbench('sync');");
expect(appSource).not.toContain("import DataSyncModal from './components/DataSyncModal';");
expect(appSource).not.toContain('isSyncModalOpen');
expect(appSource).not.toContain('syncModalEntryMode');
});
it('exposes snippet management next to shortcut management', () => {
expect(appSource).toContain("key: 'snippet-settings'");
expect(appSource).toContain("title: t('app.tools.entry.snippets.title')");
expect(appSource).toContain("description: t('app.tools.entry.snippets.description')");
expect(appSource).toContain('setIsSnippetModalOpen(true)');
expect(appSource).toContain("handleOpenToolCenterPane('workspace', 'snippet-settings')");
expect(appSource).toContain('gonavi:open-snippet-settings');
expect(appSource).not.toContain('isSnippetModalOpen');
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
@@ -42,56 +72,140 @@ describe('tool center menu entries', () => {
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
});
it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => {
it('uses the settings center side navigation for every tool group', () => {
expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';");
expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');");
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | ToolCenterGroupKey | 'about';");
expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);");
expect(appSource).toContain("title: t('app.tools.group.config.title')");
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)");
expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'");
expect(appSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain('const combinedSettingsCenterGroups = [');
expect(appSource).toContain('(group) => group.key === activeSettingsCenterGroupKey');
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("gridTemplateColumns: '204px minmax(0, 1fr)'");
expect(appUtilityStylesSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain('role="tablist" aria-orientation="vertical"');
expect(appSource).toContain('role="tab"');
expect(appSource).toContain('aria-selected={active}');
expect(appSource).toContain('title={`${group.title} - ${group.description}`}');
expect(appSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
expect(appSource).toContain('setActiveToolCenterPane(null);');
expect(appSource).toContain('group.items.length');
expect(appUtilityStylesSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
expect(appSource).toContain('setActiveSettingsCenterPane(null);');
expect(appSource).not.toContain('{group.items.length}');
expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {");
expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);");
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {");
expect(appSource).toContain("t('common.back_to_previous')");
expect(appSource).toContain("t('common.back_to_settings')");
expect(appSource).toContain("width={1080}");
expect(appSource).toContain('centered');
expect(appSource).not.toContain('const [isToolsModalOpen');
});
it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => {
expect(appSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
expect(appSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
expect(appSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
it('keeps the unified settings modal height fixed across group switches and scrolls the list area internally', () => {
expect(appUtilityStylesSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
expect(appUtilityStylesSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
expect(appUtilityStylesSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
expect(appUtilityStylesSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
expect(appSource).toContain("overflowY: 'auto'");
expect(appUtilityStylesSource).toContain("overflowY: 'auto'");
expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'");
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
});
it('keeps browser-compatible connection transfer and mounted data-root entries available in the web runtime', () => {
const toolGroupsStart = appSource.indexOf('const toolCenterGroups: SettingsCenterNavigationGroup[] = [');
const configGroupStart = appSource.indexOf("key: 'config',", toolGroupsStart);
const configGroupSource = appSource.slice(
configGroupStart,
appSource.indexOf("key: 'workflow',", configGroupStart),
);
expect(toolGroupsStart).toBeGreaterThan(-1);
expect(configGroupStart).toBeGreaterThan(toolGroupsStart);
expect(appSource).toContain("accept=\".gonavi-conn,.json,.xml,.ncx\"");
expect(appSource).toContain('ExportConnectionsPayload');
expect(appSource).toContain('downloadBrowserTextFile');
expect(appSource).toContain("__GONAVI_WEB_RUNTIME__?.buildType === 'web'");
expect(appSource).toMatch(/if \(isWebRuntime\) \{\r?\n\s+return \(/);
expect(configGroupSource).toContain("key: 'data-root'");
expect(configGroupSource).toContain("handleOpenToolCenterPane('config', 'data-root')");
expect(appSource).toContain('...toolCenterGroups,');
});
it('lets the tool center detail header own embedded tool titles', () => {
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
const renderPaneSource = appSource.slice(
renderPaneStart,
appSource.indexOf('};\n\n return (', renderPaneStart),
);
const connectionPackageSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'connection-package')"),
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'data-root')"),
);
const dataRootSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'data-root')"),
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'security-update')"),
);
const securityUpdateSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'security-update')"),
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'drivers')"),
);
const driverSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'drivers')"),
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'snippet-settings')"),
);
const snippetSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'snippet-settings')"),
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')"),
);
const shortcutSource = renderPaneSource.slice(
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')"),
renderPaneSource.indexOf('return null;', renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')")),
);
expect(appSource).toContain('activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title');
expect(connectionPackageSource).toContain('<ConnectionPackagePasswordModal');
expect(connectionPackageSource).not.toContain('renderUtilityModalTitle');
expect(dataRootSource).toContain('title={null}');
expect(dataRootSource).toContain('closable={false}');
expect(dataRootSource).not.toContain('renderUtilityModalTitle');
expect(securityUpdateSource).toContain('<SecurityUpdateSettingsModal');
expect(securityUpdateSource).not.toContain('renderUtilityModalTitle');
expect(renderPaneSource).not.toContain('<DataSyncModal');
expect(driverSource).toContain('<DriverManagerModal');
expect(driverSource).not.toContain('renderUtilityModalTitle');
expect(snippetSource).toContain('<SnippetSettingsModal');
expect(snippetSource).not.toContain('renderUtilityModalTitle');
expect(shortcutSource).toContain('title={null}');
expect(shortcutSource).toContain('closable={false}');
expect(shortcutSource).not.toContain('renderUtilityModalTitle');
});
it('does not render an extra top back button in tool detail headers', () => {
const combinedGroupsIndex = appSource.indexOf('combinedSettingsCenterGroups.map');
const detailHeaderSource = appSource.slice(
appSource.indexOf('{activeSettingsCenterPane ? (', combinedGroupsIndex),
appSource.indexOf('key={activeSettingsCenterPane.key}', combinedGroupsIndex),
);
expect(detailHeaderSource).toContain('activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title');
expect(detailHeaderSource).toContain('activeSettingsCenterPaneItem?.description ?? activeSettingsCenterGroup.description');
expect(detailHeaderSource).not.toContain('<Button onClick={closeToolCenterPane}>');
expect(detailHeaderSource).not.toContain("{t('common.back_to_settings')}");
});
it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => {
expect(appSource).toContain('onToggleAI={toggleAIPanel}');
expect(appSource).toContain('renderLegacyAIEdgeHandle');
@@ -106,22 +220,37 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('const handleOpenToolsModal = useCallback(');
expect(appSource).toContain('const handleOpenSettingsModal = useCallback(');
expect(appSource).toContain('const handleToggleLogPanel = useCallback(');
expect(appSource).toContain('new CustomEvent');
expect(appSource).toContain("'gonavi:show-sql-execution-log'");
expect(appSource).toContain("detail: { mode: 'open' }");
expect(appSource).toContain('toggleAppLogPanel();');
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
expect(appSource).toContain('theme={antdTheme}');
expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);');
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
expect(appSource).toContain('sqlLogCount={sqlLogCount}');
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
expect(appSource).toContain('onOpenAISettings={handleOpenAISettings}');
expect(appSource).not.toContain('onOpenTools=');
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
expect(appSource).not.toContain('sqlLogCount={sqlLogCount}');
expect(appSource).not.toContain('theme={{');
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
});
it('renders the shared SQL log panel only for legacy layouts', () => {
const logPanelIndex = appSource.indexOf('<LogPanel', appSource.indexOf('<Content'));
const logPanelGuardIndex = appSource.lastIndexOf('{isLogPanelOpen && (', logPanelIndex);
const legacyOnlyGuardIndex = appSource.lastIndexOf('{!isV2Ui && isLogPanelOpen && (', logPanelIndex);
expect(logPanelIndex).toBeGreaterThan(-1);
expect(logPanelGuardIndex).toBe(-1);
expect(legacyOnlyGuardIndex).toBeGreaterThan(-1);
expect(appSource).toContain('onClose={handleCloseLogPanel}');
expect(appSource).toContain('onResizeStart={handleLogResizeStart}');
});
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
@@ -152,15 +281,30 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
});
it('keeps the green v2 accent as fallback while allowing custom CSS tokens to override it', () => {
expect(appSource).toContain("const v2AntPrimaryColor = customThemeAntTokens.primary ?? (darkMode ? '#22c55e' : '#16a34a');");
expect(appSource).toContain("const v2AntPrimaryContrastColor = customThemeAntTokens.primaryContrast ?? '#ffffff';");
expect(appSource).toContain('extractCustomThemeAntTokens(activeCustomTheme.css)');
expect(appSource).toContain('resolveAvailableCustomTheme(customThemes, activeCustomThemeId)');
expect(appSource).toContain('colorTextLightSolid: isV2Ui ? v2AntPrimaryContrastColor');
expect(appSource).toContain("colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff')");
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedBg/);
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedText/);
expect(appSource).toContain("background: 'transparent'");
expect(appSource).toMatch(/color:\s*active\s*\?\s*overlayTheme\.iconColor/);
expect(appSource).toContain("background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)')");
expect(appSource).toContain("color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff')");
});
it('does not start sidebar resize from right-clicking the resize handle', () => {
expect(appSource).toContain('if (e.button !== 0)');
expect(appSidebarResizeSource).toContain('if (e.button !== 0)');
expect(appSource).toContain('onContextMenu={(event) => {');
expect(appSource).toContain('event.preventDefault();');
expect(appSource).toContain('event.stopPropagation();');
const guardIndex = appSource.indexOf('if (e.button !== 0)');
const ghostDisplayIndex = appSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
const dragStartIndex = appSource.indexOf('sidebarDragRef.current = {', guardIndex);
const guardIndex = appSidebarResizeSource.indexOf('if (e.button !== 0)');
const ghostDisplayIndex = appSidebarResizeSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
const dragStartIndex = appSidebarResizeSource.indexOf('sidebarDragRef.current = {', guardIndex);
expect(guardIndex).toBeGreaterThan(-1);
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
@@ -168,30 +312,45 @@ describe('tool center menu entries', () => {
});
it('positions sidebar resize guide from the rendered sider edge', () => {
expect(appSource).toContain('const siderRef = React.useRef<HTMLDivElement | null>(null);');
expect(appSidebarResizeSource).toContain('const siderRef = useRef<HTMLDivElement | null>(null);');
expect(appSource).toContain('ref={siderRef}');
expect(appSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
expect(appSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
expect(appSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
expect(appSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
expect(appSidebarResizeSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
expect(appSidebarResizeSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
expect(appSidebarResizeSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
expect(appSidebarResizeSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
});
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
it('keeps sidebar resize bounds aligned across drag logic and sider CSS limits', () => {
expect(sidebarLayoutSource).toContain('export const SIDEBAR_RESIZE_MAX_WIDTH = 960;');
expect(sidebarLayoutSource).toContain('export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360;');
expect(appSidebarResizeSource).toContain('resolveSidebarResizeMaxWidth(window.innerWidth, minWidth)');
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
});
it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => {
it('keeps connection modal warm-mounted while leaving the remaining heavyweight modals conditional', () => {
expect(appSource).toContain('const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);');
expect(appSource).toContain('{isConnectionModalMounted && (');
expect(appSource).toContain('{isToolsModalOpen && (');
expect(appSource).toContain('{isSettingsModalOpen && (');
expect(appSource).toContain('{isThemeModalOpen && (');
expect(appSource).toContain('{isShortcutModalOpen && (');
expect(appSource).toContain('{isAISettingsOpen && (');
expect(appSource).not.toContain('{isToolsModalOpen && (');
expect(appSource).not.toContain('{isShortcutModalOpen && (');
expect(appSource).not.toContain('{isAISettingsOpen && (');
expect(appSource).toContain('{isDriverModalOpen && (');
expect(appSource).toContain('{isSyncModalOpen && (');
expect(appSource).not.toContain('{isSyncModalOpen && (');
});
it('recreates the hidden download progress portal above dialogs opened later', () => {
const start = appSource.indexOf('title={updateDownloadProgress.version');
const end = appSource.indexOf('</Modal>', start);
const downloadProgressModalSource = appSource.slice(start, end);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
expect(downloadProgressModalSource).toContain('open={updateDownloadProgress.open}');
expect(downloadProgressModalSource).toContain('destroyOnHidden');
});
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
@@ -205,8 +364,11 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('setIsModalOpen(true);');
});
it('loads editable AI provider details before opening the edit modal so stored api keys can be shown', () => {
expect(appSource).toContain('<AISettingsModal');
it('loads editable AI provider details inside settings-center AI pane content', () => {
// 聊天/入口打开 AI 配置走设置中心 AISettingsContent不再挂独立 AISettingsModal
expect(appSource).toContain('<LazyAISettingsContent');
expect(appSource).toContain("activeSettingsCenterPane.key === 'ai'");
expect(appSource).not.toContain('<AISettingsModal');
const modalSource = readFileSync(new URL('./components/AISettingsModal.tsx', import.meta.url), 'utf8');
expect(modalSource).toContain("typeof Service?.AIGetEditableProvider === 'function'");
expect(modalSource).toContain('await Service.AIGetEditableProvider(p.id)');
@@ -216,10 +378,10 @@ describe('tool center menu entries', () => {
expect(appSource).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
});
it('keeps shortcut manager scrolling inside the modal body', () => {
it('keeps shortcut manager scrolling inside the embedded settings pane', () => {
expect(appSource).toContain('centered');
expect(appSource).toContain("height: 'min(760px, calc(100vh - 80px))'");
expect(appSource).toContain("maxHeight: 'calc(100vh - 80px)'");
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'shortcut-settings')");
expect(appSource).toContain('embedded');
expect(appSource).toContain("body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
expect(appSource).toContain('data-gonavi-shortcut-modal-scroll="true"');
expect(appSource).toContain("height: '100%'");
@@ -239,10 +401,10 @@ describe('tool center menu entries', () => {
['switchToNextTab', 'switchActiveTabByOffset(1);'],
['switchToPreviousTab', 'switchActiveTabByOffset(-1);'],
['newConnection', 'handleCreateConnection();'],
['toggleAIPanel', 'toggleAIPanel();'],
['toggleAIPanel', 'toggleOrFocusNativeAIChatFromMainWindow()'],
['toggleLogPanel', 'handleToggleLogPanel();'],
['toggleTheme', 'setTheme('],
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
['toggleTheme', 'selectPresetTheme('],
['openShortcutManager', "handleOpenToolCenterPane('workspace', 'shortcut-settings');"],
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
['resetWindowZoom', 'handleManualResetWindowZoom();'],
]);
@@ -268,16 +430,75 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('该异常不一定表现为 viewport ratio drift');
});
it('settles Windows cold-start window layout without requiring a taskbar restore', () => {
expect(appSource).toContain("const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {");
expect(appSource).toContain('markStartupWindowRestorePending(3200)');
expect(appSource).toContain("applyStartupWindowChrome(1, 'maximised');");
expect(appSource).toContain('const delayMs = attempt <= 1 ? 0 : applyRetryDelayMs');
expect(appSource).toContain('markAppliedMaximisedOrFullscreen');
expect(appSource).toContain('shouldPreferWindowsStartupMaximise(bounds, viewport)');
expect(appSource).toContain('applyWindowsWorkAreaFillFallback');
expect(appSource).toContain('resolveWorkAreaFillWindowBounds(readCurrentVisibleViewport())');
expect(appSource).toContain('restoreNormalWindowBounds');
expect(appSource).toContain("void fixWindowScaleIfNeeded('startup');");
expect(appSource).toContain('const startupLayoutFixTimers = [220, 1000, 1900].map((delayMs) => (');
expect(appSource).toContain('if (isStartupWindowRestorePending())');
expect(appSource).toContain('clearStartupWindowRestorePending();');
// 启动恢复顺序:开关最大化 → 记忆最大化 → 记忆尺寸
expect(appSource).toContain('// 1) 「启动时最大化」开关优先Windows 按 Maximize 处理)');
expect(appSource).toContain('// 2) 记忆用户上次窗口态:最大化/全屏');
expect(appSource).toContain('// 3) 普通窗口:恢复用户调整过的尺寸和位置');
});
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
expect(appSource).toContain('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
expect(appSource).toContain('if (hydrated) {');
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
expect(appSource).toContain('scheduleWindowStateSave(320);');
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
expect(appSource).toContain('scheduleWindowBoundsRepair();');
expect(appSource).toContain('scheduleWindowStateSave(260);');
expect(appSource).toContain('const cleanupWindowActivityScheduler = installNativeWindowActivityScheduler({');
expect(appSource).toContain('fallbackIntervalMs: WINDOW_STATE_FALLBACK_INTERVAL_MS,');
expect(appSource).toContain('resize: handleWindowRuntimeChange,');
expect(appSource).toContain('focus: handleWindowRuntimeChange,');
expect(appSource).toContain('pageshow: handleWindowRuntimeChange,');
expect(appSource).toContain('pagehide: handleWindowLifecycleFlush,');
expect(appSource).toContain('beforeunload: handleWindowLifecycleFlush,');
expect(appSource).toContain('cleanupWindowActivityScheduler();');
});
it('keeps the resize minimise probe independent from DPR debounce and clears it on unmount', () => {
const scaleEffectStart = appSource.indexOf('let minimisedCheckTimer: number | null = null;');
const dprScheduleStart = appSource.indexOf('const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {', scaleEffectStart);
const activationScheduleStart = appSource.indexOf('const scheduleActivationFix = () => {', dprScheduleStart);
const resizeHandlerStart = appSource.indexOf('const handleWindowResize = () => {', activationScheduleStart);
const startupFixStart = appSource.indexOf('// Windows 冷启动:', resizeHandlerStart);
const schedulerStart = appSource.indexOf('fallbackIntervalMs: WINDOWS_SCALE_FALLBACK_INTERVAL_MS,', startupFixStart);
const cleanupStart = appSource.indexOf('return () => {', schedulerStart);
const cleanupEnd = appSource.indexOf('cleanupWindowActivityScheduler();', cleanupStart);
expect([scaleEffectStart, dprScheduleStart, activationScheduleStart, resizeHandlerStart, startupFixStart, schedulerStart, cleanupStart, cleanupEnd]
.every((index) => index >= 0)).toBe(true);
expect(appSource.slice(dprScheduleStart, activationScheduleStart)).not.toContain('minimisedCheckTimer');
const resizeHandlerSource = appSource.slice(resizeHandlerStart, startupFixStart);
const minimiseProbeIndex = resizeHandlerSource.indexOf('rememberMinimisedStateSoon();');
const dprCheckIndex = resizeHandlerSource.indexOf("scheduleDevicePixelRatioCheck('resize');");
expect(minimiseProbeIndex).toBeGreaterThan(-1);
expect(dprCheckIndex).toBeGreaterThan(minimiseProbeIndex);
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('window.clearTimeout(minimisedCheckTimer);');
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('minimisedCheckTimer = null;');
});
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
expect(appSource).toContain('const readCurrentVisibleViewport = () => resolveWailsWindowVisibleViewport(');
expect(appSource).toContain('{ useMonitorLocalOrigin: isMacLikePlatform() }');
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
});
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
@@ -293,6 +514,11 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);");
});
it('consumes repeated AI shortcut events without toggling the newly focused child again', () => {
expect(appSource).toContain("if (event.repeat && matchedAction === 'toggleAIPanel') {");
expect(appSource).toContain('event.stopImmediatePropagation();');
});
it('skips the native mac titlebar bridge when the current runtime does not expose it', () => {
expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;");
expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {");
@@ -317,14 +543,28 @@ describe('global appearance tokens', () => {
expect(appSource).toContain("setProperty('--gn-font-size-mono'");
expect(appSource).toContain("setProperty('--gn-data-table-font-size'");
expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'");
expect(appSource).toContain("setProperty('--gn-sidebar-rail-scale'");
expect(appSource).toContain("setProperty('--gn-control-height'");
expect(appSource).toContain("setProperty('--gn-control-height-sm'");
expect(appSource).toContain('fontFamily: resolvedUiFontFamily');
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
expect(appSource).toContain('const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);');
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_title')");
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_hint')");
expect(appSource).toContain('v2SidebarRailScale: sanitizeV2SidebarRailScale(value)');
expect(appSource).toContain("t('app.theme.data_table.font_size')");
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
expect(v2ThemeCss).toContain('--gn-sidebar-rail-scale');
expect(v2ThemeCss).toContain('font-size: calc(var(--gn-font-size-sm, 12px) * var(--gn-sidebar-rail-scale, 1));');
expect(v2ThemeCss).toContain('width: calc(38px * var(--gn-v2-rail-scale));');
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
expect(appSource).toContain('ListInstalledFontFamilies()');
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
@@ -340,8 +580,17 @@ describe('global appearance tokens', () => {
expect(linuxCJKFontBannerSource).not.toContain('Font Settings');
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')");
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')");
expect(appSource).toContain("t('app.theme.query_template.title')");
expect(appSource).toContain("t('app.theme.query_template.description')");
expect(appSource).toContain("t('app.theme.query_template.hint')");
expect(appSource).toContain("t('app.theme.query_template.reset_default')");
expect(appSource).toContain("const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;");
expect(appSource).toContain("onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}");
expect(appSource).toContain("onClick={() => setAppearance({ newQuerySqlTemplate: null })}");
expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体');
expect(appSource).not.toContain(',然后重启 GoNavi。');
expect(appSource).not.toContain('新建查询默认 SQL');
expect(appSource).not.toContain('清空后新建查询将保持空白');
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
expect(appSource).toContain('matchFontFamilyOption');
expect(appSource).toContain('showSearch');
@@ -356,4 +605,15 @@ describe('global appearance tokens', () => {
expect(appSource).toContain('dataTableFontSize: dataTableFontSizeFollowsGlobal');
expect(appSource).toContain('sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal');
});
it('scales settings-center typography and keeps action heights consistent', () => {
expect(appCss).toContain('--gn-font-size: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1));');
expect(appCss).toContain('--gn-font-size-sm: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.86);');
expect(appCss).toContain('--gn-font-size-xs: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.76);');
expect(appCss).toContain('--gn-font-size-mono: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.92);');
expect(appCss).toContain('--gn-settings-font-secondary: var(--gn-font-size-sm);');
expect(appCss).toMatch(/\.gonavi-settings-center-modal \.ant-btn\s*\{[^}]*font-size:\s*var\(--gn-settings-font-caption\)/s);
expect(appCss).toMatch(/\.gonavi-settings-center-modal \.gonavi-ai-provider-add\.ant-btn\s*\{[^}]*height:\s*var\(--gn-control-height, 32px\) !important;/s);
expect(appCss).toMatch(/\.gonavi-theme-settings \.gonavi-settings-section-hint\s*\{[^}]*font-size:\s*var\(--gn-settings-font-secondary/s);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -13,40 +13,73 @@ describe('UI version switch placement', () => {
expect(appSource).toContain("import './v2-theme.css';");
});
it('keeps the UI version switch in theme mode and outside macOS-only settings', () => {
it('keeps light/dark first with compact previews and UI version preview tiles', () => {
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
const customThemeIndex = appSource.indexOf("t('app.theme.custom.title')", lightThemeIndex);
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
const appearanceBranchIndex = appSource.indexOf(") : themeModalSection === 'appearance' ? (", themeBranchIndex);
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
expect(themeBranchIndex).toBeGreaterThan(-1);
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
expect(lightThemeIndex).toBeGreaterThan(themeBranchIndex);
expect(customThemeIndex).toBeGreaterThan(lightThemeIndex);
expect(customThemeIndex).toBeLessThan(uiVersionIndex);
expect(uiVersionIndex).toBeGreaterThan(lightThemeIndex);
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')");
expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')");
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
expect(appSource).toContain('renderUiVersionPreview');
expect(appSource).toContain('gonavi-settings-ui-version-grid');
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key })}");
expect(appSource).toContain("t('app.theme.ui_version.beta_warning')");
expect(appSource).toContain("t('app.theme.ui_version.platform_hint')");
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
expect(appSource).toContain("appearance.uiVersion === 'v2' ? (");
});
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')");
const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex);
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
it('uses compact previews only in v2 theme settings layout', () => {
expect(appSource).toContain('renderThemeSettingsContentV2');
expect(appSource).toContain('renderThemeSettingsContentLegacy');
expect(appSource).toContain('isV2Ui ? renderThemeSettingsContentV2() : renderThemeSettingsContentLegacy()');
expect(appSource).toContain('className="gonavi-theme-settings"');
expect(appSource).toContain('gonavi-settings-mode-grid');
expect(appSource).toContain('gonavi-settings-mode-tile');
expect(appSource).toContain('renderThemeModePreview');
expect(appSource).toContain("preview: 'light' as const");
expect(appSource).toContain('ThemeSettingsSlider');
expect(appSource).toContain("unit=\"percent\"");
expect(appSource).toContain('gonavi-settings-tabs');
expect(appSource).toContain('gonavi-settings-tab');
expect(appSource).toContain('gonavi-settings-pill');
expect(appSource).toContain('<CustomThemeManager />');
expect(appSource).toContain('<CustomThemeManager legacyMode />');
// 旧版布局仍保留侧栏导航
expect(appSource).toContain("gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0'");
});
expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')");
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')");
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')");
expect(uiVersionBlock).toContain('CheckOutlined');
expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')");
expect(uiVersionBlock).toContain('<Segmented');
it('keeps theme and UI version radio tiles on one tab stop with complete keyboard navigation', () => {
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
const appearanceBranchIndex = appSource.indexOf(") : themeModalSection === 'appearance' ? (", themeBranchIndex);
const themeSource = appSource.slice(themeBranchIndex, appearanceBranchIndex);
expect(themeSource).toContain(']).map((item, itemIndex, themeItems) => {');
expect(themeSource).toContain('tabIndex={themePreference === item.key ? 0 : -1}');
expect(themeSource).toContain('selectPresetTheme(themeItems[nextIndex].key);');
expect(themeSource).toContain(']).map((item, itemIndex, uiVersionItems) => {');
expect(themeSource).toContain('tabIndex={active ? 0 : -1}');
expect(themeSource).toContain('setAppearance({ uiVersion: uiVersionItems[nextIndex].key });');
expect(themeSource.match(/\['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'\]/g)).toHaveLength(2);
expect(themeSource.match(/querySelectorAll<HTMLElement>\('\[role="radio"\]'\)/g)).toHaveLength(2);
});
it('isolates workspace settings and remembers the active section', () => {
expect(appSource).toContain("value: 'workspace'");
expect(appSource).toContain("t('app.theme.nav.workspace.title')");
expect(appSource).toContain("setThemeModalSection('workspace')");
expect(appSource).toContain("themeModalSection !== 'workspace'");
expect(appSource).toContain('gonavi.themeSettingsSection');
});
it('localizes the v2 sidebar search mode copy', () => {

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
describe('restart-to-update unsaved SQL guard', () => {
it('runs the confirmed update action through every application quit path', () => {
expect(appSource).toContain('type ApplicationQuitConfirmedAction = () => Promise<boolean>;');
expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {');
expect(appSource).toContain('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise<boolean> => {');
const runnerEnd = appSource.indexOf('\n let targets;', runnerStart);
const runnerSource = appSource.slice(runnerStart, runnerEnd);
expect(runnerSource).toContain('accepted = await confirmedAction();');
expect(runnerSource).toContain('await forceQuitApplication();\n accepted = true;');
expect(runnerSource).toContain('} catch (error) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('if (!accepted) {\n resetApplicationQuitRequest();');
expect(runnerSource).toContain('return accepted;');
expect(appSource).toContain('if (targets.length === 0) {\n await runConfirmedAction();');
expect(appSource).toContain('void runConfirmedAction();');
const saveIndex = appSource.indexOf('await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);');
const actionAfterSaveIndex = appSource.indexOf('await runConfirmedAction();', saveIndex);
expect(saveIndex).toBeGreaterThan(-1);
expect(actionAfterSaveIndex).toBeGreaterThan(saveIndex);
});
it('confirms closing every Windows instance before entering the unsaved SQL guard', () => {
expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {');
expect(appSource).toContain("if (installMode === 'portable' || installMode === 'msi') {");
expect(appSource).toContain("title: t('app.about.update_install_confirm.close_instances_title')");
expect(appSource).toContain("content: t('app.about.update_install_confirm.close_instances_content')");
expect(appSource).toContain("okText: t('app.about.update_install_confirm.close_instances_ok')");
expect(appSource).toContain("cancelText: t('common.cancel')");
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(true));');
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(false));');
expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2);
expect(appSource).not.toContain('onClick={handleInstallFromProgress}');
expect(appSource).toContain("updateInstallAction === 'install-and-restart'");
expect(appSource).toContain("updateInstallAction === 'launch-installer'");
expect(appSource.match(/\{updateInstallActionLabel\}/g)).toHaveLength(2);
});
});

47
frontend/src/bootstrap.ts Normal file
View File

@@ -0,0 +1,47 @@
import { loadCatalog } from './i18n/catalog';
import { resolveLanguage } from './i18n/resolveLanguage';
import { isNativeDetachedWindowRoute } from './utils/nativeDetachedWindowRoute';
type DetachedBootstrapRuntime = {
loadBootstrap?: () => Promise<{
payload?: { storeState?: { languagePreference?: string } };
}>;
};
const readBrowserLanguages = (): string[] => {
if (typeof navigator === 'undefined') return [];
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
return [...navigator.languages];
}
return navigator.language ? [navigator.language] : [];
};
const readDetachedLanguagePreference = async (): Promise<string | undefined> => {
if (typeof window === 'undefined') return undefined;
const runtime = (window as typeof window & {
__GONAVI_DETACHED__?: DetachedBootstrapRuntime;
}).__GONAVI_DETACHED__;
if (typeof runtime?.loadBootstrap !== 'function') return undefined;
try {
const bootstrap = await runtime.loadBootstrap();
return bootstrap.payload?.storeState?.languagePreference;
} catch {
return undefined;
}
};
const loadEntry = async (): Promise<void> => {
const detached = isNativeDetachedWindowRoute();
const preference = detached ? await readDetachedLanguagePreference() : undefined;
const language = resolveLanguage(preference, readBrowserLanguages());
await loadCatalog(language);
if (detached) {
await import('./nativeDetachedMain');
} else {
await import('./main');
}
};
void loadEntry().catch((error) => {
console.error('[GoNavi] Failed to load frontend entry', error);
});

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