mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04b424e37 | ||
|
|
400ff6dd02 | ||
|
|
a5bb8cf1c1 | ||
|
|
e84e49fa28 | ||
|
|
5918414127 | ||
|
|
c6ecd9808f | ||
|
|
8240df143a | ||
|
|
ad9d9cad8f | ||
|
|
b288a6b5f4 | ||
|
|
3d1b84831f | ||
|
|
b70d6da227 | ||
|
|
af424b782f | ||
|
|
c63de19cbf | ||
|
|
0854cc3e08 | ||
|
|
cabc7656de | ||
|
|
760d2c6409 | ||
|
|
eba6853480 | ||
|
|
805d10346a | ||
|
|
28e2568a8f | ||
|
|
608633edff | ||
|
|
14aeb93081 | ||
|
|
a7a2e2f434 | ||
|
|
2e3b7da533 | ||
|
|
d9154ee2b7 | ||
|
|
deb47df2eb | ||
|
|
760cad6d1b | ||
|
|
a3aa50ded9 | ||
|
|
1ea6d2907b | ||
|
|
1533064f1f | ||
|
|
6d866905b5 | ||
|
|
f1cc68a4ba | ||
|
|
8d32cf9ffa | ||
|
|
7cbd1418b3 | ||
|
|
e561bd156f | ||
|
|
193d875b8a | ||
|
|
58d902c07f | ||
|
|
05534fce32 | ||
|
|
19365b09bb | ||
|
|
2249aa30c0 | ||
|
|
94617a5923 | ||
|
|
9c973a2ee3 | ||
|
|
8efb24f2c7 | ||
|
|
b28374867a | ||
|
|
defc4dcfa5 | ||
|
|
ae1da14ddf | ||
|
|
638aa48f57 | ||
|
|
974848d6e2 | ||
|
|
ac2dcc1b79 | ||
|
|
18e34bf33a | ||
|
|
2808996526 | ||
|
|
ba65d4a0e0 | ||
|
|
72b8b8f0ac | ||
|
|
f28f050d97 | ||
|
|
28c0977a56 | ||
|
|
e2dc7ce176 | ||
|
|
f52be8f439 | ||
|
|
61b13826c5 | ||
|
|
f15d93deb2 | ||
|
|
d3587719d2 | ||
|
|
78f9740366 | ||
|
|
1b6ff2ba54 | ||
|
|
ac7fbaeb43 | ||
|
|
933decc98b | ||
|
|
56c0f8567d | ||
|
|
8cd5372e22 | ||
|
|
e8e1ece80d | ||
|
|
83fdd78d5b | ||
|
|
f6e5b3ab52 | ||
|
|
78a78cc209 | ||
|
|
91da8b2a9a | ||
|
|
ba77d94815 | ||
|
|
a33768ef6e | ||
|
|
45ed1047a8 | ||
|
|
7afa87e849 | ||
|
|
832e08ae1f | ||
|
|
a3e4a95400 | ||
|
|
fd1f7f0adf | ||
|
|
68afe61693 | ||
|
|
729bb454e3 | ||
|
|
061d4009ee | ||
|
|
0459dfea12 | ||
|
|
1137cab7f4 | ||
|
|
28206f2963 | ||
|
|
17f184cd07 | ||
|
|
1842ae9de7 | ||
|
|
5ed03ca2b2 | ||
|
|
bcd1aafb33 | ||
|
|
7b7d21f54f | ||
|
|
f900357474 | ||
|
|
84e8bab622 | ||
|
|
b5072f1a3e | ||
|
|
3767f4a83b | ||
|
|
b8f39f9c24 | ||
|
|
6694e4382e | ||
|
|
579008ffbd | ||
|
|
7472f16066 | ||
|
|
80d314b711 | ||
|
|
f26e373df9 | ||
|
|
859a004136 | ||
|
|
4fe04c8bb9 |
@@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf
|
||||
apps/desktop/*.ts text eol=lf
|
||||
src-tauri/windows/nsis/**/*.nsi text eol=lf
|
||||
|
||||
# Keep plugin dev-host formatting checks consistent on Windows checkouts.
|
||||
plugins/sdk/dev-host/** text=auto eol=lf
|
||||
|
||||
# Keep tests available for review and CI without counting them as shipped code
|
||||
# in GitHub's repository language breakdown.
|
||||
packages/app-tests/** linguist-vendored
|
||||
|
||||
@@ -7,58 +7,51 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈。涉及敏感信息请脱敏。请尽量提供数据库类型、版本、复现步骤、截图和错误日志。
|
||||
Thanks for reporting this. Redact sensitive information and include the database type/version, reproduction steps, screenshots, and logs when possible.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
|
||||
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。
|
||||
placeholder: "例如:PostgreSQL 16.2"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 例如 PostgreSQL 16、MySQL 8.0、Redis 7.2。 / For example, PostgreSQL 16, MySQL 8.0, or Redis 7.2.
|
||||
placeholder: "例如:PostgreSQL 16.2 / Example: PostgreSQL 16.2"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请按下面的清单逐项填写,截图和错误日志尤其重要。
|
||||
label: 问题描述 / Problem description
|
||||
description: 请按下面的清单逐项填写。 / Complete each item below.
|
||||
placeholder: |
|
||||
1. 复现步骤:新建连接 → 填写信息 → 点击保存
|
||||
2. 期望结果:能正常展开 schema
|
||||
3. 实际结果:一直 loading,无错误提示
|
||||
4. 最好附上截图和错误日志(设置 → 外观 → 启用"启用调试日志" → 复现问题 → 点击"下载日志",将内容粘贴到这里)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
1. 复现步骤 / Steps: 新建连接 → 填写信息 → 点击保存 / Create connection → enter details → click Save
|
||||
2. 期望结果 / Expected: 能正常展开 schema / Schema expands normally
|
||||
3. 实际结果 / Actual: 一直 loading,无错误提示 / Loading never finishes
|
||||
4. 附上截图和错误日志 / Attach screenshots and logs when possible
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 粘贴“复制支持信息”的结果;如无法打开应用,请说明原因。 / Paste the copied support information; explain if the app cannot open.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
操作系统:macOS 26.4
|
||||
系统架构:aarch64
|
||||
validations:
|
||||
required: true
|
||||
|
||||
DBX 版本 / Version: v0.5.52
|
||||
运行环境 / Runtime: Desktop
|
||||
操作系统 / OS: macOS 26.4
|
||||
系统架构 / Architecture: aarch64
|
||||
validations: {required: true}
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级(是否紧急)
|
||||
description: 紧急 = 希望下个版本优先修。维护者可根据实际情况调整。
|
||||
label: 优先级(是否紧急) / Priority (urgency)
|
||||
description: 紧急 = 希望下个版本优先修;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
|
||||
options:
|
||||
- "🔴 P0 紧急 · 下个版本必须修"
|
||||
- "🟠 P1 重要 · 尽快处理"
|
||||
- "🟡 P2 普通 · 按排期处理"
|
||||
- "🟢 P3 低优 · 有空再修"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- "🔴 P0 紧急 · 下个版本必须修 / Urgent · Must fix next release"
|
||||
- "🟠 P1 重要 · 尽快处理 / Important · Fix soon"
|
||||
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
|
||||
- "🟢 P3 低优 · 有空再修 / Low · Fix when possible"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 截图、SQL 样例、连接方式、特殊环境等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 截图、SQL 样例、连接方式、特殊环境等。 / Screenshots, SQL samples, connection method, or special environments.
|
||||
|
||||
@@ -7,13 +7,14 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
用于收集数据库兼容性细节,例如连接失败、schema 读取不完整、字段类型显示错误等。请脱敏。
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
This form collects database compatibility details such as connection failures, incomplete schema reads, and incorrect field types. Redact sensitive information.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息 / Support information”一键复制。
|
||||
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 也请注明发行版,如 MariaDB、AWS RDS、TiDB 等。 / Include the distribution, such as MariaDB, AWS RDS, or TiDB.
|
||||
placeholder: "例如:MariaDB 10.11"
|
||||
validations:
|
||||
required: true
|
||||
@@ -21,16 +22,17 @@ body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 什么操作,出了什么错,期望怎样?
|
||||
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。"
|
||||
label: 问题描述 / Description / Problem description
|
||||
description: 什么操作,出了什么错,期望怎样? / What did you do, what went wrong, and what did you expect?
|
||||
# What did you do, what went wrong, and what did you expect?
|
||||
placeholder: "例如:展开 schema 时报错 'unknown type',期望能正常显示字段。 / Example: expanding a schema reports 'unknown type'; fields should display normally."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error
|
||||
attributes:
|
||||
label: 错误信息
|
||||
label: 错误信息 / Error message
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
@@ -38,8 +40,8 @@ body:
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。 / Open DBX Settings → About, copy Support Information, and paste it here. Explain if the app cannot open.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
@@ -51,5 +53,6 @@ body:
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 表结构、SQL 样例、截图、部署方式(Docker/云/自建)等。 / Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
|
||||
# Include schema, SQL samples, screenshots, and deployment details (Docker, cloud, or self-hosted).
|
||||
|
||||
@@ -6,64 +6,55 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
好的建议最好说明使用场景、数据库类型和期望效果。请尽量避免只写“支持 X”。
|
||||
好的建议最好说明使用场景、数据库类型和期望效果。请避免只写“支持 X”。
|
||||
Describe the use case, database type, and desired outcome; avoid requests that only say “support X”.
|
||||
DBX 版本、运行环境、操作系统和系统架构可在应用内通过“设置 → 关于我们 → 复制支持信息”一键复制。
|
||||
|
||||
You can copy the DBX version, runtime, operating system, and architecture in the app via “Settings → About → Copy support information”.
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 如果是通用功能,请填写“通用”。如果只影响特定数据源,请注明数据库类型、版本或发行版。
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 通用功能请填写“通用”;特定数据源请注明类型和版本。 / Enter “通用” for general features; otherwise specify the data source and version.
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 通用 · General"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 粘贴应用中的支持信息。 / Paste the support information copied from the app.
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
操作系统:macOS 26.4
|
||||
系统架构:aarch64
|
||||
validations:
|
||||
required: true
|
||||
|
||||
DBX 版本 / Version: v0.5.52
|
||||
运行环境 / Runtime: Desktop
|
||||
操作系统 / OS: macOS 26.4
|
||||
系统架构 / Architecture: aarch64
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 当前痛点
|
||||
description: 现在做这件事有什么不方便?
|
||||
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 当前痛点 / Current pain point
|
||||
description: 现在做这件事有什么不方便? / What is inconvenient about the current workflow?
|
||||
placeholder: "例如:每次查询后都要手动复制结果再转成 Markdown。 / Example: I must manually copy query results and convert them to Markdown."
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: 期望方案
|
||||
description: 希望怎么支持?
|
||||
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
label: 期望方案 / Proposed solution
|
||||
description: 希望怎么支持? / How would you like this to work?
|
||||
placeholder: "例如:在查询结果右上角增加导出 Markdown 按钮。 / Example: Add an Export to Markdown button."
|
||||
validations: {required: true}
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级(是否紧急)
|
||||
description: 紧急 = 希望下个版本优先做。维护者可根据实际情况调整。
|
||||
label: 优先级(是否紧急) / Priority (urgency)
|
||||
description: 紧急 = 希望下个版本优先做;维护者可调整。 / Urgent means you hope for the next release; maintainers may adjust it.
|
||||
options:
|
||||
- "🔴 P0 紧急 · 下个版本必须做"
|
||||
- "🟠 P1 重要 · 尽快处理"
|
||||
- "🟡 P2 普通 · 按排期处理"
|
||||
- "🟢 P3 低优 · 有空再做"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- "🔴 P0 紧急 · 下个版本必须做 / Urgent · Must do next release"
|
||||
- "🟠 P1 重要 · 尽快处理 / Important · Do soon"
|
||||
- "🟡 P2 普通 · 按排期处理 / Normal · Schedule as appropriate"
|
||||
- "🟢 P3 低优 · 有空再做 / Low · Do when possible"
|
||||
validations: {required: true}
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 参考截图、替代方案、特殊环境等。
|
||||
label: 补充信息 / Additional information
|
||||
description: 参考截图、替代方案、特殊环境等。 / Screenshots, alternatives, or special environments.
|
||||
|
||||
@@ -6,8 +6,9 @@ body:
|
||||
- type: input
|
||||
id: database
|
||||
attributes:
|
||||
label: 数据库类型和版本
|
||||
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。
|
||||
label: 数据库类型和版本 / Database type and version
|
||||
description: 如果问题与数据库无关,请填写“无关”。否则请注明数据库类型、版本或发行版。 / Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
|
||||
# Enter “无关” if unrelated to a database; otherwise specify the type, version, or distribution.
|
||||
placeholder: "例如:PostgreSQL 16.2 / MySQL 8.0 / Redis 7.2 / 无关"
|
||||
validations:
|
||||
required: true
|
||||
@@ -15,8 +16,8 @@ body:
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: 支持信息
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
label: 支持信息 / Support information
|
||||
description: 打开 DBX 的“设置 → 关于我们”,点击“复制支持信息 / Support information”,然后粘贴到这里。如无法打开应用,请说明原因。
|
||||
placeholder: |
|
||||
DBX 版本:v0.5.52
|
||||
运行环境:桌面端
|
||||
@@ -28,8 +29,9 @@ body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: 描述
|
||||
description: 你想完成什么?已经尝试了什么?
|
||||
label: 描述 / Description
|
||||
description: 你想完成什么?已经尝试了什么? / What are you trying to accomplish, and what have you already tried?
|
||||
# What are you trying to accomplish, and what have you already tried?
|
||||
placeholder: "例如:我想通过 SSH 隧道连接内网 PostgreSQL,已确认本机 psql 可以连接,但 DBX 中测试连接提示..."
|
||||
validations:
|
||||
required: true
|
||||
@@ -37,8 +39,9 @@ body:
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 连接方式、特殊环境、已尝试的排查方式等。
|
||||
label: 环境信息 / Environment
|
||||
description: 连接方式、特殊环境、已尝试的排查方式等。 / Connection method, special environment, and troubleshooting already attempted.
|
||||
# Connection method, special environment, and troubleshooting already attempted.
|
||||
placeholder: |
|
||||
连接方式:
|
||||
补充环境:
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
## 变更说明
|
||||
## 变更说明 / Change Description
|
||||
|
||||
<!-- 简要描述这个 PR 做了什么 -->
|
||||
<!-- 简要描述这个 PR 做了什么 / Briefly describe what this PR changes. -->
|
||||
|
||||
## 变更类型
|
||||
## 变更类型 / Change Type
|
||||
|
||||
- [ ] 新功能
|
||||
- [ ] Bug 修复
|
||||
- [ ] 性能优化
|
||||
- [ ] 代码重构
|
||||
- [ ] 文档更新
|
||||
- [ ] CI / 构建
|
||||
- [ ] 新功能 / New feature
|
||||
- [ ] Bug 修复 / Bug fix
|
||||
- [ ] 性能优化 / Performance improvement
|
||||
- [ ] 代码重构 / Code refactoring
|
||||
- [ ] 文档更新 / Documentation
|
||||
- [ ] CI / 构建 / CI or build
|
||||
|
||||
## 涉及前端
|
||||
## 涉及前端 / Frontend Changes
|
||||
|
||||
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 -->
|
||||
<!-- 如果 PR 涉及任何前端改动(UI 组件、样式、交互、文案等),必须附截图或录屏 / If this PR changes UI components, styles, interactions, or user-facing text, include screenshots or a recording. -->
|
||||
|
||||
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方)
|
||||
- [ ] 本 PR 涉及前端改动,已附截图/录屏(见下方) / This PR includes frontend changes and screenshots or a recording are attached below.
|
||||
|
||||
<!-- 截图区域 -->
|
||||
<!-- 截图区域 / Screenshots or recording -->
|
||||
|
||||
## 验证
|
||||
## 验证 / Validation
|
||||
|
||||
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 -->
|
||||
<!-- 说明如何验证你的改动,例如:命令、测试用例、手动操作步骤 / Explain how you verified the change, including commands, tests, or manual steps. -->
|
||||
|
||||
- [ ] `make check` 通过
|
||||
- [ ] `make cargo-check-fast` 通过
|
||||
- [ ] 相关测试通过
|
||||
- [ ] `make check` 通过 / `make check` passes
|
||||
- [ ] `make cargo-check-fast` 通过 / `make cargo-check-fast` passes
|
||||
- [ ] 相关测试通过 / Relevant tests pass
|
||||
|
||||
## 关联 Issue
|
||||
## 关联 Issue / Related Issue
|
||||
|
||||
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` -->
|
||||
<!-- 如有,填写 `Close #xxx` 或 `Related #xxx` / If applicable, use `Close #xxx` or `Related #xxx`. -->
|
||||
|
||||
@@ -169,7 +169,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Oracle native agent
|
||||
working-directory: agents/drivers/oracle-go
|
||||
run: go test ./...
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Xugu native agent
|
||||
working-directory: agents/drivers/xugu
|
||||
run: GONOSUMDB=gitee.com/XuguDB/go-xugu-driver go test ./...
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test RabbitMQ native agent
|
||||
working-directory: agents/drivers/rabbitmq
|
||||
run: go test ./...
|
||||
@@ -595,7 +595,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: "1.20.14"
|
||||
- name: Test Kingbase native agent
|
||||
working-directory: agents/drivers/kingbase-go
|
||||
run: go test ./...
|
||||
|
||||
@@ -100,6 +100,9 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install plugin development host dependencies
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
name: Plugin CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Plugin CLI version to publish, for example 0.1.5"
|
||||
required: true
|
||||
default: "0.1.5"
|
||||
npm-tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
default: "latest"
|
||||
type: choice
|
||||
options:
|
||||
- latest
|
||||
- next
|
||||
|
||||
permissions:
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: plugin-cli-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Validate plugin CLI release
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
npm-tag: ${{ steps.release.outputs.npm-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
cache: false
|
||||
|
||||
- name: Validate release branch and versions
|
||||
id: release
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_NPM_TAG: ${{ inputs.npm-tag }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("fs");
|
||||
|
||||
let version;
|
||||
let tag;
|
||||
let npmTag;
|
||||
if (process.env.GITHUB_EVENT_NAME === "push") {
|
||||
tag = process.env.GITHUB_REF_NAME;
|
||||
const match = /^plugin-cli-v(.+)$/.exec(tag);
|
||||
if (!match) throw new Error(`Unexpected plugin CLI tag: ${tag}`);
|
||||
version = match[1];
|
||||
npmTag = version.includes("-") ? "next" : "latest";
|
||||
} else {
|
||||
if (process.env.GITHUB_REF !== "refs/heads/main") {
|
||||
throw new Error(`Manual plugin CLI releases must run from main, received ${process.env.GITHUB_REF}.`);
|
||||
}
|
||||
version = process.env.INPUT_VERSION.trim();
|
||||
tag = `plugin-cli-v${version}`;
|
||||
npmTag = process.env.INPUT_NPM_TAG;
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`Invalid semantic version: ${version}`);
|
||||
}
|
||||
if (version.includes("-") && npmTag === "latest") {
|
||||
throw new Error(`Prerelease ${version} cannot use the npm latest tag.`);
|
||||
}
|
||||
|
||||
const packagePaths = [
|
||||
"packages/plugin-cli/package.json",
|
||||
"packages/plugin-cli-darwin-arm64/package.json",
|
||||
"packages/plugin-cli-darwin-x64/package.json",
|
||||
"packages/plugin-cli-linux-arm64-gnu/package.json",
|
||||
"packages/plugin-cli-linux-x64-gnu/package.json",
|
||||
"packages/plugin-cli-win32-arm64/package.json",
|
||||
"packages/plugin-cli-win32-x64/package.json",
|
||||
];
|
||||
for (const path of packagePaths) {
|
||||
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
if (pkg.version !== version) {
|
||||
throw new Error(`${path} has version ${pkg.version}; expected ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const cliCargo = fs.readFileSync("plugins/sdk/cli/Cargo.toml", "utf8");
|
||||
const cliVersion = cliCargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (cliVersion !== version) {
|
||||
throw new Error(`plugins/sdk/cli/Cargo.toml has version ${cliVersion}; expected ${version}.`);
|
||||
}
|
||||
|
||||
const launcher = JSON.parse(fs.readFileSync(packagePaths[0], "utf8"));
|
||||
for (const [dependency, dependencyVersion] of Object.entries(launcher.optionalDependencies ?? {})) {
|
||||
if (dependencyVersion !== version) {
|
||||
throw new Error(`${dependency} is pinned to ${dependencyVersion}; expected ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `npm-tag=${npmTag}\n`);
|
||||
NODE
|
||||
|
||||
- name: Check npm token
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${NODE_AUTH_TOKEN}" ]; then
|
||||
echo "::error::NPM_TOKEN secret is required to publish @dbx-app/plugin-cli."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Rust CLI
|
||||
run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
|
||||
- name: Prepare and test plugin development runtime
|
||||
run: |
|
||||
npm ci --prefix plugins/sdk/dev-host
|
||||
npm test --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Test npm launcher
|
||||
run: npm --prefix packages/plugin-cli test
|
||||
|
||||
- name: Verify packed npm installation
|
||||
env:
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE: "1"
|
||||
run: node scripts/verify-plugin-cli-package.mjs
|
||||
|
||||
- name: Create immutable source tag
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
run: |
|
||||
git fetch origin --tags --force
|
||||
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
TAG_SHA="$(git rev-list -n 1 "refs/tags/${TAG}")"
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
if [ "${TAG_SHA}" != "${HEAD_SHA}" ]; then
|
||||
echo "::error::${TAG} already points to ${TAG_SHA}, not ${HEAD_SHA}."
|
||||
exit 1
|
||||
fi
|
||||
echo "${TAG} already points to this commit."
|
||||
else
|
||||
git tag "${TAG}" HEAD
|
||||
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
|
||||
fi
|
||||
|
||||
publish-platforms:
|
||||
name: Publish ${{ matrix.package-name }}
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
target: aarch64-apple-darwin
|
||||
package-dir: plugin-cli-darwin-arm64
|
||||
package-name: "@dbx-app/plugin-cli-darwin-arm64"
|
||||
binary: dbx-plugin
|
||||
- runner: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
package-dir: plugin-cli-darwin-x64
|
||||
package-name: "@dbx-app/plugin-cli-darwin-x64"
|
||||
binary: dbx-plugin
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
package-dir: plugin-cli-linux-arm64-gnu
|
||||
package-name: "@dbx-app/plugin-cli-linux-arm64-gnu"
|
||||
binary: dbx-plugin
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package-dir: plugin-cli-linux-x64-gnu
|
||||
package-name: "@dbx-app/plugin-cli-linux-x64-gnu"
|
||||
binary: dbx-plugin
|
||||
- runner: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
package-dir: plugin-cli-win32-arm64
|
||||
package-name: "@dbx-app/plugin-cli-win32-arm64"
|
||||
binary: dbx-plugin.exe
|
||||
- runner: windows-2025
|
||||
target: x86_64-pc-windows-msvc
|
||||
package-dir: plugin-cli-win32-x64
|
||||
package-name: "@dbx-app/plugin-cli-win32-x64"
|
||||
binary: dbx-plugin.exe
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install Linux compatibility builder
|
||||
if: runner.os == 'Linux'
|
||||
run: pip install ziglang==0.14.0 cargo-zigbuild==0.23.0
|
||||
|
||||
- name: Build precompiled CLI
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target/plugin-cli
|
||||
run: |
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
cargo zigbuild --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}.2.31"
|
||||
else
|
||||
cargo build --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- name: Stage platform package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "packages/${{ matrix.package-dir }}/bin"
|
||||
cp "target/plugin-cli/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
|
||||
if [[ "${{ runner.os }}" != "Windows" ]]; then
|
||||
chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Publish platform package
|
||||
shell: bash
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
|
||||
run: |
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists; refreshing ${NPM_TAG}."
|
||||
npm dist-tag add "${{ matrix.package-name }}@${VERSION}" "${NPM_TAG}"
|
||||
else
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance --tag "${NPM_TAG}"
|
||||
fi
|
||||
|
||||
publish-launcher:
|
||||
name: Publish @dbx-app/plugin-cli
|
||||
needs: [prepare, publish-platforms]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Prepare development runtime build dependencies
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Wait for platform packages
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
wait_for_package() {
|
||||
package="$1"
|
||||
for attempt in $(seq 1 60); do
|
||||
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo "Waiting for ${package}@${VERSION} (${attempt}/60)."
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
for package in \
|
||||
@dbx-app/plugin-cli-darwin-arm64 \
|
||||
@dbx-app/plugin-cli-darwin-x64 \
|
||||
@dbx-app/plugin-cli-linux-arm64-gnu \
|
||||
@dbx-app/plugin-cli-linux-x64-gnu \
|
||||
@dbx-app/plugin-cli-win32-arm64 \
|
||||
@dbx-app/plugin-cli-win32-x64; do
|
||||
wait_for_package "${package}" || {
|
||||
echo "::error::${package}@${VERSION} is unavailable; refusing to publish the launcher."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Publish launcher and bundled SDK
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
|
||||
run: |
|
||||
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "@dbx-app/plugin-cli@${VERSION} already exists; refreshing ${NPM_TAG}."
|
||||
npm dist-tag add "@dbx-app/plugin-cli@${VERSION}" "${NPM_TAG}"
|
||||
else
|
||||
npm publish ./packages/plugin-cli --access public --provenance --tag "${NPM_TAG}"
|
||||
fi
|
||||
|
||||
verify-published-package:
|
||||
name: Verify clean npm installation
|
||||
needs: [prepare, publish-launcher]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
cache: false
|
||||
|
||||
- name: Install from npm and package every template
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
work="$(mktemp -d)"
|
||||
cd "${work}"
|
||||
npm init -y >/dev/null
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [ "${attempt}" -eq 60 ]; then
|
||||
echo "::error::@dbx-app/plugin-cli@${VERSION} did not become visible on npm."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
npm install --ignore-scripts --no-audit --no-fund "@dbx-app/plugin-cli@${VERSION}"
|
||||
./node_modules/.bin/dbx-plugin --version
|
||||
|
||||
for template in frontend rust go; do
|
||||
./node_modules/.bin/dbx-plugin create "${template}-plugin" \
|
||||
--template "${template}" \
|
||||
--yes \
|
||||
--id "com.example.npm-${template}" \
|
||||
--name "npm ${template} smoke" \
|
||||
--publisher example \
|
||||
--description "Published npm package smoke"
|
||||
./node_modules/.bin/dbx-plugin package "${template}-plugin"
|
||||
test "$(find "${template}-plugin/dist" -maxdepth 1 -name '*.dbxp' | wc -l)" -eq 1
|
||||
done
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Plugin development host
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitattributes'
|
||||
- 'plugins/sdk/cli/**'
|
||||
- 'plugins/sdk/dev-host/**'
|
||||
- 'packages/plugin-cli/**'
|
||||
- 'scripts/verify-plugin-cli-package.mjs'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, macos-15, windows-2022]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
- run: npm test --prefix plugins/sdk/dev-host
|
||||
- run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
- run: npm test --prefix packages/plugin-cli
|
||||
- run: node scripts/verify-plugin-cli-package.mjs
|
||||
env:
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'
|
||||
@@ -0,0 +1,218 @@
|
||||
name: Reusable DBX plugin release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release-tag:
|
||||
description: Existing GitHub Release tag that receives the plugin assets
|
||||
required: true
|
||||
type: string
|
||||
package-command:
|
||||
description: Command that builds one unsigned candidate .dbxp and one .artifact.json for DBX_PLUGIN_TARGET
|
||||
required: true
|
||||
type: string
|
||||
working-directory:
|
||||
description: Plugin repository directory containing the package command
|
||||
default: .
|
||||
required: false
|
||||
type: string
|
||||
package-path:
|
||||
description: Glob relative to working-directory for built .dbxp files
|
||||
default: dist/*.dbxp
|
||||
required: false
|
||||
type: string
|
||||
metadata-path:
|
||||
description: Glob relative to working-directory for built .artifact.json files
|
||||
default: dist/*.artifact.json
|
||||
required: false
|
||||
type: string
|
||||
node-version:
|
||||
default: "22"
|
||||
required: false
|
||||
type: string
|
||||
go-version:
|
||||
default: "1.22.x"
|
||||
required: false
|
||||
type: string
|
||||
rust-toolchain:
|
||||
default: stable
|
||||
required: false
|
||||
type: string
|
||||
sdk-ref:
|
||||
description: DBX tag or commit used only when installing the plugin CLI from source
|
||||
default: plugin-sdk-v1
|
||||
required: false
|
||||
type: string
|
||||
plugin-cli-version:
|
||||
description: Published @dbx-app/plugin-cli version used to package the plugin
|
||||
default: "0.1.2"
|
||||
required: false
|
||||
type: string
|
||||
install-plugin-cli:
|
||||
description: Install the dbx-plugin CLI before packaging
|
||||
default: true
|
||||
required: false
|
||||
type: boolean
|
||||
install-plugin-cli-from-source:
|
||||
description: Build the CLI from sdk-ref instead of installing its precompiled npm package
|
||||
default: false
|
||||
required: false
|
||||
type: boolean
|
||||
build-matrix:
|
||||
description: JSON matrix with runner and DBX target pairs; use one universal entry for platform-independent plugins
|
||||
default: >-
|
||||
{"include":[{"runner":"ubuntu-24.04","target":"linux-x64"},{"runner":"ubuntu-24.04-arm","target":"linux-arm64"},{"runner":"windows-2022","target":"windows-x64"},{"runner":"macos-15-intel","target":"darwin-x64"},{"runner":"macos-15","target":"darwin-arm64"}]}
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(inputs.build-matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ inputs.rust-toolchain }}
|
||||
|
||||
- name: Checkout DBX plugin SDK
|
||||
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: t8y2/dbx
|
||||
ref: ${{ inputs.sdk-ref }}
|
||||
path: .dbx-plugin-sdk
|
||||
|
||||
- name: Install precompiled DBX plugin CLI
|
||||
if: inputs.install-plugin-cli && !inputs.install-plugin-cli-from-source
|
||||
shell: bash
|
||||
run: npm install --global "@dbx-app/plugin-cli@${{ inputs.plugin-cli-version }}"
|
||||
|
||||
- name: Install DBX plugin CLI from source
|
||||
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
|
||||
shell: bash
|
||||
run: cargo install --locked --path .dbx-plugin-sdk/plugins/sdk/cli
|
||||
|
||||
- name: Build unsigned plugin candidate
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
DBX_PLUGIN_TARGET: ${{ matrix.target }}
|
||||
DBX_PLUGIN_SDK_ROOT: ${{ inputs.install-plugin-cli-from-source && format('{0}/.dbx-plugin-sdk', github.workspace) || '' }}
|
||||
run: ${{ inputs.package-command }}
|
||||
|
||||
- name: Upload target artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dbx-plugin-${{ matrix.target }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
path: |
|
||||
${{ inputs.working-directory }}/${{ inputs.package-path }}
|
||||
${{ inputs.working-directory }}/${{ inputs.metadata-path }}
|
||||
|
||||
publish:
|
||||
name: Publish plugin release assets
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download target artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dbx-plugin-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Validate and merge candidate metadata
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const childProcess = require("node:child_process");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function filesUnder(root) {
|
||||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
const current = path.join(root, entry.name);
|
||||
return entry.isDirectory() ? filesUnder(current) : [current];
|
||||
});
|
||||
}
|
||||
|
||||
const files = filesUnder("release-assets");
|
||||
const metadataFiles = files.filter((file) => file.endsWith(".artifact.json"));
|
||||
const packages = new Map();
|
||||
for (const file of files.filter((file) => file.endsWith(".dbxp"))) {
|
||||
const name = path.basename(file);
|
||||
if (packages.has(name)) throw new Error(`Duplicate package filename ${name}`);
|
||||
packages.set(name, file);
|
||||
}
|
||||
if (metadataFiles.length === 0 || packages.size === 0) throw new Error("Release produced no DBX plugin artifacts");
|
||||
|
||||
const targets = new Set();
|
||||
let pluginIdentity;
|
||||
const artifacts = metadataFiles.map((file) => {
|
||||
const artifact = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!/^[a-z0-9-]{1,64}$/.test(artifact.target || "")) throw new Error(`Invalid artifact target in ${file}`);
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(artifact.sha256 || "")) throw new Error(`Invalid artifact SHA-256 in ${file}`);
|
||||
if (artifact.signingKeyId !== undefined) throw new Error(`Candidate metadata ${file} must not declare signingKeyId`);
|
||||
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) throw new Error(`Invalid artifact size in ${file}`);
|
||||
const packageName = path.basename(new URL(artifact.url, "https://plugins.invalid/releases/").pathname);
|
||||
const packageFile = packages.get(packageName);
|
||||
if (!packageFile) throw new Error(`Artifact metadata ${file} references missing package ${packageName}`);
|
||||
const packageBytes = fs.readFileSync(packageFile);
|
||||
const packageSha256 = crypto.createHash("sha256").update(packageBytes).digest("hex");
|
||||
if (artifact.size !== packageBytes.length) throw new Error(`Artifact metadata ${file} has the wrong package size`);
|
||||
if (artifact.sha256.toLowerCase() !== packageSha256) throw new Error(`Artifact metadata ${file} has the wrong package SHA-256`);
|
||||
const entries = childProcess.execFileSync("unzip", ["-Z1", packageFile], { encoding: "utf8" }).split(/\r?\n/);
|
||||
if (entries.includes("signature.json")) throw new Error(`Candidate package ${packageName} must be unsigned`);
|
||||
const manifest = JSON.parse(childProcess.execFileSync("unzip", ["-p", packageFile, "manifest.json"], { encoding: "utf8" }));
|
||||
const identity = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
description: manifest.description || "",
|
||||
publisher: manifest.publisher,
|
||||
version: manifest.version,
|
||||
permissions: [...(manifest.permissions || [])].sort(),
|
||||
};
|
||||
if (!identity.id || !identity.name || !identity.publisher || !identity.version) throw new Error(`Package ${packageName} has incomplete manifest identity`);
|
||||
if (pluginIdentity && JSON.stringify(pluginIdentity) !== JSON.stringify(identity)) throw new Error(`Package ${packageName} manifest identity differs from other targets`);
|
||||
pluginIdentity ||= identity;
|
||||
if (targets.has(artifact.target)) throw new Error(`Duplicate artifact target ${artifact.target}`);
|
||||
targets.add(artifact.target);
|
||||
return artifact;
|
||||
}).sort((left, right) => left.target.localeCompare(right.target));
|
||||
|
||||
fs.writeFileSync("release-assets/release-candidates.json", `${JSON.stringify({ plugin: pluginIdentity, artifacts }, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Upload assets to GitHub Release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release-tag }}
|
||||
run: |
|
||||
mapfile -d '' packages < <(find release-assets -type f -name '*.dbxp' -print0)
|
||||
if [ "${#packages[@]}" -eq 0 ]; then
|
||||
echo "No .dbxp packages were downloaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${packages[@]}" release-assets/release-candidates.json \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
@@ -1,4 +1,5 @@
|
||||
# Dependencies
|
||||
openGauss-server/
|
||||
node_modules
|
||||
.claude/
|
||||
.codex/
|
||||
@@ -32,6 +33,7 @@ plugins/jdbc/build/
|
||||
plugins/jdbc/bin/main/
|
||||
plugins/jdbc/bin/test/
|
||||
plugins/jdbc/dist/
|
||||
packages/plugin-cli/sdk-root/
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/.source/
|
||||
@@ -65,10 +67,14 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.dbx-plugin-signing-key.env
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
# Local issue screenshots (never commit)
|
||||
issue-assets/
|
||||
|
||||
# AI background-run dev artifacts (never commit)
|
||||
.dlp-quarantine/
|
||||
outputs/
|
||||
@@ -81,6 +87,7 @@ review_classified.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
/.dbx-dev/
|
||||
|
||||
|
||||
# Generated changelog data
|
||||
|
||||
@@ -65,6 +65,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl
|
||||
| `crates/dbx-web/` | Docker / Web HTTP backend |
|
||||
| `packages/cli/` | `@dbx-app/cli` |
|
||||
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
|
||||
| `packages/plugin-cli/` | Precompiled `@dbx-app/plugin-cli` launcher and bundled plugin SDKs |
|
||||
| `packages/mongo-shell/` | Private MongoDB editor parsing helpers |
|
||||
| `docs/` | Official documentation site |
|
||||
| `examples/` | Sample configs and automation scripts |
|
||||
|
||||
Generated
+73
-16
@@ -1916,6 +1916,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto 0.2.9",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -2002,7 +2003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.11"
|
||||
version = "0.6.12"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2049,6 +2050,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
@@ -2092,12 +2094,13 @@ dependencies = [
|
||||
"csv",
|
||||
"dbx-sqlite-worker",
|
||||
"deadpool-postgres",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"font-kit",
|
||||
"fs2",
|
||||
"futures",
|
||||
"httpdate",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"json5",
|
||||
"jsonwebtoken",
|
||||
@@ -2126,6 +2129,7 @@ dependencies = [
|
||||
"rust_decimal",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -2192,7 +2196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.11"
|
||||
version = "0.6.12"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -2587,19 +2591,43 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8 0.10.2",
|
||||
"signature 2.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"signature 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek 4.1.3",
|
||||
"ed25519 2.2.3",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2609,11 +2637,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-pre.6",
|
||||
"ed25519",
|
||||
"ed25519 3.0.0-rc.4",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2638,7 +2666,7 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
"once_cell",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"rustcrypto-ff",
|
||||
"rustcrypto-group",
|
||||
@@ -3035,6 +3063,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -4210,7 +4248,7 @@ dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"crypto-bigint",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint-dig",
|
||||
@@ -4222,7 +4260,7 @@ dependencies = [
|
||||
"sec1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -6097,6 +6135,16 @@ dependencies = [
|
||||
"spki 0.8.0-rc.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0-rc.11"
|
||||
@@ -7053,10 +7101,10 @@ dependencies = [
|
||||
"crypto-primes",
|
||||
"digest 0.11.3",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -7121,7 +7169,7 @@ dependencies = [
|
||||
"der 0.8.1",
|
||||
"digest 0.10.7",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"elliptic-curve",
|
||||
"enum_dispatch",
|
||||
"flate2",
|
||||
@@ -7150,7 +7198,7 @@ dependencies = [
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1",
|
||||
"pkcs5",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"polyval 0.7.3",
|
||||
"rand 0.10.2",
|
||||
"rand_core 0.10.1",
|
||||
@@ -7165,7 +7213,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -8034,6 +8082,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 20 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
|
||||
@@ -178,7 +178,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🪶 20 MB, zero runtime bloat</h3>
|
||||
<h3>🪶 25 MB, zero runtime bloat</h3>
|
||||
<p>No Java JRE. No Python venv. No bundled Chromium. DBX ships as a single small binary — download, install, connect. DBeaver needs Java; TablePlus is Freemium. DBX runs everywhere with nothing extra.</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -202,7 +202,7 @@
|
||||
|
||||
### 90+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~25 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -458,6 +458,8 @@ make docs
|
||||
|
||||
The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally.
|
||||
|
||||
Plugin authors should start with [Develop and Submit DBX Plugins](https://dbxio.com/en/docs/plugin-development). Plugin source normally stays in its own repository; Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), while plugin host, SDK, and CLI changes go to this repository.
|
||||
|
||||
For clean, reproducible local database instances, use the versioned Docker Compose recipes under [`deploy/database/`](deploy/database/README.md):
|
||||
|
||||
```bash
|
||||
@@ -495,6 +497,7 @@ The installer will be in `src-tauri/target/release/bundle/`.
|
||||
## Documentation
|
||||
|
||||
- [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials
|
||||
- [Plugin development](https://dbxio.com/en/docs/plugin-development) — create plugins and submit Marketplace listings to the correct repository
|
||||
- [Database Test Lab](https://dbxio.com/en/docs/database-lab) — local database recipes for development and verification
|
||||
- [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs
|
||||
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>20 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
|
||||
@@ -178,7 +178,7 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🪶 20 MB,极致轻量</h3>
|
||||
<h3>🪶 25 MB,极致轻量</h3>
|
||||
<p>无需 Java 运行环境,无需 Python 虚拟环境,不内嵌 Chromium。DBX 是单个小巧的二进制文件——下载、安装、连接。DBeaver 依赖 Java;TablePlus 是 Freemium。DBX 全平台可用,无需额外运行时。</p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -202,7 +202,7 @@
|
||||
|
||||
### 90+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
### 查询编辑器
|
||||
|
||||
@@ -537,7 +537,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
|
||||
DBX 仅 20 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -26,5 +26,8 @@ Security-sensitive areas include:
|
||||
- AI provider keys and OpenAI-compatible endpoint configuration.
|
||||
- MCP and CLI access to local DBX connections.
|
||||
- Docker web service authentication and data directory handling.
|
||||
- Plugin package verification, native sidecar execution, sandboxed plugin UI, host bridge permissions, and plugin connection secrets.
|
||||
|
||||
Plugin connection secrets are stored outside ordinary connection JSON. Unencrypted sync snapshots are always redacted. If encrypted secret sync is enabled with a user-provided passphrase, plugin secrets are included only in the encrypted payload and are restored through the normal secret-store path.
|
||||
|
||||
Please avoid testing against systems you do not own or have explicit permission to assess.
|
||||
|
||||
@@ -219,6 +219,118 @@ public final class DdlBuilder {
|
||||
return ddl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends trailing SQL (e.g. GRANT statements) after a CREATE TABLE DDL script.
|
||||
* Returns the original DDL unchanged when {@code trailingSql} is blank.
|
||||
*/
|
||||
public static String appendTrailingSql(String ddl, String trailingSql) {
|
||||
if (!notBlank(trailingSql)) {
|
||||
return ddl == null ? "" : ddl;
|
||||
}
|
||||
String base = ddl == null ? "" : ddl.trim();
|
||||
if (base.isEmpty()) {
|
||||
return trailingSql.trim();
|
||||
}
|
||||
if (!base.endsWith(";")) {
|
||||
base = base + ";";
|
||||
}
|
||||
String grants = trailingSql.trim();
|
||||
return base + "\n\n" + grants + (grants.endsWith(";") ? "" : ";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Oracle-style object GRANT statements for table and column privileges.
|
||||
* Privileges are grouped by grantee / grantable / privilege(+columns).
|
||||
*/
|
||||
public static String buildOracleObjectGrantSql(
|
||||
String schema,
|
||||
String table,
|
||||
List<OracleObjectPrivilege> privileges
|
||||
) {
|
||||
if (privileges == null || privileges.isEmpty() || !notBlank(table)) {
|
||||
return "";
|
||||
}
|
||||
String tableRef = qualifiedName(schema, table, false);
|
||||
|
||||
Map<String, List<String>> tablePrivileges = new LinkedHashMap<>();
|
||||
Map<String, List<String>> columnPrivileges = new LinkedHashMap<>();
|
||||
for (OracleObjectPrivilege privilege : privileges) {
|
||||
if (privilege == null || !notBlank(privilege.grantee()) || !notBlank(privilege.privilege())) {
|
||||
continue;
|
||||
}
|
||||
String privilegeName = privilege.privilege().trim().toUpperCase(Locale.ROOT);
|
||||
String grantableKey = privilege.grantable() ? "1" : "0";
|
||||
if (notBlank(privilege.columnName())) {
|
||||
String key = privilege.grantee() + "\u0000" + privilegeName + "\u0000" + grantableKey;
|
||||
columnPrivileges
|
||||
.computeIfAbsent(key, ignored -> new ArrayList<>())
|
||||
.add(privilege.columnName().trim());
|
||||
} else {
|
||||
String key = privilege.grantee() + "\u0000" + grantableKey;
|
||||
List<String> names = tablePrivileges.computeIfAbsent(key, ignored -> new ArrayList<>());
|
||||
if (!names.contains(privilegeName)) {
|
||||
names.add(privilegeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> statements = new ArrayList<>();
|
||||
for (Map.Entry<String, List<String>> entry : tablePrivileges.entrySet()) {
|
||||
String[] parts = entry.getKey().split("\u0000", -1);
|
||||
String grantee = parts[0];
|
||||
boolean grantable = "1".equals(parts[1]);
|
||||
statements.add(formatOracleGrant(
|
||||
join(entry.getValue(), ", "),
|
||||
null,
|
||||
tableRef,
|
||||
grantee,
|
||||
grantable
|
||||
));
|
||||
}
|
||||
for (Map.Entry<String, List<String>> entry : columnPrivileges.entrySet()) {
|
||||
String[] parts = entry.getKey().split("\u0000", -1);
|
||||
String grantee = parts[0];
|
||||
String privilegeName = parts[1];
|
||||
boolean grantable = "1".equals(parts[2]);
|
||||
List<String> columns = new ArrayList<>();
|
||||
for (String column : entry.getValue()) {
|
||||
String quoted = quoteIdent(column, false);
|
||||
if (!columns.contains(quoted)) {
|
||||
columns.add(quoted);
|
||||
}
|
||||
}
|
||||
statements.add(formatOracleGrant(
|
||||
privilegeName,
|
||||
join(columns, ", "),
|
||||
tableRef,
|
||||
grantee,
|
||||
grantable
|
||||
));
|
||||
}
|
||||
return join(statements, "\n");
|
||||
}
|
||||
|
||||
private static String formatOracleGrant(
|
||||
String privilegeList,
|
||||
String columnList,
|
||||
String tableRef,
|
||||
String grantee,
|
||||
boolean grantable
|
||||
) {
|
||||
StringBuilder statement = new StringBuilder("GRANT ");
|
||||
statement.append(privilegeList);
|
||||
if (notBlank(columnList)) {
|
||||
statement.append(" (").append(columnList).append(")");
|
||||
}
|
||||
statement.append(" ON ").append(tableRef);
|
||||
statement.append(" TO ").append(quoteIdent(grantee, false));
|
||||
if (grantable) {
|
||||
statement.append(" WITH GRANT OPTION");
|
||||
}
|
||||
statement.append(";");
|
||||
return statement.toString();
|
||||
}
|
||||
|
||||
private static List<ForeignKeyGroup> groupForeignKeys(List<ForeignKeyInfo> foreignKeys) {
|
||||
List<ForeignKeyGroup> result = new ArrayList<>();
|
||||
Map<String, ForeignKeyGroup> namedGroups = new LinkedHashMap<>();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
/**
|
||||
* Oracle-compatible object privilege row used when reconstructing GRANT DDL.
|
||||
*/
|
||||
public final class OracleObjectPrivilege {
|
||||
private final String grantee;
|
||||
private final String privilege;
|
||||
private final boolean grantable;
|
||||
private final String columnName;
|
||||
|
||||
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable) {
|
||||
this(grantee, privilege, grantable, null);
|
||||
}
|
||||
|
||||
public OracleObjectPrivilege(String grantee, String privilege, boolean grantable, String columnName) {
|
||||
this.grantee = grantee;
|
||||
this.privilege = privilege;
|
||||
this.grantable = grantable;
|
||||
this.columnName = columnName;
|
||||
}
|
||||
|
||||
public String grantee() {
|
||||
return grantee;
|
||||
}
|
||||
|
||||
public String privilege() {
|
||||
return privilege;
|
||||
}
|
||||
|
||||
public boolean grantable() {
|
||||
return grantable;
|
||||
}
|
||||
|
||||
public String columnName() {
|
||||
return columnName;
|
||||
}
|
||||
}
|
||||
@@ -750,6 +750,36 @@ class CommonJavaCompatibilityTest {
|
||||
assertTrue(ddl.contains("\"NAME\" NVARCHAR(100) NOT NULL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsOracleObjectGrantSqlAfterTableDdl() {
|
||||
String ddl = DdlBuilder.buildTableDdl(
|
||||
"APP",
|
||||
"USERS",
|
||||
Collections.singletonList(new ColumnInfo("ID", "NUMBER", false, null, true)),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList()
|
||||
);
|
||||
String grants = DdlBuilder.buildOracleObjectGrantSql(
|
||||
"APP",
|
||||
"USERS",
|
||||
Arrays.asList(
|
||||
new OracleObjectPrivilege("READER", "SELECT", false),
|
||||
new OracleObjectPrivilege("READER", "INSERT", false),
|
||||
new OracleObjectPrivilege("ADMIN", "SELECT", true),
|
||||
new OracleObjectPrivilege("ANALYST", "UPDATE", false, "NAME")
|
||||
)
|
||||
);
|
||||
|
||||
String combined = DdlBuilder.appendTrailingSql(ddl, grants);
|
||||
|
||||
assertTrue(combined.contains("CREATE TABLE \"APP\".\"USERS\""));
|
||||
assertTrue(combined.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"));
|
||||
assertTrue(combined.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"));
|
||||
assertTrue(combined.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"));
|
||||
assertEquals("", DdlBuilder.buildOracleObjectGrantSql("APP", "USERS", Collections.emptyList()));
|
||||
assertEquals(ddl, DdlBuilder.appendTrailingSql(ddl, " "));
|
||||
}
|
||||
|
||||
private static class MinimalAgent implements DatabaseAgent {
|
||||
@Override
|
||||
public void connect(ConnectParams params) {
|
||||
|
||||
Generated
+72
-16
@@ -1154,6 +1154,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.10.7",
|
||||
"fiat-crypto 0.2.9",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
@@ -1245,11 +1246,12 @@ dependencies = [
|
||||
"csv",
|
||||
"dbx-sqlite-worker",
|
||||
"deadpool-postgres",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures",
|
||||
"httpdate",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"json5",
|
||||
"jsonwebtoken",
|
||||
@@ -1275,6 +1277,7 @@ dependencies = [
|
||||
"rust_decimal",
|
||||
"rustls 0.23.42",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -1551,19 +1554,43 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8 0.10.2",
|
||||
"signature 2.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"signature 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek 4.1.3",
|
||||
"ed25519 2.2.3",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1573,11 +1600,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-pre.6",
|
||||
"ed25519",
|
||||
"ed25519 3.0.0-rc.4",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1602,7 +1629,7 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
"once_cell",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"rustcrypto-ff",
|
||||
"rustcrypto-group",
|
||||
@@ -1799,6 +1826,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -2321,7 +2358,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2526,7 +2563,7 @@ dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"crypto-bigint",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint-dig",
|
||||
@@ -2538,7 +2575,7 @@ dependencies = [
|
||||
"sec1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"ssh-cipher",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -3729,6 +3766,16 @@ dependencies = [
|
||||
"spki 0.8.0-rc.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der 0.7.10",
|
||||
"spki 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0-rc.11"
|
||||
@@ -4399,10 +4446,10 @@ dependencies = [
|
||||
"crypto-primes",
|
||||
"digest 0.11.3",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"rand_core 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4448,7 +4495,7 @@ dependencies = [
|
||||
"der 0.8.1",
|
||||
"digest 0.10.7",
|
||||
"ecdsa",
|
||||
"ed25519-dalek",
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"elliptic-curve",
|
||||
"enum_dispatch",
|
||||
"flate2",
|
||||
@@ -4477,7 +4524,7 @@ dependencies = [
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1",
|
||||
"pkcs5",
|
||||
"pkcs8",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"polyval 0.7.3",
|
||||
"rand 0.10.2",
|
||||
"rand_core 0.10.1",
|
||||
@@ -4492,7 +4539,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"signature",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0-rc.4",
|
||||
"ssh-encoding",
|
||||
"subtle",
|
||||
@@ -5063,6 +5110,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
@@ -5415,7 +5471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -316,9 +316,13 @@ func runQueryBenchmark(process *agentProcess, duration time.Duration, concurrenc
|
||||
total += value
|
||||
}
|
||||
count := operations.Load()
|
||||
sampleCount := len(merged)
|
||||
if sampleCount < 1 {
|
||||
sampleCount = 1
|
||||
}
|
||||
return benchmarkResult{
|
||||
Concurrency: concurrency, Operations: count, Errors: failures.Load(), DurationMS: float64(elapsed.Microseconds()) / 1000,
|
||||
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(max(1, len(merged))),
|
||||
QPS: float64(count) / elapsed.Seconds(), MeanMS: total / float64(sampleCount),
|
||||
P50MS: percentile(merged, 0.50), P95MS: percentile(merged, 0.95), P99MS: percentile(merged, 0.99),
|
||||
PeakRSSKB: peakRSS.Load(),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module github.com/t8y2/dbx/agents/drivers/kingbase-go
|
||||
|
||||
go 1.22
|
||||
go 1.20
|
||||
|
||||
require gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||
|
||||
@@ -880,11 +880,27 @@ func (s *server) closeAllQuerySessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// minInt/maxInt replace the Go 1.21 builtins so the module stays go 1.20
|
||||
// compatible for the Windows 7 toolchain build.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func readQuerySessionPage(session *querySession, pageSize int) (queryPageResult, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
capacity := min(pageSize, session.remaining)
|
||||
capacity := minInt(pageSize, session.remaining)
|
||||
result := queryPageResult{Columns: session.columns, ColumnTypes: session.columnTypes, Rows: make([][]any, 0, capacity)}
|
||||
for len(result.Rows) < pageSize && session.remaining > 0 {
|
||||
if session.pending != nil {
|
||||
@@ -925,7 +941,7 @@ func readRows(rows *sql.Rows, maxRows int) (queryResult, error) {
|
||||
}
|
||||
columns = nonNilStrings(columns)
|
||||
columnTypes := columnTypeNames(rows)
|
||||
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, min(maxRows, 1024))}
|
||||
result := queryResult{Columns: columns, ColumnTypes: columnTypes, Rows: make([][]any, 0, minInt(maxRows, 1024))}
|
||||
for rows.Next() {
|
||||
if len(result.Rows) >= maxRows {
|
||||
result.Truncated = true
|
||||
@@ -1814,7 +1830,7 @@ func hasTopLevelSQLKeyword(sqlText string, index int, target string) bool {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth = max(depth-1, 0)
|
||||
depth = maxInt(depth-1, 0)
|
||||
default:
|
||||
if depth == 0 && isSQLWordStartByte(sqlText[index]) {
|
||||
keyword, next := sqlKeywordAt(sqlText, index)
|
||||
|
||||
@@ -1448,7 +1448,7 @@ func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
indexes, err := server.listIndexes("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1939,7 +1939,7 @@ func TestListTriggerDefinitionsFallsBackToSingleArgument(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
definitions, err := server.listTriggerDefinitions("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1973,7 +1973,7 @@ func TestListTriggersFallsBackToV7InternalPredicate(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
triggers, err := server.listTriggers("PUBLIC", "orders")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3442,7 +3442,7 @@ func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = openMetadataDB(t, state)
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
source, err := server.getObjectSource("PUBLIC", "format_name", "FUNCTION")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -4274,7 +4274,7 @@ func TestSchemaConnSkipsInitialAndRepeatedEmptySchema(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = db
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, err := server.schemaConn(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -4400,7 +4400,7 @@ func TestExecuteQueryReappliesSchemaForRepeatedRequests(t *testing.T) {
|
||||
server := newServer()
|
||||
server.db = db
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := server.executeQuery(queryOptions{SQL: "SELECT 1", Schema: "sdy_smartsite", MaxRows: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+136
-1
@@ -14,6 +14,7 @@ import com.dbx.agent.MultiSessionJsonRpcServer;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
import com.dbx.agent.ObjectInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.OracleObjectPrivilege;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
|
||||
@@ -556,7 +557,141 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
// Table comment is optional; DDL generation should still succeed without it.
|
||||
}
|
||||
|
||||
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, java.util.Collections.emptyList(), false, true, tableComment);
|
||||
String ddl = DdlBuilder.buildTableDdl(
|
||||
schema,
|
||||
table,
|
||||
getColumns(schema, table),
|
||||
indexes,
|
||||
foreignKeys,
|
||||
java.util.Collections.emptyList(),
|
||||
false,
|
||||
true,
|
||||
tableComment
|
||||
);
|
||||
try {
|
||||
String owner = normalizeSchema(schema);
|
||||
return DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table));
|
||||
} catch (RuntimeException | SQLException ignored) {
|
||||
// Privilege metadata is optional; CREATE TABLE DDL should still succeed without it.
|
||||
return ddl;
|
||||
}
|
||||
}
|
||||
|
||||
private String queryObjectGrantSql(String owner, String table) throws SQLException {
|
||||
List<OracleObjectPrivilege> privileges = new ArrayList<>();
|
||||
privileges.addAll(queryTablePrivileges(owner, table));
|
||||
try {
|
||||
privileges.addAll(queryColumnPrivileges(owner, table));
|
||||
} catch (SQLException ignored) {
|
||||
// Column privileges are optional when table-level grants are available.
|
||||
}
|
||||
return DdlBuilder.buildOracleObjectGrantSql(owner, table, privileges);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryTablePrivileges(String owner, String table) throws SQLException {
|
||||
// DBA_TAB_PRIVS shows every object grant (OWNER column). ALL_TAB_PRIVS is limited to
|
||||
// grants where the session user is owner/grantor/grantee (or PUBLIC), so admins
|
||||
// browsing another schema often see nothing unless we prefer the DBA view.
|
||||
return queryFirstAvailablePrivileges(tablePrivilegeQueries(), owner, table, null);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryColumnPrivileges(String owner, String table) throws SQLException {
|
||||
return queryFirstAvailablePrivileges(columnPrivilegeQueries(), owner, table, "COLUMN_NAME");
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryFirstAvailablePrivileges(
|
||||
List<String> queries,
|
||||
String owner,
|
||||
String table,
|
||||
String columnNameField
|
||||
) throws SQLException {
|
||||
SQLException firstError = null;
|
||||
for (String sql : queries) {
|
||||
try {
|
||||
return queryPrivileges(sql, owner, table, columnNameField);
|
||||
} catch (SQLException error) {
|
||||
if (firstError == null) {
|
||||
firstError = error;
|
||||
} else {
|
||||
firstError.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw firstError == null ? new SQLException("No privilege views available") : firstError;
|
||||
}
|
||||
|
||||
private static List<String> tablePrivilegeQueries() {
|
||||
return List.of(
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM DBA_TAB_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM SYS.DBA_TAB_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE
|
||||
FROM ALL_TAB_PRIVS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, PRIVILEGE
|
||||
""".stripIndent().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private static List<String> columnPrivilegeQueries() {
|
||||
return List.of(
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM DBA_COL_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM SYS.DBA_COL_PRIVS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim(),
|
||||
"""
|
||||
SELECT GRANTEE, PRIVILEGE, GRANTABLE, COLUMN_NAME
|
||||
FROM ALL_COL_PRIVS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY GRANTEE, COLUMN_NAME, PRIVILEGE
|
||||
""".stripIndent().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private List<OracleObjectPrivilege> queryPrivileges(
|
||||
String sql,
|
||||
String owner,
|
||||
String table,
|
||||
String columnNameField
|
||||
) throws SQLException {
|
||||
List<OracleObjectPrivilege> result = new ArrayList<>();
|
||||
try (var stmt = requireConnection().prepareStatement(sql)) {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(new OracleObjectPrivilege(
|
||||
rs.getString("GRANTEE"),
|
||||
rs.getString("PRIVILEGE"),
|
||||
isYes(rs.getString("GRANTABLE")),
|
||||
columnNameField == null ? null : rs.getString(columnNameField)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean isYes(String value) {
|
||||
return value != null && "YES".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+239
-1
@@ -439,7 +439,15 @@ class OceanBaseOracleAgentTest {
|
||||
columnResultSet(new Object[][]{
|
||||
{"CREATED_AT", "TIMESTAMP", "N", null, null, null, null, "SYSDATE", "Created timestamp", 0},
|
||||
{"INTERNAL_NOTE", "VARCHAR2", "Y", null, null, 100, 100, null, " ", 0}
|
||||
})
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "AUDIT_LOG");
|
||||
@@ -451,6 +459,147 @@ class OceanBaseOracleAgentTest {
|
||||
);
|
||||
Assertions.assertTrue(ddl.contains("\"INTERNAL_NOTE\" VARCHAR2(100)"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("\"INTERNAL_NOTE\" IS"), ddl);
|
||||
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlAppendsObjectGrantsFromDictionaryViews() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1},
|
||||
{"NAME", "VARCHAR2", "Y", null, null, 100, 100, null, null, 0}
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"READER", "SELECT", "NO"},
|
||||
{"READER", "INSERT", "NO"},
|
||||
{"ADMIN", "SELECT", "YES"}
|
||||
}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"ANALYST", "NAME", "UPDATE", "NO"}
|
||||
}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertTrue(ddl.contains("GRANT SELECT, INSERT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"ADMIN\" WITH GRANT OPTION;"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("GRANT UPDATE (\"NAME\") ON \"APP\".\"USERS\" TO \"ANALYST\";"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_COL_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlFallsBackToAllTabPrivsWhenDbaViewUnavailable() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, dbaFallbackPrivilegeConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
|
||||
}),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{
|
||||
{"READER", "SELECT", "NO"}
|
||||
}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"},
|
||||
new Object[][]{}
|
||||
)
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("GRANT SELECT ON \"APP\".\"USERS\" TO \"READER\";"), ddl);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM ALL_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM SYS.DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableDdlKeepsCreateTableWhenPrivilegeQueriesFail() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, privilegeFailingDdlConnection(sql,
|
||||
resultSet(
|
||||
new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"},
|
||||
new Object[][]{}
|
||||
),
|
||||
resultSet(
|
||||
new String[]{"COMMENTS"},
|
||||
new Object[][]{{null}}
|
||||
),
|
||||
columnResultSet(new Object[][]{
|
||||
{"ID", "NUMBER", "N", 19, 0, null, null, null, null, 1}
|
||||
})
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertFalse(ddl.contains("GRANT "), ddl);
|
||||
Assertions.assertTrue(
|
||||
sql.stream().anyMatch(statement -> statement.toUpperCase(Locale.ROOT).contains("FROM DBA_TAB_PRIVS")),
|
||||
String.valueOf(sql)
|
||||
);
|
||||
}
|
||||
|
||||
private static ResultSet columnResultSet(Object[][] rows) {
|
||||
@@ -496,6 +645,95 @@ class OceanBaseOracleAgentTest {
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection privilegeFailingDdlConnection(List<String> sql, ResultSet... resultSets) {
|
||||
int[] resultSetIndex = {0};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String statementSql = String.valueOf(args[0]);
|
||||
sql.add(statementSql);
|
||||
String normalized = statementSql.toUpperCase(Locale.ROOT);
|
||||
if (normalized.contains("DBA_TAB_PRIVS")
|
||||
|| normalized.contains("ALL_TAB_PRIVS")
|
||||
|| normalized.contains("DBA_COL_PRIVS")
|
||||
|| normalized.contains("ALL_COL_PRIVS")) {
|
||||
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
throw new SQLException("privilege view unavailable");
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return failing;
|
||||
}
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
|
||||
resultSetIndex[0] += 1;
|
||||
return resultSets[current];
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection dbaFallbackPrivilegeConnection(List<String> sql, ResultSet... resultSets) {
|
||||
int[] resultSetIndex = {0};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
String statementSql = String.valueOf(args[0]);
|
||||
sql.add(statementSql);
|
||||
String normalized = statementSql.toUpperCase(Locale.ROOT);
|
||||
if (normalized.contains("DBA_TAB_PRIVS") || normalized.contains("DBA_COL_PRIVS")) {
|
||||
PreparedStatement failing = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
throw new SQLException("DBA privilege view unavailable");
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return failing;
|
||||
}
|
||||
PreparedStatement statement = proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
if ("executeQuery".equals(statementMethod.getName())) {
|
||||
int current = Math.min(resultSetIndex[0], resultSets.length - 1);
|
||||
resultSetIndex[0] += 1;
|
||||
return resultSets[current];
|
||||
}
|
||||
if ("setString".equals(statementMethod.getName())
|
||||
|| "setInt".equals(statementMethod.getName())
|
||||
|| "close".equals(statementMethod.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
return statement;
|
||||
}
|
||||
if ("isClosed".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Connection schemaConnection(List<String> sql, ResultSet resultSet) {
|
||||
Statement statement = proxy(Statement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
|
||||
@@ -3401,6 +3401,15 @@ func (s *server) getViewSource(schema, name string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
viewName := strings.TrimSpace(name)
|
||||
var source string
|
||||
viewsErr := db.QueryRow(
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
|
||||
schema, viewName,
|
||||
).Scan(&source)
|
||||
if viewsErr == nil && strings.TrimSpace(source) != "" {
|
||||
return strings.TrimSpace(source), nil
|
||||
}
|
||||
|
||||
var ddl string
|
||||
metadataErr := db.QueryRow(
|
||||
"SELECT DBMS_METADATA.GET_DDL('VIEW', :1, :2) FROM DUAL",
|
||||
@@ -3410,22 +3419,11 @@ func (s *server) getViewSource(schema, name string) (string, error) {
|
||||
return strings.TrimSpace(ddl), nil
|
||||
}
|
||||
|
||||
var source string
|
||||
fallbackErr := db.QueryRow(
|
||||
"SELECT TEXT FROM ALL_VIEWS WHERE OWNER = :1 AND VIEW_NAME = :2",
|
||||
schema, viewName,
|
||||
).Scan(&source)
|
||||
if fallbackErr == nil && strings.TrimSpace(source) != "" {
|
||||
return strings.TrimSpace(source), nil
|
||||
}
|
||||
if fallbackErr != nil && !errors.Is(fallbackErr, sql.ErrNoRows) {
|
||||
if metadataErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to load view source for %s.%s: DBMS_METADATA: %v; ALL_VIEWS: %w",
|
||||
schema, viewName, metadataErr, fallbackErr,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("failed to load view source for %s.%s from ALL_VIEWS: %w", schema, viewName, fallbackErr)
|
||||
if viewsErr != nil && !errors.Is(viewsErr, sql.ErrNoRows) && metadataErr != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to load view source for %s.%s: ALL_VIEWS: %v; DBMS_METADATA: %w",
|
||||
schema, viewName, viewsErr, metadataErr,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("view source not found: %s.%s", schema, viewName)
|
||||
}
|
||||
|
||||
@@ -2757,15 +2757,15 @@ func fakeOracleColumnLoader(columns []oracleColumnMeta) oracleColumnMetaLoader {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
|
||||
func TestGetObjectSourceUsesOriginalViewNameWithAllViews(t *testing.T) {
|
||||
for _, viewName := range []string{"vEnginWJZ", "V_ENGINE_WJZ"} {
|
||||
t.Run(viewName, func(t *testing.T) {
|
||||
ddl := `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."` + viewName + `" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
source := `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
rows: [][]driver.Value{{ddl}},
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", viewName},
|
||||
rows: [][]driver.Value{{source}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
@@ -2775,7 +2775,7 @@ func TestGetObjectSourceUsesOriginalViewNameWithDBMSMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["source"] != ddl {
|
||||
if result["source"] != source {
|
||||
t.Fatalf("unexpected view source: %#v", result["source"])
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
@@ -2982,19 +2982,19 @@ func TestGetObjectSourcePreservesQuotedSynonymName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
func TestGetObjectSourceFallsBackToDBMSMetadataWithOriginalName(t *testing.T) {
|
||||
const viewName = "vEnginWJZ"
|
||||
const source = `SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
const ddl = `CREATE OR REPLACE FORCE VIEW "ZTZS_ERP2"."vEnginWJZ" AS SELECT source_id FROM "ZTZS_ERP2"."SOURCE_TABLE"`
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
{
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", viewName},
|
||||
rows: [][]driver.Value{{source}},
|
||||
err: errors.New("ORA-00942: table or view does not exist"),
|
||||
},
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{viewName, "ZTZS_ERP2"},
|
||||
rows: [][]driver.Value{{ddl}},
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
@@ -3004,7 +3004,7 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["source"] != source {
|
||||
if result["source"] != ddl {
|
||||
t.Fatalf("unexpected fallback source: %#v", result["source"])
|
||||
}
|
||||
if scripted.next != len(scripted.steps) {
|
||||
@@ -3014,16 +3014,16 @@ func TestGetObjectSourceFallsBackToAllViewsWithOriginalName(t *testing.T) {
|
||||
|
||||
func TestGetObjectSourceRejectsMissingViewSource(t *testing.T) {
|
||||
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
{
|
||||
queryContains: "FROM ALL_VIEWS",
|
||||
args: []driver.Value{"ZTZS_ERP2", "vEnginWJZ"},
|
||||
rows: nil,
|
||||
},
|
||||
{
|
||||
queryContains: "DBMS_METADATA.GET_DDL('VIEW'",
|
||||
args: []driver.Value{"vEnginWJZ", "ZTZS_ERP2"},
|
||||
err: errors.New("ORA-31603: object not found"),
|
||||
},
|
||||
})
|
||||
s := newServer()
|
||||
s.db = db
|
||||
|
||||
+33
-1
@@ -428,7 +428,39 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
return super.listIndexes(metadataSchema(schema, table), table);
|
||||
String resolvedSchema = metadataSchema(schema, table);
|
||||
List<IndexInfo> indexes = super.listIndexes(resolvedSchema, table);
|
||||
return markPrimaryKeyIndex(indexes, resolvedSchema, table);
|
||||
}
|
||||
|
||||
// SQL Server names a primary-key index after its constraint (PK__<table>__<hex>
|
||||
// or a user-chosen name), so the shared JDBC metadata layer's "PRIMARY"
|
||||
// index-name convention never matches and is_primary stayed false. Resolve
|
||||
// the flag from DatabaseMetaData.getPrimaryKeys() PK_NAME instead, so table
|
||||
// cloning, the index tree badge, and DDL output see the real primary key.
|
||||
private List<IndexInfo> markPrimaryKeyIndex(List<IndexInfo> indexes, String schema, String table) {
|
||||
if (indexes.isEmpty()) {
|
||||
return indexes;
|
||||
}
|
||||
try {
|
||||
String primaryKeyName = null;
|
||||
try (java.sql.ResultSet rs = requireConnection().getMetaData().getPrimaryKeys(null, schema, table)) {
|
||||
if (rs.next()) {
|
||||
primaryKeyName = rs.getString("PK_NAME");
|
||||
}
|
||||
}
|
||||
if (primaryKeyName == null || primaryKeyName.trim().isEmpty()) {
|
||||
return indexes;
|
||||
}
|
||||
for (IndexInfo index : indexes) {
|
||||
if (primaryKeyName.equals(index.getName())) {
|
||||
index.setIs_primary(true);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Fail soft: keep the metadata-layer flags when primary-key lookup is unavailable.
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+31
@@ -2,6 +2,7 @@ package com.dbx.agent.sqlserverlegacy;
|
||||
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -603,4 +604,34 @@ class SqlServerLegacyAgentTest {
|
||||
private interface MethodHandler {
|
||||
Object handle(Method method, Object[] args) throws Throwable;
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIndexesMarksPrimaryKeyByConstraintName() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
DatabaseMetaData metadata = proxy(DatabaseMetaData.class, (method, args) -> {
|
||||
if ("getIndexInfo".equals(method.getName())) {
|
||||
return metadataResultSet(Arrays.asList(
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id"),
|
||||
Arrays.asList("IX_orders_name", "name")
|
||||
), Map.of("INDEX_NAME", 0, "COLUMN_NAME", 1));
|
||||
}
|
||||
if ("getPrimaryKeys".equals(method.getName())) {
|
||||
return metadataResultSet(Arrays.asList(
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "id"),
|
||||
Arrays.asList("PK__orders__3213E83F5A9632C8", "tenant_id")
|
||||
), Map.of("PK_NAME", 0, "COLUMN_NAME", 1));
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
TestSupport.setPrivateConnection(agent, proxy(Connection.class, (method, args) ->
|
||||
"getMetaData".equals(method.getName()) ? metadata : defaultValue(method.getReturnType())));
|
||||
|
||||
List<IndexInfo> indexes = agent.listIndexes("dbo", "orders");
|
||||
|
||||
List<IndexInfo> primary = indexes.stream().filter(IndexInfo::getIs_primary).toList();
|
||||
Assertions.assertEquals(1, primary.size(), indexes.toString());
|
||||
Assertions.assertEquals("PK__orders__3213E83F5A9632C8", primary.get(0).getName());
|
||||
Assertions.assertEquals(List.of("id", "tenant_id"), primary.get(0).getColumns());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module github.com/t8y2/dbx/agents/drivers/xugu
|
||||
|
||||
go 1.22
|
||||
go 1.20
|
||||
|
||||
require gitee.com/XuguDB/go-xugu-driver v1.0.12
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestConnectionConfiguration(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
if !hasTLSOptions(connectionConfig{CACertPath: "/tmp/ca.pem"}) {
|
||||
if !tlsOptionsPresent(connectionConfig{CACertPath: "/tmp/ca.pem"}) {
|
||||
t.Fatal("TLS path was not detected")
|
||||
}
|
||||
for input, expected := range map[string]int{"": 16, "bad": 16, "0": 1, "32": 32, "100": 64} {
|
||||
@@ -113,7 +113,9 @@ func TestConnectionValidationHappensBeforeNetwork(t *testing.T) {
|
||||
config connectionConfig
|
||||
message string
|
||||
}{
|
||||
{"tls", connectionConfig{SSL: true}, "ZooKeeper TLS is not supported"},
|
||||
{"tls client cert without key", connectionConfig{ClientCertPath: "/tmp/client.crt"}, "client key (client_key_path) is required"},
|
||||
{"tls client key without cert", connectionConfig{ClientKeyPath: "/tmp/client.key"}, "client certificate (client_cert_path) is required"},
|
||||
{"tls missing CA file", connectionConfig{CACertPath: "/tmp/missing-ca.pem"}, "load ZooKeeper CA certificate"},
|
||||
{"auth", connectionConfig{AuthScheme: "sasl"}, `Unsupported auth_scheme "sasl"`},
|
||||
{"sasl username", connectionConfig{AuthScheme: saslDigestAuthScheme, Password: "secret"}, `username is required when auth_scheme = "sasl_digest"`},
|
||||
{"sasl password", connectionConfig{AuthScheme: saslDigestAuthScheme, Username: "user"}, `password is required when auth_scheme = "sasl_digest"`},
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -147,8 +148,9 @@ func (service *server) connectionInfo() (map[string]any, error) {
|
||||
}
|
||||
|
||||
func openClient(config connectionConfig) (*clientSession, error) {
|
||||
if hasTLSOptions(config) {
|
||||
return nil, errors.New("ZooKeeper TLS is not supported")
|
||||
tlsConfig, err := buildZooKeeperTLSConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authScheme := resolveAuthScheme(config)
|
||||
if authScheme != defaultAuthScheme && authScheme != saslDigestAuthScheme {
|
||||
@@ -179,16 +181,16 @@ func openClient(config connectionConfig) (*clientSession, error) {
|
||||
}
|
||||
connectionTimeout := millisecondsOrDefault(config.ConnectionTimeoutMS, defaultConnectionTimeout)
|
||||
probeTimeout := minDuration(defaultProbeTimeout, connectionTimeout)
|
||||
if err := requireReachableServer(target.Servers, probeTimeout); err != nil {
|
||||
if err := requireReachableServer(target.Servers, probeTimeout, tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialer := newZooKeeperDialer(connectionTimeout, nil)
|
||||
dialer := newZooKeeperDialer(connectionTimeout, nil, tlsConfig)
|
||||
if authScheme == saslDigestAuthScheme {
|
||||
dialer = newZooKeeperDialer(connectionTimeout, &saslDigestCredentials{
|
||||
Username: strings.TrimSpace(config.Username),
|
||||
Password: config.Password,
|
||||
})
|
||||
}, tlsConfig)
|
||||
}
|
||||
|
||||
sessionTimeout := millisecondsOrDefault(config.SessionTimeoutMS, defaultSessionTimeout)
|
||||
@@ -254,13 +256,13 @@ func openClient(config connectionConfig) (*clientSession, error) {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func newZooKeeperDialer(connectionTimeout time.Duration, credentials *saslDigestCredentials) zk.Dialer {
|
||||
func newZooKeeperDialer(connectionTimeout time.Duration, credentials *saslDigestCredentials, tlsConfig *tls.Config) zk.Dialer {
|
||||
return func(network, address string, libraryTimeout time.Duration) (net.Conn, error) {
|
||||
timeout := libraryTimeout
|
||||
if timeout <= 0 || connectionTimeout < timeout {
|
||||
timeout = connectionTimeout
|
||||
}
|
||||
connection, err := net.DialTimeout(network, address, timeout)
|
||||
connection, err := dialZooKeeperConnection(address, timeout, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,13 +296,17 @@ func databaseInfo(config connectionConfig) map[string]any {
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
if version := detectServerVersion(target.Servers, millisecondsOrDefault(config.ConnectionTimeoutMS, defaultConnectionTimeout)); version != "" {
|
||||
tlsConfig, err := buildZooKeeperTLSConfig(config)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
if version := detectServerVersion(target.Servers, millisecondsOrDefault(config.ConnectionTimeoutMS, defaultConnectionTimeout), tlsConfig); version != "" {
|
||||
info["productVersion"] = version
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func detectServerVersion(servers []string, timeout time.Duration) string {
|
||||
func detectServerVersion(servers []string, timeout time.Duration, tlsConfig *tls.Config) string {
|
||||
deadline := timeout
|
||||
if deadline <= 0 || deadline > 2*time.Second {
|
||||
deadline = 2 * time.Second
|
||||
@@ -313,7 +319,7 @@ func detectServerVersion(servers []string, timeout time.Duration) string {
|
||||
// ZooKeeper 3.5+ whitelists only "srvr" by default; "envi"/"stat"
|
||||
// are opt-in, so probe srvr first for default-config clusters.
|
||||
for _, command := range []string{"srvr", "envi", "stat"} {
|
||||
connection, err := net.DialTimeout("tcp", address, deadline)
|
||||
connection, err := dialZooKeeperConnection(address, deadline, tlsConfig)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -378,7 +384,7 @@ func parseConnectTarget(value string) (connectTarget, error) {
|
||||
return connectTarget{Servers: servers, Chroot: chroot}, nil
|
||||
}
|
||||
|
||||
func requireReachableServer(servers []string, timeout time.Duration) error {
|
||||
func requireReachableServer(servers []string, timeout time.Duration, tlsConfig *tls.Config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout+500*time.Millisecond)
|
||||
defer cancel()
|
||||
workers := minInt(len(servers), maximumReachabilityWorkers)
|
||||
@@ -394,7 +400,7 @@ func requireReachableServer(servers []string, timeout time.Duration) error {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
connection, err := net.DialTimeout("tcp", address, timeout)
|
||||
connection, err := dialZooKeeperConnection(address, timeout, tlsConfig)
|
||||
if err == nil {
|
||||
connection.Close()
|
||||
select {
|
||||
@@ -485,16 +491,6 @@ func connectionURLParams(config connectionConfig) url.Values {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func hasTLSOptions(config connectionConfig) bool {
|
||||
return config.SSL || firstNonBlank(
|
||||
config.CACertPath,
|
||||
config.ClientCertPath,
|
||||
config.ClientKeyPath,
|
||||
config.CertPath,
|
||||
config.KeyPath,
|
||||
) != ""
|
||||
}
|
||||
|
||||
func joinPrefix(chroot, namespace string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if normalized := normalizePrefix(chroot); normalized != "" {
|
||||
|
||||
@@ -171,3 +171,32 @@ func BenchmarkZooKeeperOperations(b *testing.B) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestZooKeeperTLSIntegration(t *testing.T) {
|
||||
connectString := os.Getenv("DBX_ZOOKEEPER_TLS_TEST_CONNECT_STRING")
|
||||
if connectString == "" {
|
||||
t.Skip("DBX_ZOOKEEPER_TLS_TEST_CONNECT_STRING is not set")
|
||||
}
|
||||
caCertPath := os.Getenv("DBX_ZOOKEEPER_TLS_TEST_CA_CERT_PATH")
|
||||
if caCertPath == "" {
|
||||
t.Skip("DBX_ZOOKEEPER_TLS_TEST_CA_CERT_PATH is not set")
|
||||
}
|
||||
service := &server{statLookupConcurrency: 4}
|
||||
connection := map[string]any{
|
||||
"zookeeper_connect_string": connectString,
|
||||
"ssl": true,
|
||||
"ca_cert_path": caCertPath,
|
||||
}
|
||||
if _, err := service.connect(mustJSON(map[string]any{"connection": connection})); err != nil {
|
||||
t.Fatalf("connect over TLS: %v", err)
|
||||
}
|
||||
t.Cleanup(service.closeClient)
|
||||
root := fmt.Sprintf("/dbx-go-tls-integration-%d", time.Now().UnixNano())
|
||||
if _, err := service.put(mustJSON(map[string]any{"key": root + "/value", "value": map[string]string{"encoding": "utf8", "data": "secret"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.get(mustJSON(map[string]any{"key": root + "/value"}))
|
||||
if err != nil || result["found"] != true {
|
||||
t.Fatalf("get=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dialZooKeeperConnection opens a raw ZooKeeper transport connection. When
|
||||
// tlsConfig is non-nil the connection is wrapped in a TLS handshake so the
|
||||
// caller can talk to a secure (clientCnxn with ssl) ZooKeeper port.
|
||||
func dialZooKeeperConnection(address string, timeout time.Duration, tlsConfig *tls.Config) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
if tlsConfig == nil {
|
||||
return dialer.Dial("tcp", address)
|
||||
}
|
||||
config := tlsConfig.Clone()
|
||||
if config.ServerName == "" {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ZooKeeper TLS address %q: %w", address, err)
|
||||
}
|
||||
config.ServerName = host
|
||||
}
|
||||
return tls.DialWithDialer(dialer, "tcp", address, config)
|
||||
}
|
||||
|
||||
// buildZooKeeperTLSConfig turns the TLS-related fields of a connectionConfig
|
||||
// into a *tls.Config. It returns (nil, nil) when TLS is not requested so the
|
||||
// caller can keep using the plain TCP dialer for non-secure clusters.
|
||||
func buildZooKeeperTLSConfig(config connectionConfig) (*tls.Config, error) {
|
||||
if !tlsOptionsPresent(config) {
|
||||
return nil, nil
|
||||
}
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
caPath := firstNonBlank(config.CACertPath)
|
||||
if caPath != "" {
|
||||
pool, err := loadCACertPool(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ZooKeeper CA certificate: %w", err)
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
certPath := firstNonBlank(config.ClientCertPath, config.CertPath)
|
||||
keyPath := firstNonBlank(config.ClientKeyPath, config.KeyPath)
|
||||
switch {
|
||||
case certPath != "" && keyPath != "":
|
||||
certificate, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load ZooKeeper client certificate: %w", err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{certificate}
|
||||
case certPath != "":
|
||||
return nil, errors.New("client key (client_key_path) is required when a client certificate is provided")
|
||||
case keyPath != "":
|
||||
return nil, errors.New("client certificate (client_cert_path) is required when a client key is provided")
|
||||
}
|
||||
// insecure_skip_verify is exposed only as a connection URL parameter
|
||||
// (e.g. ?insecure_skip_verify=true). It defaults to off, so callers that
|
||||
// don't set it get full certificate-chain verification. Only enable it for
|
||||
// trusted or test-only clusters, since it disables that verification.
|
||||
if paramBool(connectionURLParams(config), "insecure_skip_verify") {
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
}
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
// tlsOptionsPresent reports whether the connection configuration requests a
|
||||
// TLS-secured connection, either via the explicit ssl flag or by supplying any
|
||||
// certificate/material paths.
|
||||
func tlsOptionsPresent(config connectionConfig) bool {
|
||||
return config.SSL || firstNonBlank(
|
||||
config.CACertPath,
|
||||
config.ClientCertPath,
|
||||
config.ClientKeyPath,
|
||||
config.CertPath,
|
||||
config.KeyPath,
|
||||
) != ""
|
||||
}
|
||||
|
||||
// loadCACertPool reads a PEM-encoded trust store (one or more CERTIFICATE
|
||||
// blocks) and returns an x509.CertPool built from it. It uses
|
||||
// x509.CertPool.AppendCertsFromPEM (the same helper etcd-go relies on) so a
|
||||
// single call parses every CERTIFICATE block in the file, including CA-chain
|
||||
// bundles, without a manual decode loop.
|
||||
func loadCACertPool(path string) (*x509.CertPool, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(contents) {
|
||||
return nil, errors.New("PEM CA file contains no CERTIFICATE blocks")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// paramBool parses a boolean connection URL parameter, treating missing or
|
||||
// unparseable values as false.
|
||||
func paramBool(params url.Values, name string) bool {
|
||||
value := strings.TrimSpace(params.Get(name))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writePEM(t *testing.T, dir, name, blockType string, der []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func generateCertificate(t *testing.T, dir string, ca *x509.Certificate, caKey *ecdsa.PrivateKey) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "dbx-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: ca == nil,
|
||||
}
|
||||
signer := ca
|
||||
signerKey := caKey
|
||||
if ca == nil {
|
||||
signer = template
|
||||
signerKey = key
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, signer, &key.PublicKey, signerKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
certPath = writePEM(t, dir, "cert.pem", "CERTIFICATE", der)
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
keyPath = writePEM(t, dir, "key.pem", "EC PRIVATE KEY", keyDER)
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
func TestBuildZooKeeperTLSConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caCertPath, caKeyPath := generateCertificate(t, dir, nil, nil)
|
||||
caDER, _ := os.ReadFile(caCertPath)
|
||||
caBlock, _ := pem.Decode(caDER)
|
||||
ca, err := x509.ParseCertificate(caBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ca: %v", err)
|
||||
}
|
||||
keyDER, _ := os.ReadFile(caKeyPath)
|
||||
caKeyBlock, _ := pem.Decode(keyDER)
|
||||
caKey, err := x509.ParseECPrivateKey(caKeyBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ca key: %v", err)
|
||||
}
|
||||
|
||||
t.Run("disabled without options", func(t *testing.T) {
|
||||
config, err := buildZooKeeperTLSConfig(connectionConfig{})
|
||||
if err != nil || config != nil {
|
||||
t.Fatalf("expected (nil, nil), got (%v, %v)", config, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ca only sets RootCAs", func(t *testing.T) {
|
||||
caPath, _ := generateCertificate(t, dir, ca, caKey)
|
||||
config, err := buildZooKeeperTLSConfig(connectionConfig{CACertPath: caPath})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if config == nil || config.RootCAs == nil {
|
||||
t.Fatal("expected tls.Config with RootCAs")
|
||||
}
|
||||
if config.MinVersion != 0x0303 { // TLS 1.2
|
||||
t.Fatalf("expected MinVersion TLS1.2, got %x", config.MinVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client cert and key load", func(t *testing.T) {
|
||||
clientCert, clientKey := generateCertificate(t, dir, ca, caKey)
|
||||
config, err := buildZooKeeperTLSConfig(connectionConfig{
|
||||
ClientCertPath: clientCert,
|
||||
ClientKeyPath: clientKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(config.Certificates) != 1 {
|
||||
t.Fatalf("expected 1 client certificate, got %d", len(config.Certificates))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client cert without key errors", func(t *testing.T) {
|
||||
clientCert, _ := generateCertificate(t, dir, ca, caKey)
|
||||
if _, err := buildZooKeeperTLSConfig(connectionConfig{ClientCertPath: clientCert}); err == nil {
|
||||
t.Fatal("expected error when client cert has no key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("insecure skip verify", func(t *testing.T) {
|
||||
config, err := buildZooKeeperTLSConfig(connectionConfig{
|
||||
SSL: true,
|
||||
URLParams: "insecure_skip_verify=true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !config.InsecureSkipVerify {
|
||||
t.Fatal("expected InsecureSkipVerify true")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"databend": "0.1.45",
|
||||
"databricks": "0.1.52",
|
||||
"db2": "0.1.58",
|
||||
"duckdb": "0.1.17",
|
||||
"duckdb": "0.1.18",
|
||||
"exasol": "0.1.52",
|
||||
"firebird": "0.1.53",
|
||||
"gbase8a": "0.1.52",
|
||||
@@ -46,9 +46,9 @@
|
||||
"iotdb": "0.1.41",
|
||||
"etcd": "0.1.43",
|
||||
"etcd2": "0.1.1",
|
||||
"zookeeper": "0.1.30",
|
||||
"zookeeper": "0.1.31",
|
||||
"kafka": "0.1.14",
|
||||
"rocketmq": "0.1.13",
|
||||
"rabbitmq": "0.1.11",
|
||||
"sqlserver-legacy": "0.1.29"
|
||||
"sqlserver-legacy": "0.1.30"
|
||||
}
|
||||
|
||||
+209
-74
@@ -70,6 +70,7 @@ import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
|
||||
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
|
||||
@@ -114,7 +115,7 @@ import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/l
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
|
||||
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
|
||||
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
@@ -151,10 +152,12 @@ import { resolveWindowContext } from "@/lib/app/windowContext";
|
||||
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
|
||||
|
||||
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
|
||||
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
|
||||
const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue"));
|
||||
const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue"));
|
||||
const SqlFilePanel = defineAsyncComponent(() => import("@/components/layout/SqlFilePanel.vue"));
|
||||
const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue"));
|
||||
const PluginCenterPage = defineAsyncComponent(() => import("@/components/plugins/PluginContributionsPanel.vue"));
|
||||
const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue"));
|
||||
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
|
||||
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
|
||||
@@ -323,8 +326,13 @@ const showQueryEditorObjectSourceDialog = ref(false);
|
||||
const driverStoreTabOpen = ref(false);
|
||||
const driverStoreActive = ref(false);
|
||||
const driverStoreActiveTab = ref<"agent" | "jdbc" | "storage" | "runtime">("agent");
|
||||
const settingsReturnSurface = ref<"query" | "driverStore" | "welcome">("welcome");
|
||||
const pluginCenterTabOpen = ref(false);
|
||||
const pluginCenterActive = ref(false);
|
||||
const pluginCenterFocus = ref<PluginCenterFocus | null>(null);
|
||||
const connectionPluginProvider = ref<PluginCenterFocus | null>(null);
|
||||
const settingsReturnSurface = ref<"query" | "driverStore" | "pluginCenter" | "welcome">("welcome");
|
||||
const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value);
|
||||
const showPluginCenter = computed(() => pluginCenterTabOpen.value && pluginCenterActive.value);
|
||||
const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive));
|
||||
const showQuickOpen = ref(false);
|
||||
const showTabSwitcher = ref(false);
|
||||
@@ -409,6 +417,12 @@ const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQ
|
||||
let aiRunsQuitConfirmed = false;
|
||||
|
||||
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
|
||||
// Plugin workbench tabs stay mounted once opened (hidden via v-show): an
|
||||
// iframe moved out of the DOM reloads from scratch, so KeepAlive/ContentArea
|
||||
// remounts flash the whole webview and drop its live session state.
|
||||
const mountedPluginWorkbenchTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench));
|
||||
type PluginWorkbenchTabHandle = { refresh: () => Promise<unknown> };
|
||||
const pluginWorkbenchTabRefs = new Map<string, PluginWorkbenchTabHandle>();
|
||||
const tabNavigationHistory = ref(createTabNavigationHistory());
|
||||
let pendingTabHistoryNavigationId: string | null = null;
|
||||
let detachedEventUnlisteners: Array<() => void> = [];
|
||||
@@ -769,18 +783,23 @@ const executableSql = computed(() => {
|
||||
: "";
|
||||
});
|
||||
|
||||
async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot) {
|
||||
const tab = activeTab.value;
|
||||
return tab
|
||||
? await resolveExecutableSqlWithBackend(snapshot?.fullSql ?? tab.sql, snapshot?.selectedSql ?? selectedSql.value, {
|
||||
mode: settingsStore.editorSettings.executeMode,
|
||||
cursorPos: snapshot?.cursorPos ?? cursorPos.value,
|
||||
databaseType: activeConnection.value?.db_type,
|
||||
})
|
||||
: "";
|
||||
async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot, executionTab?: QueryTab) {
|
||||
const tab = executionTab ?? activeTab.value;
|
||||
if (!tab) return "";
|
||||
const connection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value;
|
||||
const selection = tab.editorSelection;
|
||||
const fallbackSelectedSql = tab.id === activeTab.value?.id ? selectedSql.value : selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
|
||||
return await resolveExecutableSqlWithBackend(snapshot?.fullSql ?? tab.sql, snapshot?.selectedSql ?? fallbackSelectedSql, {
|
||||
mode: settingsStore.editorSettings.executeMode,
|
||||
cursorPos: snapshot?.cursorPos ?? (tab.id === activeTab.value?.id ? cursorPos.value : (selection?.head ?? 0)),
|
||||
databaseType: connection?.db_type,
|
||||
});
|
||||
}
|
||||
|
||||
const blockDangerousRedisCommands = ref(true);
|
||||
const blockDangerousRedisCommands = computed({
|
||||
get: () => settingsStore.editorSettings.blockDangerousRedisCommands,
|
||||
set: (value: boolean) => settingsStore.updateEditorSettings({ blockDangerousRedisCommands: value }),
|
||||
});
|
||||
const databaseRequiredSignal = ref(0);
|
||||
const databaseRequiredTabId = ref<string | null>(null);
|
||||
const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot & { tabId?: string }>();
|
||||
@@ -828,22 +847,21 @@ const {
|
||||
onExecutionCancelled: (editorViewportRequestId) => contentAreaRef.value?.cancelQueryEditorExecutionViewport(editorViewportRequestId),
|
||||
});
|
||||
|
||||
function captureActiveEditorExecutionSnapshot() {
|
||||
const snapshot = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();
|
||||
pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;
|
||||
function captureActiveEditorExecutionSnapshot(tabId: string) {
|
||||
const snapshot = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.(tabId);
|
||||
pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId } : undefined;
|
||||
}
|
||||
|
||||
function requestActiveEditorExecute(source?: "pointer" | "keyboard") {
|
||||
function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: string) {
|
||||
const snapshot = pendingToolbarExecutionSnapshot.value;
|
||||
pendingToolbarExecutionSnapshot.value = undefined;
|
||||
if (source === "pointer") {
|
||||
if (snapshot) {
|
||||
void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });
|
||||
return;
|
||||
}
|
||||
const targetTabId = tabId ?? (source === "pointer" ? snapshot?.tabId : undefined);
|
||||
if (source === "pointer" && snapshot && snapshot.tabId === targetTabId) {
|
||||
void tryExecute(snapshot, { tabId: targetTabId });
|
||||
return;
|
||||
}
|
||||
if (contentAreaRef.value?.requestQueryEditorExecute?.()) return;
|
||||
void tryExecute();
|
||||
if (contentAreaRef.value?.requestQueryEditorExecute?.(targetTabId)) return;
|
||||
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
|
||||
}
|
||||
|
||||
function requestActiveEditorExecuteInNewResultTab() {
|
||||
@@ -859,11 +877,13 @@ function requestActiveEditorExecuteInNewResultTab() {
|
||||
const specialPageTabs = computed(() => ({
|
||||
settingsOpen: settingsPageTabOpen.value,
|
||||
settingsActive: settingsStore.settingsPageActive,
|
||||
pluginCenterOpen: pluginCenterTabOpen.value,
|
||||
pluginCenterActive: pluginCenterActive.value,
|
||||
driverStoreOpen: driverStoreTabOpen.value,
|
||||
driverStoreActive: driverStoreActive.value,
|
||||
driverUpdateCount: toolbarAgentDriverUpdateCount.value,
|
||||
}));
|
||||
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || settingsStore.settingsPageActive))));
|
||||
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive))));
|
||||
provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
explainMode,
|
||||
blockDangerousRedisCommands,
|
||||
@@ -890,6 +910,8 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
specialPageTabs,
|
||||
activateSettingsPage,
|
||||
closeSettingsPage,
|
||||
activatePluginCenter: () => openPluginCenterPage(pluginCenterFocus.value),
|
||||
closePluginCenter: closePluginCenterPage,
|
||||
activateDriverStore: () => openDriverStorePage(),
|
||||
closeDriverStore: closeDriverStorePage,
|
||||
});
|
||||
@@ -975,6 +997,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
||||
openSqlFilePath,
|
||||
openDbFilePath,
|
||||
openConnectionDeepLink,
|
||||
closeActiveSurface,
|
||||
openAiConfigDeepLink,
|
||||
});
|
||||
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
|
||||
@@ -1005,16 +1028,17 @@ function openSettings(initialTab = "appearance", initialSection?: string) {
|
||||
settingsInitialSection.value = initialSection;
|
||||
settingsNavigationRequestId.value += 1;
|
||||
if (!settingsStore.settingsPageActive) {
|
||||
settingsReturnSurface.value = showDriverStore.value ? "driverStore" : activeTab.value ? "query" : "welcome";
|
||||
settingsReturnSurface.value = showDriverStore.value ? "driverStore" : showPluginCenter.value ? "pluginCenter" : activeTab.value ? "query" : "welcome";
|
||||
}
|
||||
activateSettingsPage();
|
||||
}
|
||||
|
||||
type MainContentSurface = "query" | "settings" | "driverStore";
|
||||
type MainContentSurface = "query" | "settings" | "driverStore" | "pluginCenter";
|
||||
|
||||
function activateMainContentSurface(surface: MainContentSurface) {
|
||||
settingsStore.settingsPageActive = surface === "settings";
|
||||
driverStoreActive.value = surface === "driverStore";
|
||||
pluginCenterActive.value = surface === "pluginCenter";
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -1042,6 +1066,10 @@ function activateOpenSpecialPageFallback() {
|
||||
}
|
||||
if (driverStoreTabOpen.value) {
|
||||
activateMainContentSurface("driverStore");
|
||||
return;
|
||||
}
|
||||
if (pluginCenterTabOpen.value) {
|
||||
activateMainContentSurface("pluginCenter");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,12 +1079,24 @@ function closeSettingsPage() {
|
||||
activateMainContentSurface("driverStore");
|
||||
return;
|
||||
}
|
||||
if (settingsReturnSurface.value === "pluginCenter" && pluginCenterTabOpen.value) {
|
||||
activateMainContentSurface("pluginCenter");
|
||||
return;
|
||||
}
|
||||
if (driverStoreTabOpen.value) {
|
||||
activateMainContentSurface("driverStore");
|
||||
return;
|
||||
}
|
||||
if (pluginCenterTabOpen.value) {
|
||||
activateMainContentSurface("pluginCenter");
|
||||
return;
|
||||
}
|
||||
activateMainContentSurface("query");
|
||||
}
|
||||
|
||||
const driverStoreFocus = ref<DriverStoreFocus | null>(null);
|
||||
|
||||
function openDriverStorePage(target?: "agent" | "jdbc" | "storage" | "runtime" | DriverStoreFocus | null) {
|
||||
function openDriverStorePage(target?: DriverStoreTab | DriverStoreFocus | null) {
|
||||
if (typeof target === "string") {
|
||||
driverStoreActiveTab.value = target;
|
||||
driverStoreFocus.value = null;
|
||||
@@ -1068,14 +1108,48 @@ function openDriverStorePage(target?: "agent" | "jdbc" | "storage" | "runtime" |
|
||||
}
|
||||
driverStoreTabOpen.value = true;
|
||||
activateMainContentSurface("driverStore");
|
||||
pluginCenterActive.value = false;
|
||||
}
|
||||
|
||||
function closeDriverStorePage() {
|
||||
driverStoreTabOpen.value = false;
|
||||
activateMainContentSurface("query");
|
||||
// Keep another open special page visible when closing the active one. The
|
||||
// previous implementation always switched to the query surface, which
|
||||
// cleared pluginCenterActive and made an already-open Plugin Center tab
|
||||
// disappear together with Driver Manager.
|
||||
if (pluginCenterTabOpen.value) {
|
||||
activateMainContentSurface("pluginCenter");
|
||||
} else if (settingsPageTabOpen.value) {
|
||||
activateMainContentSurface("settings");
|
||||
} else {
|
||||
activateMainContentSurface("query");
|
||||
}
|
||||
driverStoreActiveTab.value = "agent";
|
||||
driverStoreFocus.value = null;
|
||||
}
|
||||
|
||||
function openPluginCenterPage(focus?: PluginCenterFocus | null) {
|
||||
pluginCenterFocus.value = focus ?? null;
|
||||
pluginCenterTabOpen.value = true;
|
||||
activateMainContentSurface("pluginCenter");
|
||||
}
|
||||
|
||||
function closePluginCenterPage() {
|
||||
pluginCenterTabOpen.value = false;
|
||||
pluginCenterFocus.value = null;
|
||||
if (driverStoreTabOpen.value) {
|
||||
activateMainContentSurface("driverStore");
|
||||
} else if (settingsPageTabOpen.value) {
|
||||
activateMainContentSurface("settings");
|
||||
} else {
|
||||
activateMainContentSurface("query");
|
||||
}
|
||||
}
|
||||
|
||||
function openPluginConnectionDialog(pluginId: string, providerId: string) {
|
||||
connectionPluginProvider.value = { pluginId, providerId };
|
||||
showConnectionDialog.value = true;
|
||||
}
|
||||
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
|
||||
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
|
||||
const toolbarMcpUpdateAvailable = computed(() => updateNotificationsEnabled.value && mcpUpdateAvailable.value);
|
||||
@@ -1263,6 +1337,7 @@ watch(
|
||||
if (id) newQueryContextSource.value = "tab";
|
||||
if (id) activateQuerySurface();
|
||||
else if (previousId) activateOpenSpecialPageFallback();
|
||||
if (id && pluginCenterActive.value) pluginCenterActive.value = false;
|
||||
const tab = id ? queryStore.tabs.find((candidate) => candidate.id === id) : undefined;
|
||||
const selection = tab?.editorSelection;
|
||||
selectedSql.value = tab && selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
|
||||
@@ -2242,6 +2317,7 @@ async function openConnectionDeepLink(url: string) {
|
||||
if (!draft) return;
|
||||
connectionStore.stopEditing();
|
||||
connectionStore.stopCreatingConnectionInGroup();
|
||||
connectionPluginProvider.value = null;
|
||||
connectionDialogPrefill.value = draft;
|
||||
showConnectionDialog.value = true;
|
||||
} catch (e: any) {
|
||||
@@ -2299,12 +2375,14 @@ function setConnectionDialogOpen(value: boolean) {
|
||||
showConnectionDialog.value = value;
|
||||
if (!value) {
|
||||
connectionDialogPrefill.value = null;
|
||||
connectionPluginProvider.value = null;
|
||||
connectionDialogInitialTab.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "connection") {
|
||||
if (!connectionStore.getConfig(connectionId)) return;
|
||||
connectionPluginProvider.value = null;
|
||||
connectionDialogInitialTab.value = initialTab;
|
||||
connectionStore.startEditing(connectionId);
|
||||
showConnectionDialog.value = true;
|
||||
@@ -2331,6 +2409,8 @@ async function newQuery() {
|
||||
} else if (connectionTarget.kind === "nacos-admin") {
|
||||
await connectionStore.loadNacosNamespaces(target.connectionId);
|
||||
queryStore.openNacosAdmin(target.connectionId);
|
||||
} else if (connectionTarget.kind === "plugin-workbench") {
|
||||
await queryStore.openPluginConnection(target.connectionId);
|
||||
} else {
|
||||
queryStore.createTab(target.connectionId, "", `${conn.name}:keys`, connectionTarget.kind);
|
||||
}
|
||||
@@ -2407,6 +2487,19 @@ async function openConnectionQuery(connectionId: string) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "plugin-workbench") {
|
||||
try {
|
||||
await queryStore.openPluginConnection(connectionId);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
t("connection.connectFailed", {
|
||||
message: translateBackendError(t, e),
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
@@ -2538,7 +2631,7 @@ function onEditTableStructure(table: SqlObjectNavigationTarget) {
|
||||
queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName, undefined, undefined, target.catalog);
|
||||
}
|
||||
|
||||
async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: boolean) {
|
||||
function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: boolean) {
|
||||
const provisionalTarget = tableTargetFromActiveTab(table);
|
||||
if (!provisionalTarget) return;
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(provisionalTarget.connectionId));
|
||||
@@ -2550,42 +2643,22 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi
|
||||
const sourceName = sqlObjectNavigationSourceName(navigation);
|
||||
const sourceSchema = sqlObjectNavigationSourceSchema(navigation, target.schema || target.database);
|
||||
try {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
connectionStore.activeConnectionId = target.connectionId;
|
||||
// Align Ctrl/Cmd+click with sidebar "view source" (dialog vs data tab).
|
||||
// Keep the editor navigation path aligned with the sidebar: create a visible
|
||||
// pending tab first, then let the store own connection/source loading.
|
||||
if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") {
|
||||
try {
|
||||
const resolvedDatabaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(target.connectionId));
|
||||
if (!resolvedDatabaseType) throw new Error("Connection type is unavailable.");
|
||||
// Wrap Oracle bare ALL_SOURCE (`procedure name is ...`) as CREATE OR REPLACE for the editor.
|
||||
const {
|
||||
raw,
|
||||
editableSource,
|
||||
objectType: resolvedType,
|
||||
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
schema: sourceSchema || target.database,
|
||||
name: sourceName,
|
||||
objectType,
|
||||
databaseType: resolvedDatabaseType,
|
||||
signature: navigation.signature,
|
||||
});
|
||||
const tabId = queryStore.createTab(target.connectionId, target.database, `Source - ${sourceName}`, "query", sourceSchema, editableSource, target.catalog, { forceNew: true, sourceView: true });
|
||||
const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType);
|
||||
if (sourceIsEditable) {
|
||||
queryStore.setObjectSource(tabId, {
|
||||
schema: sourceSchema || target.database,
|
||||
name: sourceName,
|
||||
objectType: resolvedType,
|
||||
signature: navigation.signature,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
queryStore.openObjectSourceTabPending({
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
title: `Source - ${sourceName}`,
|
||||
schema: sourceSchema || target.database,
|
||||
catalog: target.catalog,
|
||||
request: { name: sourceName, objectType, signature: navigation.signature },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The dialog owns ensureConnected so its existing loading/error/retry UI is
|
||||
// mounted before a slow connection health check or Oracle metadata query.
|
||||
queryEditorObjectSourceTarget.value = {
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
@@ -2978,6 +3051,7 @@ function activateQueryTab(tabId: string): boolean {
|
||||
dispatchBeforeTabSwitch(tabId);
|
||||
if (!queryStore.activateTab(tabId)) return false;
|
||||
activateQuerySurface();
|
||||
pluginCenterActive.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3072,6 +3146,35 @@ function handleNativeSelectAll(e: KeyboardEvent) {
|
||||
if (shouldBlockAppNativeSelectAll(e)) e.preventDefault();
|
||||
}
|
||||
|
||||
function setPluginWorkbenchTabRef(tabId: string, element: unknown) {
|
||||
if (element && typeof element === "object" && "refresh" in element && typeof element.refresh === "function") {
|
||||
pluginWorkbenchTabRefs.set(tabId, element as PluginWorkbenchTabHandle);
|
||||
} else {
|
||||
pluginWorkbenchTabRefs.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshActivePluginWorkbench(): boolean {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode !== "plugin-workbench") return false;
|
||||
const pluginWorkbench = pluginWorkbenchTabRefs.get(tab.id);
|
||||
if (!pluginWorkbench) return false;
|
||||
void pluginWorkbench.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function closeActiveSurface() {
|
||||
if (showSettingsPage.value) {
|
||||
closeSettingsPage();
|
||||
} else if (showPluginCenter.value) {
|
||||
closePluginCenterPage();
|
||||
} else if (showDriverStore.value) {
|
||||
closeDriverStorePage();
|
||||
} else if (queryStore.activeTabId) {
|
||||
if (await queryStore.clearQueryResults(queryStore.activeTabId)) return;
|
||||
queryStore.closeTab(queryStore.activeTabId);
|
||||
}
|
||||
}
|
||||
async function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
@@ -3187,14 +3290,7 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
if (isCloseTabShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
if (showSettingsPage.value) {
|
||||
closeSettingsPage();
|
||||
} else if (showDriverStore.value) {
|
||||
closeDriverStorePage();
|
||||
} else if (queryStore.activeTabId) {
|
||||
if (await queryStore.clearQueryResults(queryStore.activeTabId)) return;
|
||||
queryStore.closeTab(queryStore.activeTabId);
|
||||
}
|
||||
await closeActiveSurface();
|
||||
return;
|
||||
}
|
||||
if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) {
|
||||
@@ -3226,6 +3322,11 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
if (selectedSql.value.trim()) sendSelectionToAi(selectedSql.value);
|
||||
return;
|
||||
}
|
||||
if (isModRShortcut(e) && refreshActivePluginWorkbench()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (isModRShortcut(e) && e.target instanceof Element && contentAreaRef.value?.handleModRTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -3292,6 +3393,11 @@ async function initApp() {
|
||||
onOptionalStateError: (error) => console.error("[STARTUP] settingsStore.initAiConfigs failed", error),
|
||||
});
|
||||
}
|
||||
// Restored plugin tabs need the sidecar connection registry repopulated
|
||||
// (see reconnectRestoredPluginTabs); kick it off before the heavier
|
||||
// optional init so it races ahead of each plugin webview's first
|
||||
// session/open. Fire-and-forget: it must never block startup.
|
||||
void queryStore.reconnectRestoredPluginTabs();
|
||||
await settingsStore.initDesktopSettings().catch(() => {});
|
||||
if (isDesktop) {
|
||||
updateWindowReady = true;
|
||||
@@ -3522,6 +3628,7 @@ onUnmounted(() => {
|
||||
:sql-library-save-feedback-id="sqlLibrarySaveFeedbackId"
|
||||
:show-sql-file-panel="showSqlFilePanel"
|
||||
:show-driver-store="showDriverStore"
|
||||
:show-plugin-center="showPluginCenter"
|
||||
:show-settings-page="showSettingsPage"
|
||||
:checking-updates="checkingUpdates"
|
||||
:has-update-available="toolbarHasUpdateAvailable"
|
||||
@@ -3545,6 +3652,7 @@ onUnmounted(() => {
|
||||
@open-github="openGitHub"
|
||||
@open-settings="openSettings(toolbarMcpUpdateAvailable ? 'mcp' : 'appearance')"
|
||||
@open-driver-store="openDriverStorePage"
|
||||
@open-plugin-center="openPluginCenterPage()"
|
||||
@check-updates="checkUpdates()"
|
||||
@open-transfer="dialogs.showTransferDialog.value = true"
|
||||
@open-sql-file="dialogs.showSqlFileDialog.value = true"
|
||||
@@ -3574,6 +3682,8 @@ onUnmounted(() => {
|
||||
ref="appTabBarRef"
|
||||
:driver-store-open="driverStoreTabOpen"
|
||||
:driver-store-active="driverStoreActive"
|
||||
:plugin-center-open="pluginCenterTabOpen"
|
||||
:plugin-center-active="pluginCenterActive"
|
||||
:settings-page-open="settingsPageTabOpen"
|
||||
:settings-page-active="settingsStore.settingsPageActive"
|
||||
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
|
||||
@@ -3582,9 +3692,11 @@ onUnmounted(() => {
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
@activate-driver-store="openDriverStorePage"
|
||||
@activate-plugin-center="openPluginCenterPage(pluginCenterFocus)"
|
||||
@activate-settings-page="activateSettingsPage"
|
||||
@activate-tab="activateQueryTab"
|
||||
@close-driver-store="closeDriverStorePage"
|
||||
@close-plugin-center="closePluginCenterPage"
|
||||
@close-settings-page="closeSettingsPage"
|
||||
@save-tab="handleSaveTab"
|
||||
@discard-tab-close="handleDiscardPendingTabClose"
|
||||
@@ -3594,6 +3706,7 @@ onUnmounted(() => {
|
||||
@detach-tab="detachTab"
|
||||
>
|
||||
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
|
||||
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" @new-connection="openPluginConnectionDialog" />
|
||||
<EditorSettingsPage
|
||||
v-if="settingsPageTabOpen"
|
||||
v-show="settingsStore.settingsPageActive"
|
||||
@@ -3635,16 +3748,22 @@ onUnmounted(() => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<div v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="flex flex-col min-h-0" :class="activeTab?.mode === 'plugin-workbench' ? 'flex-none' : 'flex-1'">
|
||||
<SqlEditorWorkspace
|
||||
ref="contentAreaRef"
|
||||
:content-suppressed="activeTab?.mode === 'plugin-workbench'"
|
||||
@locate-tab="locateTabInSidebar"
|
||||
@close-tab="
|
||||
(tabId: string) => {
|
||||
if (tabId === queryStore.activeTabId) void closeActiveSurface();
|
||||
}
|
||||
"
|
||||
@toggle-zen-mode="toggleZenMode"
|
||||
@start-resize="startTabBarResize"
|
||||
@toggle-collapse="toggleTabBarCollapsed"
|
||||
:active-tab="activeTab ?? undefined"
|
||||
:show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"
|
||||
:show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen || pluginCenterTabOpen"
|
||||
:active-connection="activeConnection"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
@@ -3774,6 +3893,21 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</SqlEditorWorkspace>
|
||||
</div>
|
||||
<!-- Always-mounted plugin workbench layer: switching tabs only
|
||||
toggles visibility, so plugin webviews (SSH terminals) are
|
||||
never destroyed and reloaded. SqlEditorWorkspace no longer
|
||||
renders plugin tabs — this layer owns them. It in turn
|
||||
yields the layout (display:none) while a plugin tab is
|
||||
active, or both flex-1 siblings would split the column. -->
|
||||
<div v-for="workbenchTab in mountedPluginWorkbenchTabs" :key="workbenchTab.id" v-show="activeTab && workbenchTab.id === activeTab.id" class="flex min-h-0 flex-1 flex-col">
|
||||
<PluginWorkbenchTab
|
||||
:ref="(element) => setPluginWorkbenchTabRef(workbenchTab.id, element)"
|
||||
:plugin-id="workbenchTab.pluginWorkbench!.pluginId"
|
||||
:contribution-id="workbenchTab.pluginWorkbench!.contributionId"
|
||||
:context="workbenchTab.pluginWorkbench!.context"
|
||||
@close-tab="queryStore.closeTab(workbenchTab.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3781,7 +3915,7 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showAiPanel"
|
||||
v-show="!isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full']"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
|
||||
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
<div v-if="!isAiPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @mousedown="startAiPanelResize" />
|
||||
@@ -3845,6 +3979,7 @@ onUnmounted(() => {
|
||||
<AppDialogs
|
||||
:show-connection-dialog="showConnectionDialog"
|
||||
:connection-prefill="connectionDialogPrefill"
|
||||
:connection-plugin-provider="connectionPluginProvider"
|
||||
:connection-initial-tab="connectionDialogInitialTab"
|
||||
:show-danger-dialog="showDangerDialog"
|
||||
:danger-sql="dangerSql"
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
type DriverInstallProgress,
|
||||
} from "@/lib/connection/driverInstallProgressUi";
|
||||
import { installRegisteredManagedJdbcDriver, isManagedJdbcDriver, managedJdbcDriverRows, uninstallRegisteredManagedJdbcDriver } from "@/lib/database/managedJdbcDrivers";
|
||||
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { runAgentOfflineExportAction } from "@/lib/driverStore/agentOfflineExportFlow";
|
||||
@@ -55,7 +55,7 @@ function backendError(e: unknown): string {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
updateNotificationsEnabled?: boolean;
|
||||
activeTab?: "agent" | "jdbc" | "storage" | "runtime";
|
||||
activeTab?: DriverStoreTab;
|
||||
focusTarget?: DriverStoreFocus | null;
|
||||
}>(),
|
||||
{
|
||||
@@ -67,12 +67,12 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update-count-change": [count: number];
|
||||
"update:activeTab": [tab: "agent" | "jdbc" | "storage" | "runtime"];
|
||||
"update:activeTab": [tab: DriverStoreTab];
|
||||
}>();
|
||||
|
||||
const driverStoreTab = computed({
|
||||
get: () => props.activeTab,
|
||||
set: (tab: "agent" | "jdbc" | "storage" | "runtime") => emit("update:activeTab", tab),
|
||||
set: (tab: DriverStoreTab) => emit("update:activeTab", tab),
|
||||
});
|
||||
|
||||
// ──────────── Driver store path ────────────
|
||||
@@ -1443,8 +1443,8 @@ watch(driverStoreTab, (tab) => {
|
||||
<div class="driver-store-scroll flex-1 min-h-0 overflow-y-auto">
|
||||
<div class="driver-store-container max-w-4xl mx-auto px-6 py-6">
|
||||
<Tabs v-model="driverStoreTab" default-value="agent" class="driver-store-tabs-root">
|
||||
<div class="driver-store-header flex items-center justify-between">
|
||||
<TabsList class="driver-store-tabs grid w-[360px] grid-cols-3">
|
||||
<div class="driver-store-header flex flex-wrap items-center justify-between gap-2">
|
||||
<TabsList class="driver-store-tabs grid w-full max-w-[380px] grid-cols-3">
|
||||
<TabsTrigger value="agent" class="gap-1.5 relative">
|
||||
{{ t("driverStore.agentDrivers") }}
|
||||
<span v-if="agentTabUpdateCount > 0" class="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
@@ -2263,7 +2263,7 @@ watch(driverStoreTab, (tab) => {
|
||||
|
||||
.driver-store-tabs {
|
||||
display: grid !important;
|
||||
width: 360px !important;
|
||||
width: 480px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles";
|
||||
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
|
||||
import { applySshAuthMethod } from "@/lib/connection/sshAuthMethod";
|
||||
import { stripInvisibleCharactersFromLayer } from "@/lib/connection/credentialSanitizer";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SshConfigHostEntry, TunnelProfile } from "@/types/database";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -151,7 +152,10 @@ async function save() {
|
||||
invalidateProfileTest();
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await store.saveProfiles(cloneProfiles(draft.value));
|
||||
// Pasted credentials may carry invisible characters that trim() keeps (#9043).
|
||||
const sanitized = cloneProfiles(draft.value);
|
||||
sanitized.forEach(stripInvisibleCharactersFromLayer);
|
||||
await store.saveProfiles(sanitized);
|
||||
toast(t("settings.tunnelsSaved"));
|
||||
} catch (error) {
|
||||
toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
|
||||
@@ -28,7 +28,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue";
|
||||
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, Trash2, X } from "@lucide/vue";
|
||||
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
@@ -166,6 +166,16 @@ function handleUpdateSelectedTables(value: string[]) {
|
||||
reconcileTableMappings(value);
|
||||
}
|
||||
|
||||
function handleRemoveSelectedTable(sourceTable: string) {
|
||||
handleUpdateSelectedTables(localSelectedTables.value.filter((table) => table !== sourceTable));
|
||||
}
|
||||
|
||||
function matchStatusClass(kind: SchemaDiffTableMatch["kind"]): string {
|
||||
if (kind === "unmatched") return "text-destructive";
|
||||
if (kind === "manual") return "text-amber-600 dark:text-amber-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function clearUnavailableTableSelection() {
|
||||
sourceTableList.value = [];
|
||||
targetTableList.value = [];
|
||||
@@ -307,6 +317,10 @@ function handleUpdateSelectedRoutines(value: string[]) {
|
||||
reconcileRoutineMappings(value);
|
||||
}
|
||||
|
||||
function handleRemoveSelectedRoutine(sourceRoutine: string) {
|
||||
handleUpdateSelectedRoutines(localSelectedRoutines.value.filter((routine) => routine !== sourceRoutine));
|
||||
}
|
||||
|
||||
function clearUnavailableRoutineSelection() {
|
||||
sourceRoutineList.value = [];
|
||||
targetRoutineList.value = [];
|
||||
@@ -458,14 +472,7 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const config = store.getConfig(connectionId);
|
||||
let dbNames: string[];
|
||||
if (config?.db_type === "dameng") {
|
||||
// 达梦的"数据库"概念对应 schema,使用 fetchNamespaceOptionsForConnection
|
||||
dbNames = await fetchNamespaceOptionsForConnection(connectionId, config);
|
||||
} else {
|
||||
const dbs = await api.listDatabases(connectionId);
|
||||
dbNames = Array.isArray(dbs) ? dbs.map((db: any) => (typeof db === "string" ? db : db.name || db.database)) : [];
|
||||
}
|
||||
const dbNames = config ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((db) => db.name);
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = dbNames;
|
||||
if (props.sourceDatabase) {
|
||||
@@ -861,6 +868,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceTables") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetTable") }}</th>
|
||||
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
|
||||
<th class="w-16 px-2 py-1.5 text-right font-medium">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -881,9 +889,14 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : match.kind === 'manual' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
|
||||
<td class="px-2 py-1.5" :class="matchStatusClass(match.kind)">
|
||||
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" :aria-label="t('common.delete')" @click="handleRemoveSelectedTable(match.sourceTable)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -926,6 +939,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
@update:model-value="handleUpdateSelectedRoutines"
|
||||
:tables="sourceRoutineList"
|
||||
:title="t('diff.sourceRoutines')"
|
||||
:search-placeholder="t('diff.searchSourceRoutines')"
|
||||
:empty-text="t('diff.noRoutines')"
|
||||
/>
|
||||
</template>
|
||||
@@ -946,6 +960,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceRoutines") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetRoutine") }}</th>
|
||||
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
|
||||
<th class="w-16 px-2 py-1.5 text-right font-medium">{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -955,9 +970,14 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
|
||||
<span v-if="match.targetRoutine">{{ match.targetRoutine }}</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</td>
|
||||
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : 'text-muted-foreground'">
|
||||
<td class="px-2 py-1.5" :class="matchStatusClass(match.kind)">
|
||||
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 p-0" :aria-label="t('common.delete')" @click="handleRemoveSelectedRoutine(match.sourceRoutine)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutio
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
import SchemaDiffConfigSelector from "@/components/diff/SchemaDiffConfigSelector.vue";
|
||||
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
|
||||
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
|
||||
import SchemaDiffRoutineList from "@/components/diff/SchemaDiffRoutineList.vue";
|
||||
@@ -103,6 +104,7 @@ const ignoreComments = ref(false);
|
||||
// Options panel
|
||||
const showOptionsPanel = ref(false);
|
||||
const showFieldMappingDialog = ref(false);
|
||||
const showConfigSelector = ref(false);
|
||||
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
|
||||
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
|
||||
|
||||
@@ -254,6 +256,11 @@ function toggleMaximize() {
|
||||
}
|
||||
|
||||
function handleDialogEscape(event: KeyboardEvent) {
|
||||
if (showConfigSelector.value) {
|
||||
event.preventDefault();
|
||||
showConfigSelector.value = false;
|
||||
return;
|
||||
}
|
||||
if (!showOptionsPanel.value) return;
|
||||
|
||||
event.preventDefault();
|
||||
@@ -313,7 +320,7 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
// Config management
|
||||
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
|
||||
const { configs, activeConfigId, activeConfig, recentConfigs, ensureDefaultConfig, createConfig, updateConfig, renameConfig, deleteConfig, duplicateConfig, importConfigs, updateActiveConfigConnection, updateActiveConfigOptions, saveToHistory, deleteFromHistory } = useSchemaDiffConfig();
|
||||
const schemaDiffPanelOptions = computed(() => normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()));
|
||||
|
||||
const selectedObject = computed(() => {
|
||||
@@ -1075,6 +1082,13 @@ async function copyRoutineDiffSide(side: TextDiffSide) {
|
||||
}
|
||||
}
|
||||
function handleLoadHistoryConfig(config: SchemaDiffConfig) {
|
||||
applyConfigToForm(config);
|
||||
if (config.options) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfigToForm(config: SchemaDiffConfig) {
|
||||
runWithoutTargetIdentityReset(() => {
|
||||
sourceConnectionId.value = config.sourceConnectionId;
|
||||
sourceDatabase.value = config.sourceDatabase;
|
||||
@@ -1083,18 +1097,85 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) {
|
||||
targetDatabase.value = config.targetDatabase;
|
||||
targetSchema.value = config.targetSchema;
|
||||
});
|
||||
if (config.options) {
|
||||
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType()));
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotCurrentConfig(name: string): SchemaDiffConfig {
|
||||
ensureDefaultConfig(getDbType());
|
||||
const base = activeConfig.value;
|
||||
return {
|
||||
id: base?.id ?? "",
|
||||
name,
|
||||
createdAt: base?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sourceConnectionId: sourceConnectionId.value,
|
||||
sourceDatabase: sourceDatabase.value,
|
||||
sourceSchema: sourceSchema.value,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
targetDatabase: targetDatabase.value,
|
||||
targetSchema: targetSchema.value,
|
||||
options: { ...schemaDiffPanelOptions.value },
|
||||
};
|
||||
}
|
||||
|
||||
function handleLoadConfig() {
|
||||
ensureDefaultConfig(getDbType());
|
||||
showConfigSelector.value = true;
|
||||
}
|
||||
|
||||
function handleSelectActiveConfig(id: string) {
|
||||
if (!id || id === activeConfigId.value) return;
|
||||
activeConfigId.value = id;
|
||||
const config = configs.value.find((entry) => entry.id === id);
|
||||
if (config) applyConfigToForm(config);
|
||||
}
|
||||
|
||||
function handleSaveConfig() {
|
||||
if (activeConfig.value) {
|
||||
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
|
||||
if (name === null) return; // User cancelled
|
||||
const configToSave = { ...activeConfig.value, name: name.trim() || t("diff.defaultConfigName") };
|
||||
saveToHistory(configToSave);
|
||||
ensureDefaultConfig(getDbType());
|
||||
if (!activeConfig.value) return;
|
||||
const name = window.prompt(t("diff.saveConfigPrompt"), activeConfig.value.name || t("diff.defaultConfigName"));
|
||||
if (name === null) return;
|
||||
const trimmed = name.trim() || t("diff.defaultConfigName");
|
||||
const snapshot = snapshotCurrentConfig(trimmed);
|
||||
updateConfig(activeConfig.value.id, {
|
||||
name: trimmed,
|
||||
sourceConnectionId: snapshot.sourceConnectionId,
|
||||
sourceDatabase: snapshot.sourceDatabase,
|
||||
sourceSchema: snapshot.sourceSchema,
|
||||
targetConnectionId: snapshot.targetConnectionId,
|
||||
targetDatabase: snapshot.targetDatabase,
|
||||
targetSchema: snapshot.targetSchema,
|
||||
options: snapshot.options,
|
||||
});
|
||||
saveToHistory({ ...snapshot, id: activeConfig.value.id });
|
||||
toast(t("diff.configSaved"), 2000);
|
||||
}
|
||||
|
||||
function handleCreateNamedConfig(name: string) {
|
||||
const snapshot = snapshotCurrentConfig(name);
|
||||
createConfig(name, snapshot, getDbType());
|
||||
}
|
||||
|
||||
function handleRenameNamedConfig(id: string, name: string) {
|
||||
renameConfig(id, name);
|
||||
}
|
||||
|
||||
function handleDeleteNamedConfig(id: string) {
|
||||
deleteConfig(id);
|
||||
if (activeConfig.value) applyConfigToForm(activeConfig.value);
|
||||
}
|
||||
|
||||
function handleDuplicateNamedConfig(id: string) {
|
||||
duplicateConfig(id);
|
||||
if (activeConfig.value) applyConfigToForm(activeConfig.value);
|
||||
}
|
||||
|
||||
function handleImportNamedConfigs(jsonText: string) {
|
||||
try {
|
||||
importConfigs(jsonText, "merge");
|
||||
if (activeConfig.value) applyConfigToForm(activeConfig.value);
|
||||
toast(t("diff.configSaved"), 2000);
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,6 +1318,7 @@ const targetConnectionInfo = computed(() => {
|
||||
@swap="handleSwap"
|
||||
@show-options="showOptionsPanel = true"
|
||||
@save-config="handleSaveConfig"
|
||||
@load-config="handleLoadConfig"
|
||||
@load-history-config="handleLoadHistoryConfig"
|
||||
@delete-history-config="handleDeleteHistoryConfig"
|
||||
@update:field-mappings="handleFieldMappingsUpdate"
|
||||
@@ -1566,6 +1648,26 @@ const targetConnectionInfo = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config selector overlay (save/load named configs) -->
|
||||
<div v-if="showConfigSelector" class="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" @click.self="showConfigSelector = false">
|
||||
<div class="max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] overflow-auto rounded-lg border bg-card p-4 shadow-lg">
|
||||
<div class="mb-4 flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-medium">{{ t("diff.loadConfig") }}</h3>
|
||||
<Button variant="ghost" size="sm" :aria-label="t('common.close')" @click="showConfigSelector = false">✕</Button>
|
||||
</div>
|
||||
<SchemaDiffConfigSelector
|
||||
:configs="configs"
|
||||
:active-config-id="activeConfigId"
|
||||
@update:active-config-id="handleSelectActiveConfig"
|
||||
@create="handleCreateNamedConfig"
|
||||
@rename="handleRenameNamedConfig"
|
||||
@delete="handleDeleteNamedConfig"
|
||||
@duplicate="handleDuplicateNamedConfig"
|
||||
@import="handleImportNamedConfigs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Mapping Dialog Overlay -->
|
||||
<FieldMappingDialog
|
||||
:open="showFieldMappingDialog"
|
||||
|
||||
@@ -20,6 +20,8 @@ const props = withDefaults(
|
||||
modelValue: string[];
|
||||
/** Optional header label; falls back to `tableMultiSelect.tables`. */
|
||||
title?: string;
|
||||
/** Optional search input placeholder; falls back to `tableMultiSelect.search`. */
|
||||
searchPlaceholder?: string;
|
||||
/** Show the search box (only rendered when there are more than 5 tables). */
|
||||
searchable?: boolean;
|
||||
/** Text shown when `tables` is empty. */
|
||||
@@ -28,6 +30,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
searchPlaceholder: "",
|
||||
searchable: true,
|
||||
emptyText: "",
|
||||
disabled: false,
|
||||
@@ -64,7 +67,7 @@ function toggleSelectAll() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="t('tableMultiSelect.search')" :disabled="disabled" />
|
||||
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="searchPlaceholder || t('tableMultiSelect.search')" :disabled="disabled" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button v-if="tables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="disabled" @click="toggleSelectAll">
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import { getDefaultOptionsForDbType } from "@/types/schemaDiff";
|
||||
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
listDatabases: vi.fn().mockResolvedValue([]),
|
||||
listSchemas: vi.fn().mockResolvedValue(["APP", "REPORTING"]),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => {
|
||||
const connection = { id: "oracle-11g", name: "Oracle 11g", db_type: "oracle", driver_profile: "oracle", database: "XE" };
|
||||
return {
|
||||
useConnectionStore: () => ({
|
||||
connections: [connection],
|
||||
sidebarLayout: { groups: [], order: [{ type: "connection", id: connection.id }] },
|
||||
getConfig: (id: string) => (id === connection.id ? connection : undefined),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDatabases: mocks.listDatabases,
|
||||
listSchemas: mocks.listSchemas,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function flushAsyncSetup() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.textContent = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("offers Oracle schemas as schema-diff database choices", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(SchemaDiffConfigStep, {
|
||||
configs: [],
|
||||
activeConfigId: "",
|
||||
sourceConnectionId: "oracle-11g",
|
||||
sourceDatabase: "",
|
||||
sourceSchema: "",
|
||||
targetConnectionId: "",
|
||||
targetDatabase: "",
|
||||
targetSchema: "",
|
||||
ignoreComments: false,
|
||||
options: getDefaultOptionsForDbType("oracle"),
|
||||
tableListLoader: { load: vi.fn().mockResolvedValue([]) },
|
||||
loading: false,
|
||||
recentConfigs: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await flushAsyncSetup();
|
||||
|
||||
const sourceDatabase = document.querySelector<HTMLButtonElement>("button.dbx-searchable-select-trigger");
|
||||
expect(sourceDatabase?.disabled).toBe(false);
|
||||
sourceDatabase?.click();
|
||||
await flushAsyncSetup();
|
||||
|
||||
const options = [...document.querySelectorAll<HTMLButtonElement>(".dbx-searchable-select-list button")].map((button) => button.textContent?.trim());
|
||||
expect(options).toEqual(expect.arrayContaining(["APP", "REPORTING"]));
|
||||
expect(mocks.listDatabases).not.toHaveBeenCalled();
|
||||
expect(mocks.listSchemas).toHaveBeenCalledWith("oracle-11g", "XE", true);
|
||||
});
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
} from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -156,7 +155,7 @@ import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelectio
|
||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||
import { fetchNamespaceOptionsForConnection, useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import { useSchemaOptions } from "@/composables/useSchemaOptions";
|
||||
import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseCapabilities";
|
||||
@@ -1405,19 +1404,43 @@ const dbSelectOptions = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const selectedNamespace = computed(() => (props.connection && props.tab ? resolveAiNamespaceSelection(props.tab, props.connection).value : ""));
|
||||
|
||||
const selectedDatabaseSelectValue = computed(() => (props.connection ? encodeSelectableDatabaseValue(props.connection.db_type, selectedNamespace.value) : ""));
|
||||
// AI can inspect more than the tab's active database. Keep this selection local
|
||||
// to the composer so changing the visible query tab does not rewrite SQL state.
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
|
||||
const selectedDatabaseValues = computed(() => new Set(selectedDatabases.value));
|
||||
const selectedDatabaseLabel = computed(() => {
|
||||
if (!props.connection) return t("editor.selectDatabase");
|
||||
if (!props.tab) return t("editor.selectDatabase");
|
||||
return formatDatabaseLabel(props.connection, selectedNamespace.value, {
|
||||
defaultDatabase: t("editor.defaultDatabase"),
|
||||
noDatabase: t("editor.noDatabase"),
|
||||
});
|
||||
const labels = dbSelectOptions.value.filter((option) => selectedDatabaseValues.value.has(option.database)).map((option) => option.label);
|
||||
return labels.length ? labels.join(", ") : t("editor.selectDatabase");
|
||||
});
|
||||
|
||||
function syncSelectedDatabases() {
|
||||
const active = selectedNamespace.value;
|
||||
const available = dbSelectOptions.value.map((option) => option.database);
|
||||
const retained = selectedDatabases.value.filter((database) => available.includes(database));
|
||||
selectedDatabases.value = retained.length ? retained : active ? [active] : [];
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string) {
|
||||
if (selectedDatabaseValues.value.has(database)) {
|
||||
if (selectedDatabases.value.length === 1) return;
|
||||
selectedDatabases.value = selectedDatabases.value.filter((item) => item !== database);
|
||||
} else {
|
||||
selectedDatabases.value = [...selectedDatabases.value, database];
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNamespace = computed(() => (props.connection && props.tab ? resolveAiNamespaceSelection(props.tab, props.connection).value : ""));
|
||||
|
||||
watch(
|
||||
() => `${props.connection?.id ?? ""}:${props.tab?.id ?? ""}`,
|
||||
() => {
|
||||
selectedDatabases.value = selectedNamespace.value ? [selectedNamespace.value] : [];
|
||||
},
|
||||
);
|
||||
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
|
||||
|
||||
const showAiSchemaSelector = computed(() => {
|
||||
const connection = props.connection;
|
||||
return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
|
||||
@@ -1478,20 +1501,6 @@ async function changeConnection(connectionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeNamespace(value: string) {
|
||||
const tab = props.tab;
|
||||
const connection = props.connection;
|
||||
if (!tab || !connection) return;
|
||||
const namespace = decodeSelectableDatabaseValue(connection.db_type, value);
|
||||
if (resolveAiNamespaceSelection(tab, connection).value === namespace) return;
|
||||
clearContextReferences();
|
||||
if (resolveAiNamespaceSelection(tab, connection).kind === "schema") {
|
||||
queryStore.updateSchema(tab.id, namespace || undefined);
|
||||
} else {
|
||||
queryStore.updateDatabase(tab.id, namespace);
|
||||
}
|
||||
}
|
||||
|
||||
function changeSchema(schema: string) {
|
||||
const tab = props.tab;
|
||||
if (!tab || tab.schema === schema) return;
|
||||
@@ -2793,6 +2802,9 @@ async function send() {
|
||||
clearPendingWriteGrant();
|
||||
return;
|
||||
}
|
||||
// Capture the selection before context loading or queued run scheduling can
|
||||
// yield to another conversation. Dameng's top-level selector is a schema.
|
||||
const runDatabases = resolveAiNamespaceSelection(tab, connection).kind === "database" ? [...selectedDatabases.value] : [];
|
||||
const activeConfig = activeFullConfig.value;
|
||||
if (!activeConfig) {
|
||||
clearPendingWriteGrant();
|
||||
@@ -3086,11 +3098,21 @@ async function send() {
|
||||
// paying for buildAiContext() too; it can do real backend/schema work that
|
||||
// would be entirely wasted on an already-abandoned request.
|
||||
if (!generationCanContinue()) return;
|
||||
const context = await buildAiContext(tab, connection, {
|
||||
mentionedTables,
|
||||
sqlFiles,
|
||||
csvFiles: csvAttachments,
|
||||
});
|
||||
const requestDatabase = runDatabases[0] ?? tab.database;
|
||||
const context = await buildAiContext(
|
||||
{
|
||||
...tab,
|
||||
database: requestDatabase,
|
||||
schema: runDatabases.length > 1 || requestDatabase !== tab.database ? undefined : tab.schema,
|
||||
},
|
||||
connection,
|
||||
{
|
||||
mentionedTables,
|
||||
sqlFiles,
|
||||
csvFiles: csvAttachments,
|
||||
},
|
||||
);
|
||||
context.selectedDatabases = runDatabases;
|
||||
// Superseded while awaiting buildAiContext() above — must bail before ever
|
||||
// calling runAgentStream(), not just before writing its results. Without
|
||||
// this recheck, a clear/switch/unmount that fires during context
|
||||
@@ -4970,27 +4992,26 @@ async function openExternalUrl(url: string) {
|
||||
/>
|
||||
<template v-if="connection">
|
||||
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||
<Select
|
||||
:model-value="selectedDatabaseSelectValue"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (typeof v === 'string') changeNamespace(v);
|
||||
}
|
||||
"
|
||||
<Popover
|
||||
@update:open="
|
||||
(open: boolean) => {
|
||||
if (open) loadDatabases();
|
||||
}
|
||||
"
|
||||
>
|
||||
<SelectTrigger :class="['h-5 w-auto border-0 rounded-md bg-transparent dark:bg-transparent p-0 px-1 text-xs text-foreground/80 shadow-none focus:ring-0 focus-visible:ring-0 [&_svg]:size-3', showAiSchemaSelector && 'min-w-0 max-w-56 flex-1']">
|
||||
<SelectValue :placeholder="t('editor.selectDatabase')">{{ selectedDatabaseLabel }}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in dbSelectOptions" :key="option.value" :value="option.value">{{ option.label }}</SelectItem>
|
||||
<SelectItem v-if="!dbSelectOptions.length && connection && tab" :value="selectedDatabaseSelectValue">{{ selectedDatabaseLabel }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" :class="['h-5 max-w-64 justify-start border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'min-w-0 flex-1']">
|
||||
<span class="truncate">{{ selectedDatabaseLabel }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-64 p-1">
|
||||
<button v-for="option in dbSelectOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
|
||||
<Check :class="['h-4 w-4', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
<div v-if="!dbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("editor.selectDatabase") }}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<template v-if="showAiSchemaSelector">
|
||||
<Layers class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||
<SearchableSelect
|
||||
|
||||
@@ -5187,14 +5187,14 @@ onUnmounted(() => {
|
||||
</component>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="settings-layout flex min-h-0 flex-1 flex-col gap-3 overflow-hidden lg:flex-row">
|
||||
<div class="settings-layout flex min-h-0 flex-1 flex-col gap-3 overflow-visible lg:flex-row">
|
||||
<nav class="settingsCategoryNav settings-category-nav flex min-h-0 shrink-0 gap-1 overflow-x-auto border-b pb-3 lg:w-52 lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3">
|
||||
<button v-for="category in settingsCategoryNav" :key="category.value" type="button" :class="settingsCategoryButton(category.value)" @click="onSettingsCategoryClick(category.value)">
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-hidden px-1 flex flex-col">
|
||||
<div class="min-w-0 flex-1 overflow-visible pl-1 pr-0 flex flex-col">
|
||||
<div class="shrink-0 px-2 pt-1 pb-3">
|
||||
<div ref="settingsSearchInputContainerRef" class="relative">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-4 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -5263,7 +5263,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="settingsContentScrollRef" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pr-2">
|
||||
<div v-else ref="settingsContentScrollRef" class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 pr-6 -mr-4">
|
||||
<section v-if="activeSettingsTab === 'editor'" data-settings-search-id="editor" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('editor')]">
|
||||
<div class="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<!-- Font Family -->
|
||||
|
||||
@@ -15,7 +15,7 @@ let lastHandledCompressRequestId = 0;
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import { Transaction, StateEffect } from "@codemirror/state";
|
||||
@@ -132,6 +132,7 @@ import {
|
||||
} from "@/lib/editor/queryEditorTableDrop";
|
||||
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
|
||||
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||
import { copySqlAsRichText } from "@/lib/sql/sqlRichText";
|
||||
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
|
||||
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
|
||||
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
|
||||
@@ -177,6 +178,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { isMacOS } from "@/lib/backend/platform";
|
||||
import {
|
||||
@@ -282,6 +284,7 @@ const viewportEmitTask = createDeferredEditorTask(() => {
|
||||
let viewportRestoreFrame: number | null = null;
|
||||
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
let tabSwitchStateCaptured = false;
|
||||
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
|
||||
let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection;
|
||||
let contextMenuDoc: Text | null = null;
|
||||
@@ -291,7 +294,11 @@ const settingsStore = useSettingsStore();
|
||||
|
||||
function sqlStatementParameterOptions() {
|
||||
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, props.databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
|
||||
return { databaseType: props.databaseType, enabledSyntaxes: enabledSqlParameterSyntaxes(toggles) };
|
||||
return {
|
||||
databaseType: props.databaseType,
|
||||
compatibilityMode: props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : undefined,
|
||||
enabledSyntaxes: enabledSqlParameterSyntaxes(toggles),
|
||||
};
|
||||
}
|
||||
const { isDark, themePalette, activeCustomUiColors } = useTheme();
|
||||
const { t } = useI18n();
|
||||
@@ -680,6 +687,7 @@ const EDITOR_SCROLLBAR_POINTER_GUTTER_PX = 18;
|
||||
const EDITOR_SELECTION_DRAG_THRESHOLD_PX = 6;
|
||||
const tableNavigationHoverClass = "query-editor--table-navigation-hover";
|
||||
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
|
||||
const BEFORE_TAB_SWITCH_EVENT = "dbx:before-tab-switch";
|
||||
|
||||
function editorThemeAppearance() {
|
||||
return editorThemeAppearanceFor(isDark.value ? "dark" : "light", themePalette.value, themePalette.value === "custom" ? activeCustomUiColors.value : undefined);
|
||||
@@ -1611,6 +1619,18 @@ async function copySelectedSqlFromContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
// 富文本复制:写入 text/html + text/plain,粘贴到邮件/Word/IM 时保留语法高亮。
|
||||
async function copySelectedSqlAsRichTextFromContextMenu() {
|
||||
if (!canCopySelectedSql.value) return;
|
||||
try {
|
||||
await copySqlAsRichText(selectedSql.value);
|
||||
toast(t("grid.copied"));
|
||||
focusEditor();
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function cutSelectedSqlFromContextMenu() {
|
||||
if (!canCopySelectedSql.value) return;
|
||||
const currentView = view.value;
|
||||
@@ -2041,6 +2061,12 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
icon: Copy,
|
||||
shortcut: "Mod+C",
|
||||
},
|
||||
{
|
||||
label: t("editor.contextMenu.copySelectionAsRichText"),
|
||||
action: copySelectedSqlAsRichTextFromContextMenu,
|
||||
disabled: !canCopySelectedSql.value,
|
||||
icon: Highlighter,
|
||||
},
|
||||
{
|
||||
label: t("editor.contextMenu.screenshotSelection"),
|
||||
action: () => {
|
||||
@@ -3095,6 +3121,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
objectType: sqlObjectNavigationSourceKind(table),
|
||||
};
|
||||
let sqlContent: string | undefined;
|
||||
const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType);
|
||||
let metadataLoadFailed = false;
|
||||
|
||||
// The persisted display DDL is canonical across the full-page and hover
|
||||
@@ -3109,7 +3136,6 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
// (the same one the sidebar/object-source viewers use). Tables keep
|
||||
// the aligned column layout from reformatHoverDdl.
|
||||
const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW";
|
||||
const formatDialect = props.formatDialect ?? sqlFormatDialectForDbType(props.databaseType);
|
||||
const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
|
||||
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect);
|
||||
}
|
||||
@@ -3144,6 +3170,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
||||
}
|
||||
if (fullColumns.length > 0) {
|
||||
sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment);
|
||||
if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect);
|
||||
metadataLoadFailed = false;
|
||||
}
|
||||
}
|
||||
@@ -3451,7 +3478,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
if (props.databaseType !== "sqlserver") {
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
}
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges);
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
|
||||
if (diagnosticRanges.length === 0) {
|
||||
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
|
||||
return;
|
||||
@@ -4617,6 +4644,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
if (props.databaseType === "victoriametrics") return null;
|
||||
const hasDatabase = props.database != null;
|
||||
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
|
||||
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
|
||||
|
||||
const epoch = ++completionEpoch;
|
||||
|
||||
@@ -4645,12 +4673,12 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
|
||||
// require-prefix: only compute local facts (no positionalEligible).
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin,
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
};
|
||||
if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null;
|
||||
@@ -4658,13 +4686,13 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
|
||||
// positional: compute positionalEligible (lazy).
|
||||
if (mode === "positional") {
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin,
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
positionalEligible,
|
||||
};
|
||||
@@ -4700,6 +4728,27 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
return buildCompletionResult(items, useDatabaseCompletion.from, undefined, useDatabaseCompletion.prefix);
|
||||
}
|
||||
|
||||
if (databaseLinkContext) {
|
||||
if (!hasDatabase) return null;
|
||||
const links = await connectionStore.listOracleDatabaseLinks(props.connectionId, props.database!);
|
||||
if (epoch !== completionEpoch) return null;
|
||||
return {
|
||||
from: databaseLinkContext.from,
|
||||
to: databaseLinkContext.to,
|
||||
options: oracleDatabaseLinkCompletionItems(links, databaseLinkContext.prefix).map((item) => ({
|
||||
...item,
|
||||
apply(editor: EditorViewType, _completion: unknown, from: number, to: number) {
|
||||
markCompletionAccepted({ label: item.label, type: "text", boost: 0 });
|
||||
// Keep the entire link suffix intact and use the normal completion
|
||||
// transaction so accepting a link does not immediately reopen the menu.
|
||||
if (codeMirrorInsertCompletionText) editor.dispatch(codeMirrorInsertCompletionText(editor.state, item.apply, from, to));
|
||||
else editor.dispatch({ changes: { from, to, insert: item.apply }, selection: { anchor: from + item.apply.length } });
|
||||
},
|
||||
})),
|
||||
validFor: /^[A-Za-z0-9_$#.]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
if (sequenceLiteralContext) {
|
||||
if (!hasDatabase) return null;
|
||||
const sequences = await connectionStore.listCompletionObjects(props.connectionId, props.database!, sequenceLiteralContext.prefix, MAX_COMPLETION_TABLES, sequenceLiteralContext.schema, undefined, false, undefined, ["sequence"], sequenceLiteralContext.nameQuoted);
|
||||
@@ -4910,6 +4959,7 @@ function flushImeComposition() {
|
||||
*/
|
||||
function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean {
|
||||
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
|
||||
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
|
||||
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false;
|
||||
const mode = settingsStore.editorSettings.completionTriggerMode;
|
||||
if (mode === "manual") return false;
|
||||
@@ -4922,25 +4972,25 @@ function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number
|
||||
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
|
||||
|
||||
if (mode === "require-prefix") {
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin: "typing",
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
};
|
||||
return shouldAllowSqlCompletionTrigger(mode, facts);
|
||||
}
|
||||
|
||||
// positional
|
||||
const ctx = sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
|
||||
const prevChar = fullDoc[position - 1] ?? "";
|
||||
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
|
||||
const facts: SqlCompletionTriggerFacts = {
|
||||
origin: "typing",
|
||||
hasIdentifierPrefix: ctx.prefix.length > 0,
|
||||
qualifierTriggered: prevChar === "." && ("from" in ctx ? ctx.schema != null : ctx.qualifier != null),
|
||||
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
|
||||
useDatabasePrefix,
|
||||
positionalEligible,
|
||||
};
|
||||
@@ -5289,9 +5339,31 @@ function shouldLoadCompletionObjects(completionContext: ReturnType<typeof getSql
|
||||
return routineContext && !isReferencedTableQualifier(completionContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Databases whose qualified routine completion treats the first qualifier as a
|
||||
* package (Oracle) or as a package-or-schema in A compatibility mode
|
||||
* (openGauss). The backend re-validates: non-package parents fall through to
|
||||
* the ordinary routine search, so routing is safe even when the compatibility
|
||||
* map is not yet warm. openGauss keeps the package-aware routing while the
|
||||
* mode is unknown (cold start, so A-mode completion works immediately after
|
||||
* connect) and once the mode is known to be A; a known B/PG mode skips the
|
||||
* extra package-style queries entirely.
|
||||
*/
|
||||
function usesPackageAwareRoutineCompletion(): boolean {
|
||||
if (props.databaseType === "oracle") return true;
|
||||
if (props.databaseType !== "opengauss") return false;
|
||||
const mode = connectionStore.databaseCompatibilityMode(props.connectionId, props.database)?.trim().toUpperCase();
|
||||
return mode === undefined || mode === "A";
|
||||
}
|
||||
|
||||
function oracleRoutineCompletionTargets(completionContext: ReturnType<typeof getSqlCompletionContext>): RoutineCompletionTarget[] {
|
||||
const parts = (completionContext.qualifierParts?.length ? completionContext.qualifierParts : completionContext.qualifier?.split("."))?.filter(Boolean) ?? [];
|
||||
if (parts.length === 0) return [{ schema: props.schema, globalSearch: true }];
|
||||
if (parts.length === 0) {
|
||||
// Oracle resolves unqualified routines across all schemas (owner semantics).
|
||||
// openGauss keeps the PG search_path scope for the unqualified case.
|
||||
if (props.databaseType === "oracle") return [{ schema: props.schema, globalSearch: true }];
|
||||
return [{ schema: props.schema }];
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return [{ schema: props.schema, parentName: parts[0] }, { schema: parts[0] }];
|
||||
}
|
||||
@@ -5308,14 +5380,14 @@ function routineCompletionTargetForContext(completionContext: ReturnType<typeof
|
||||
}
|
||||
|
||||
function routineCompletionScopeForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): CompletionMetadataScope {
|
||||
if (props.databaseType === "oracle") return scope;
|
||||
if (usesPackageAwareRoutineCompletion()) return scope;
|
||||
const target = routineCompletionTargetForContext(completionContext, scope);
|
||||
return { database: target.database, schema: target.schema };
|
||||
}
|
||||
|
||||
function lookupLocalCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): SqlCompletionObject[] {
|
||||
if (!props.connectionId || props.database == null) return [];
|
||||
if (props.databaseType === "oracle") {
|
||||
if (usesPackageAwareRoutineCompletion()) {
|
||||
return connectionStore.lookupLocalCompletionObjects(props.connectionId, scope.database, completionContext.prefix, MAX_COMPLETION_TABLES);
|
||||
}
|
||||
const target = routineCompletionTargetForContext(completionContext, scope);
|
||||
@@ -5325,7 +5397,7 @@ function lookupLocalCompletionObjectsForContext(completionContext: ReturnType<ty
|
||||
async function listCompletionObjectsForContext(completionContext: ReturnType<typeof getSqlCompletionContext>, scope: CompletionMetadataScope): Promise<SqlCompletionObject[]> {
|
||||
if (!props.connectionId || props.database == null) return [];
|
||||
const objectKinds = completionObjectKindsForContext(completionContext);
|
||||
if (props.databaseType !== "oracle") {
|
||||
if (!usesPackageAwareRoutineCompletion()) {
|
||||
const target = routineCompletionTargetForContext(completionContext, scope);
|
||||
return connectionStore.listCompletionObjects(props.connectionId, target.database, target.mask, MAX_COMPLETION_TABLES, target.schema, undefined, false, scope.schema, objectKinds);
|
||||
}
|
||||
@@ -6077,6 +6149,7 @@ onMounted(async () => {
|
||||
queryEditorLineCommentToken(props.databaseType) === "//" ? shellLineCommentHighlightPlugin : [],
|
||||
];
|
||||
const MAX_SQL_SEMANTIC_HIGHLIGHT_WINDOWS = 32;
|
||||
const MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH = 128_000;
|
||||
const SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS = 100;
|
||||
const refreshSqlSemanticHighlightEffect = StateEffect.define<null>();
|
||||
buildSqlSemanticHighlightExtension = () => [
|
||||
@@ -6088,6 +6161,7 @@ onMounted(async () => {
|
||||
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
|
||||
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
|
||||
private cachedDoc: import("@codemirror/state").Text | null = null;
|
||||
private prewarmedDoc: import("@codemirror/state").Text | null = null;
|
||||
private cachedSql = "";
|
||||
private cachedDialectId = "";
|
||||
private cachedDatabaseType: DatabaseType | undefined;
|
||||
@@ -6098,6 +6172,7 @@ onMounted(async () => {
|
||||
}> = [];
|
||||
|
||||
constructor(private currentView: import("@codemirror/view").EditorView) {
|
||||
this.decorations = Decoration.none;
|
||||
this.decorations = this.buildDecorations(currentView);
|
||||
}
|
||||
|
||||
@@ -6143,13 +6218,15 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
const sql = this.cachedSql;
|
||||
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
|
||||
const windows: Array<{
|
||||
from: number;
|
||||
to: number;
|
||||
spans: Array<{ start: number; end: number }>;
|
||||
}> = [];
|
||||
const pendingWindows: Array<{ from: number; to: number }> = [];
|
||||
for (const visibleRange of currentView.visibleRanges) {
|
||||
const rangesToHighlight = shouldPrewarmFullDocument ? [{ from: 0, to: sql.length }] : currentView.visibleRanges;
|
||||
for (const visibleRange of rangesToHighlight) {
|
||||
const cached = this.cachedWindows.find((candidate) => candidate.from <= visibleRange.from && candidate.to >= visibleRange.to);
|
||||
if (cached) {
|
||||
if (!windows.includes(cached)) windows.push(cached);
|
||||
@@ -6169,8 +6246,19 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
if (pendingWindows.length > 0) {
|
||||
const tree = ensureSyntaxTree(currentView.state, Math.max(...pendingWindows.map((window) => window.to)), 25);
|
||||
if (!tree) return Decoration.set([]);
|
||||
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
|
||||
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
|
||||
if (!tree) {
|
||||
// The Lezer parse has not reached the pending windows yet (long
|
||||
// documents). Keep the decorations that are still valid and let
|
||||
// the deferred refresh rebuild them once parsing catches up —
|
||||
// returning an empty set here wiped table-name colors after
|
||||
// scrolling stopped or when a freshly mounted editor (tab
|
||||
// switch) had no later viewport change to trigger a rebuild.
|
||||
this.scheduleRefresh(currentView);
|
||||
return this.decorations;
|
||||
}
|
||||
if (shouldPrewarmFullDocument) this.prewarmedDoc = doc;
|
||||
for (const window of pendingWindows) {
|
||||
const entry = {
|
||||
...window,
|
||||
@@ -6790,6 +6878,7 @@ onMounted(async () => {
|
||||
contextMenuPointerCleanup = null;
|
||||
};
|
||||
|
||||
restoreEditorSelection(props.initialSelection, !props.initialViewport);
|
||||
restoreEditorViewport();
|
||||
syncContextMenuState(view.value);
|
||||
emit("previewChangesAvailable", !!previewContextSql.value);
|
||||
@@ -6874,8 +6963,10 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
// Flush the outgoing document before props and restored scroll positions
|
||||
// become the new tab's state. The event carries its original owner.
|
||||
flushEditorViewport();
|
||||
// become the new tab's state. The event carries its original owner. A
|
||||
// before-tab-switch capture already flushed this editor while it was still
|
||||
// visible, so avoid reading the reset scroll position during the transition.
|
||||
if (!tabSwitchStateCaptured) flushEditorViewport();
|
||||
viewportOwnerTabId = tabId;
|
||||
latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 };
|
||||
lastEmittedViewport = undefined;
|
||||
@@ -6896,7 +6987,7 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
|
||||
// document keeps whatever scroll offset the dispatch left behind (#8374).
|
||||
// A brand-new tab has no saved state, so reset it instead of falling back
|
||||
// to the previous tab's latest position (#8378).
|
||||
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 });
|
||||
restoreEditorSelection(props.initialSelection ?? { anchor: 0, head: 0 }, !props.initialViewport);
|
||||
restoreEditorViewport(props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 });
|
||||
clearScheduledPreviewContextRefresh();
|
||||
syncContextMenuState(currentView);
|
||||
@@ -6921,7 +7012,7 @@ function activateTabDocument(prevTabId: string | undefined, tabId: string | unde
|
||||
}
|
||||
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
||||
invalidateSemanticDiagnosticsForDocumentChange();
|
||||
restoreEditorSelection();
|
||||
restoreEditorSelection(undefined, !props.initialViewport);
|
||||
restoreEditorViewport();
|
||||
clearScheduledPreviewContextRefresh();
|
||||
syncContextMenuState(currentView);
|
||||
@@ -6945,6 +7036,31 @@ watch([() => props.tabId, () => props.modelValue], ([tabId, val], [prevTabId]) =
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.initialViewport,
|
||||
(viewport, previousViewport) => {
|
||||
if (!view.value || !viewport || previousViewport) return;
|
||||
// Saved SQL content can hydrate after the editor has already mounted. In
|
||||
// that case the initial prop was undefined and the mount-time restore had
|
||||
// nothing to apply.
|
||||
latestViewport = { ...viewport };
|
||||
lastEmittedViewport = { ...viewport };
|
||||
restoreEditorViewport(viewport);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.initialSelection,
|
||||
(selection, previousSelection) => {
|
||||
if (!view.value || !selection || previousSelection) return;
|
||||
// Keep the cursor and viewport in sync when a saved SQL tab hydrates after
|
||||
// the editor component has already been mounted.
|
||||
restoreEditorSelection(selection, !props.initialViewport);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.formatRequestId,
|
||||
(val) => {
|
||||
@@ -7045,6 +7161,23 @@ watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect,
|
||||
});
|
||||
});
|
||||
|
||||
// openGauss compatibility mode is loaded asynchronously from the backend into a
|
||||
// dedicated store map (not the sidebar tree). A restored tab may open before the
|
||||
// map is warm; when the mode arrives, re-derive statement boundaries and
|
||||
// diagnostics so package DDL is parsed with the correct PL/SQL rules.
|
||||
watch(
|
||||
() => (props.databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(props.connectionId, props.database) : undefined),
|
||||
(now, before) => {
|
||||
if (now === before) return;
|
||||
executableStatementRangeCache = null;
|
||||
if (props.databaseType !== "opengauss") return;
|
||||
if (!view.value) return;
|
||||
refreshCompletionCache();
|
||||
setSemanticDiagnostics([]);
|
||||
scheduleSemanticDiagnostics(0);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.forceWordWrap,
|
||||
() => {
|
||||
@@ -7181,9 +7314,16 @@ watch(
|
||||
function pauseQueryEditorBackgroundWork() {
|
||||
finishBatchColumnSelectionDrag(false);
|
||||
cancelBatchColumnSelectionRefresh();
|
||||
flushEditorViewport();
|
||||
flushEditorSelection();
|
||||
emit("editorStateFlushed");
|
||||
const stateWasCapturedBeforeTabSwitch = tabSwitchStateCaptured;
|
||||
tabSwitchStateCaptured = false;
|
||||
// A KeepAlive-evicted editor can be unmounted after it was already
|
||||
// deactivated. Its DOM scroll position has been reset by then, so flushing
|
||||
// that inactive view would overwrite the saved viewport with zero.
|
||||
if (editorIsActive && !stateWasCapturedBeforeTabSwitch) {
|
||||
flushEditorViewport();
|
||||
flushEditorSelection();
|
||||
emit("editorStateFlushed");
|
||||
}
|
||||
clearTableNavigationHover();
|
||||
clearPendingCompletionEnter();
|
||||
clearPendingCompletionTab();
|
||||
@@ -7196,12 +7336,23 @@ function pauseQueryEditorBackgroundWork() {
|
||||
unregisterTableReferenceDropListener();
|
||||
}
|
||||
|
||||
function captureEditorStateBeforeTabSwitch(event: Event) {
|
||||
const fromTabId = (event as CustomEvent<{ fromTabId?: string }>).detail?.fromTabId;
|
||||
if (!view.value || !fromTabId || fromTabId !== props.tabId) return;
|
||||
// Capture while the outgoing editor is still visible. Once KeepAlive starts
|
||||
// deactivating the surface, WebKit can report a reset scrollTop of zero.
|
||||
flushEditorViewport();
|
||||
flushEditorSelection();
|
||||
emit("editorStateFlushed");
|
||||
tabSwitchStateCaptured = true;
|
||||
}
|
||||
|
||||
function resumeQueryEditorBackgroundWork() {
|
||||
editorIsActive = true;
|
||||
registerTableReferenceDropListener();
|
||||
scheduleSemanticDiagnostics();
|
||||
if (view.value) schedulePreviewContextRefresh(view.value);
|
||||
restoreEditorSelection();
|
||||
restoreEditorSelection(undefined, !props.initialViewport);
|
||||
restoreEditorFocus();
|
||||
restoreEditorViewport();
|
||||
}
|
||||
@@ -7210,6 +7361,11 @@ onActivated(resumeQueryEditorBackgroundWork);
|
||||
|
||||
onDeactivated(pauseQueryEditorBackgroundWork);
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pauseQueryEditorBackgroundWork();
|
||||
viewportEmitTask.cancel();
|
||||
@@ -7222,6 +7378,7 @@ onBeforeUnmount(() => {
|
||||
view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit);
|
||||
window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease);
|
||||
window.removeEventListener("blur", clearTableNavigationHover);
|
||||
window.removeEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
|
||||
contextMenuPointerCleanup?.();
|
||||
postCompositionKeyGuardCleanup?.();
|
||||
postCompositionKeyGuardCleanup = null;
|
||||
@@ -7265,10 +7422,10 @@ function flushEditorSelection() {
|
||||
if (latestSelection) emitEditorSelection(latestSelection);
|
||||
}
|
||||
|
||||
function restoreEditorSelection(selection = props.initialSelection ?? latestSelection) {
|
||||
function restoreEditorSelection(selection = props.initialSelection ?? latestSelection, scrollIntoView = false) {
|
||||
const normalizedSelection = normalizedEditorSelection(selection, props.modelValue.length);
|
||||
if (!view.value || !normalizedSelection) return;
|
||||
view.value.dispatch({ selection: normalizedSelection });
|
||||
view.value.dispatch({ selection: normalizedSelection, scrollIntoView });
|
||||
}
|
||||
|
||||
function restoreEditorFocus() {
|
||||
@@ -7321,7 +7478,7 @@ function restoreEditorViewport(viewport = props.initialViewport ?? latestViewpor
|
||||
const restoreNextFrame = () => {
|
||||
restoreScroll();
|
||||
attempts += 1;
|
||||
if (attempts >= 8) {
|
||||
if (attempts >= 32) {
|
||||
viewportRestoreFrame = null;
|
||||
return;
|
||||
}
|
||||
@@ -7450,6 +7607,7 @@ defineExpose({
|
||||
|
||||
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar) {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-track) {
|
||||
|
||||
@@ -607,8 +607,11 @@ function sqlStatementsForTable(r: GeneratedTableResult): string[] {
|
||||
stmts.push(...r.statements);
|
||||
} else {
|
||||
const colList = r.columns.map((c) => quoteTableIdentifier(dbType.value, c)).join(", ");
|
||||
// Match by column name: rows may carry a leading tbname (TDengine stable),
|
||||
// so positional indexes into resolvedColumns would drift.
|
||||
const dataTypeByName = new Map(r.resolvedColumns.map((c) => [c.columnName.toLowerCase(), c.dataType]));
|
||||
for (const row of r.rows) {
|
||||
const vals = row.map((value) => formatGeneratedValue(value)).join(", ");
|
||||
const vals = row.map((value, index) => formatGeneratedValue(value, dbType.value, dataTypeByName.get(r.columns[index]?.toLowerCase() ?? ""))).join(", ");
|
||||
stmts.push(`INSERT INTO ${targetTable} (${colList}) VALUES (${vals});`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructur
|
||||
import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration";
|
||||
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridClipboardText, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { compactHeaderColumnType, formatMetadataColumnTypeLabel, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType";
|
||||
import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
@@ -513,7 +513,7 @@ interface DataGridProps {
|
||||
queryResultExportRequest?: (options: {
|
||||
exportId: string;
|
||||
filePath: string;
|
||||
format: "csv" | "xlsx" | "txt" | "sql";
|
||||
format: "csv" | "xlsx" | "json" | "txt" | "sql";
|
||||
includeSqlSheet?: boolean;
|
||||
exportTableName?: string;
|
||||
exportColumnTypes?: Array<string | null | undefined>;
|
||||
@@ -1933,7 +1933,8 @@ const {
|
||||
filteredColumnLayoutOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
hideColumns: hideColumnsInLayout,
|
||||
showAllColumns: showAllColumnsInLayout,
|
||||
invertColumnVisibility,
|
||||
showColumn,
|
||||
persistColumnOrder,
|
||||
@@ -2017,13 +2018,15 @@ const columnAligns = computed<("left" | "right")[]>(() => {
|
||||
});
|
||||
|
||||
function gridCellTextColorClass(item: RowItem, actualColIdx: number, visibleColIdx: number): string {
|
||||
if (!colorizeDataGridCellTypes.value) return "text-foreground";
|
||||
const value = item.data[actualColIdx];
|
||||
const isGridNull = value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL);
|
||||
if (isGridNull) return "text-muted-foreground italic";
|
||||
if (!colorizeDataGridCellTypes.value) return "text-foreground";
|
||||
const checkbox = booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && value !== null;
|
||||
return dataGridCellTextClass({
|
||||
colorizeTypes: colorizeDataGridCellTypes.value,
|
||||
typeKind: allColumnTypeVisualKinds.value[actualColIdx] ?? "unknown",
|
||||
isNull: value === null,
|
||||
isNull: isGridNull,
|
||||
isDraft: item.isDraft && value === null,
|
||||
isEditing: editingCell.value?.rowId === item.id && editingCell.value.col === actualColIdx,
|
||||
isControl: checkbox,
|
||||
@@ -2036,14 +2039,16 @@ function gridCellTextColorClass(item: RowItem, actualColIdx: number, visibleColI
|
||||
}
|
||||
|
||||
function transposeCellTextColorClass(recordIndex: number, actualColIdx: number): string {
|
||||
if (!colorizeDataGridCellTypes.value) return "text-foreground";
|
||||
const item = displayItems.value[recordIndex];
|
||||
if (!item) return "text-foreground";
|
||||
const value = item.data[actualColIdx];
|
||||
const isGridNull = value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL);
|
||||
if (isGridNull) return "text-muted-foreground italic";
|
||||
if (!colorizeDataGridCellTypes.value) return "text-foreground";
|
||||
return dataGridCellTextClass({
|
||||
colorizeTypes: colorizeDataGridCellTypes.value,
|
||||
typeKind: allColumnTypeVisualKinds.value[actualColIdx] ?? "unknown",
|
||||
isNull: value === null,
|
||||
isNull: isGridNull,
|
||||
isDraft: item.isDraft && value === null,
|
||||
isEditing: editingCell.value?.rowId === item.id && editingCell.value.col === actualColIdx,
|
||||
isControl: booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && value !== null,
|
||||
@@ -2290,6 +2295,47 @@ function unfreezeAllColumns() {
|
||||
applyColumnOrderChange(unfreezeAllColumnsInLayout);
|
||||
}
|
||||
|
||||
// 隐藏列后列宽/滚动范围都会缩短:让滚动条贴回新的最大偏移,避免右侧出现空白槽。
|
||||
function clampGridHorizontalScroll() {
|
||||
const scroller = gridScrollerElement();
|
||||
if (!scroller) return;
|
||||
const previousLeft = scroller.scrollLeft;
|
||||
clampGridScrollerBounds(scroller); // 复用既有边界收敛,保持单一事实来源
|
||||
if (scroller.scrollLeft === previousLeft) return; // 未越界,无需同步
|
||||
updateGridHorizontalViewport(scroller);
|
||||
if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
|
||||
}
|
||||
|
||||
// 批量隐藏(表头右键菜单):一次布局提交 + 一次持久化,并把可见索引变化交给
|
||||
// applyColumnOrderChange 处理,随后清空单元格选区、重测列宽并收敛横向滚动。
|
||||
function hideColumns(columnIndexes: number[]) {
|
||||
if (columnIndexes.length === 0) return;
|
||||
applyColumnOrderChange(() => hideColumnsInLayout(columnIndexes));
|
||||
clearCellSelection();
|
||||
void nextTick(() => {
|
||||
scheduleColumnLayoutRefresh();
|
||||
clampGridHorizontalScroll();
|
||||
});
|
||||
}
|
||||
|
||||
function hideContextColumn() {
|
||||
const columnIndex = contextHeaderColumnIndex.value;
|
||||
if (columnIndex === null || columnIndex < 0) return;
|
||||
hideColumns([columnIndex]);
|
||||
}
|
||||
|
||||
function hideSelectedColumns() {
|
||||
const actualColumnIndexes = selectedVisibleColumnIndexes()
|
||||
.map((visibleColIdx) => visibleColumnIndexes.value[visibleColIdx])
|
||||
.filter((index): index is number => index !== undefined);
|
||||
hideColumns(actualColumnIndexes);
|
||||
}
|
||||
|
||||
function showAllColumns() {
|
||||
applyColumnOrderChange(showAllColumnsInLayout);
|
||||
void nextTick(scheduleColumnLayoutRefresh);
|
||||
}
|
||||
|
||||
// --- 表头拖拽进 SQL 编辑器:目标导向模式切换的控制器 ---
|
||||
// 按选择顺序(Set 插入序)取列名;被拖列未选中时仅拖该列。
|
||||
function columnReferenceDragNames(draggedVisibleColIdx: number): string[] | null {
|
||||
@@ -3947,6 +3993,10 @@ function isIoTDBTimestampColumn(columnIndex: number): boolean {
|
||||
}
|
||||
|
||||
function inlineCellEditorText(value: CellValue, columnIndex: number): string {
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridEditorText(value);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
const columnInfo = tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex);
|
||||
const columnType = props.result.column_types?.[columnIndex] ?? columnInfo?.data_type;
|
||||
return (
|
||||
@@ -7073,6 +7123,7 @@ function drawCanvasGrid() {
|
||||
searchMatchKeys: searchMatchSet.value,
|
||||
currentSearchMatch: currentSearchMatch.value,
|
||||
formatCell: (value, columnIndex, row) => formatCellCached(visibleLargeValuePreviewValue(row, columnIndex, value), columnIndex, largeValueOriginalBytes(row, columnIndex)),
|
||||
isNullValue: (value) => value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL),
|
||||
newRowCellPlaceholder,
|
||||
isRowActive,
|
||||
rowCellsUseSelectionVisual,
|
||||
@@ -7395,7 +7446,7 @@ const {
|
||||
columnComments: visibleColumnComments,
|
||||
allColumnComments,
|
||||
displayValue: formatCellCached,
|
||||
cellClipboardText: (value) => (props.mongoCollectionGrid ? mongoDocumentGridEditorText(value) : undefined),
|
||||
cellClipboardText: (value) => (props.mongoCollectionGrid ? mongoDocumentGridClipboardText(value) : undefined),
|
||||
externalCellValue: (value) => (props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value),
|
||||
mongoDocuments: computed(() => props.result.mongo_copy_documents ?? props.result.mongo_documents),
|
||||
spatialColumns: computed(() => props.result.spatial_columns),
|
||||
@@ -8933,7 +8984,7 @@ async function onGridKeydown(event: KeyboardEvent) {
|
||||
|
||||
function detailClipboardText(detail: DataGridCellDetail): string {
|
||||
if (props.mongoCollectionGrid) {
|
||||
const documentGridText = mongoDocumentGridEditorText(detail.value);
|
||||
const documentGridText = mongoDocumentGridClipboardText(detail.value);
|
||||
if (documentGridText !== undefined) return documentGridText;
|
||||
}
|
||||
if (detail.value === null) return "";
|
||||
@@ -11095,6 +11146,7 @@ defineExpose({
|
||||
filteredColumnLayoutOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
hideColumns,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
hasCustomColumnOrder,
|
||||
@@ -11280,6 +11332,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const row = contextRowItem.value;
|
||||
const rowLabels = rowActionLabels();
|
||||
const hasEditableSelection = selectionHasEditableCells();
|
||||
const selectedColumnCount = selectedVisibleColumnIndexes().length;
|
||||
const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null;
|
||||
const previewItems: ContextMenuItem[] = [];
|
||||
if (!contextHeaderColumn.value && contextCell.value) {
|
||||
@@ -11311,6 +11364,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
frozenColumnCount: frozenColumnCount.value,
|
||||
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
|
||||
hasColumnSelection: hasColumnSelection.value,
|
||||
selectedColumnCount,
|
||||
visibleColumnCount: visibleColumnCount.value,
|
||||
hiddenColumnCount: hiddenColumnCount.value,
|
||||
labels: {
|
||||
copyName:
|
||||
selectedColumnNamesForCopy.value.length > 1
|
||||
@@ -11329,6 +11385,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
freezeToColumn: t("grid.freezeToColumn"),
|
||||
freezeSelectedColumns: t("grid.freezeSelectedColumns"),
|
||||
unfreezeColumns: t("grid.unfreezeColumns"),
|
||||
hideColumn: t("grid.hideColumn"),
|
||||
hideSelectedColumns: t("grid.hideSelectedColumns", { count: selectedColumnCount }),
|
||||
showAllColumnsMenu: t("grid.showAllColumnsMenu"),
|
||||
},
|
||||
icons: {
|
||||
copy: Copy,
|
||||
@@ -11359,6 +11418,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
unfreezeAllColumns();
|
||||
clearCellSelection();
|
||||
},
|
||||
hideColumn: hideContextColumn,
|
||||
hideSelectedColumns,
|
||||
showAllColumnsMenu: showAllColumns,
|
||||
},
|
||||
filterSubmenu: filterSubmenu(),
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FocusOutsideEvent, PointerDownOutsideEvent } from "reka-ui";
|
||||
import { CalendarClock, ChevronDown, ChevronUp, CircleSlash } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { formatTemporalInputValue, parseTemporalInputValue, stepTemporalInputValue, type TemporalCellEditorKind } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
import { formatTemporalInputValue, hostTimezoneOffsetSuffix, parseTemporalInputValue, stepTemporalInputValue, temporalOffsetSuffix, type TemporalCellEditorKind } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -134,7 +134,7 @@ function updateTime(part: "hour" | "minute" | "second", rawValue: string | numbe
|
||||
const parts = { ...timeParts.value, [part]: normalizeTimePart(rawValue, part === "hour" ? 23 : 59) };
|
||||
const nextTime = `${parts.hour}:${parts.minute}:${parts.second}${fractionSuffix.value}`;
|
||||
if (props.kind === "time") {
|
||||
setModelValue(nextTime, true);
|
||||
setModelValue(`${nextTime}${temporalOffsetSuffix(localValue.value, props.kind)}`, true);
|
||||
return;
|
||||
}
|
||||
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
|
||||
@@ -162,7 +162,7 @@ function updateFractionValue(rawValue: string) {
|
||||
const digits = rawValue.replace(/\D/g, "").slice(0, maxLength);
|
||||
const nextTime = `${timeParts.value.hour}:${timeParts.value.minute}:${timeParts.value.second}${digits ? `.${digits}` : ""}`;
|
||||
if (props.kind === "time") {
|
||||
setModelValue(nextTime, true);
|
||||
setModelValue(`${nextTime}${temporalOffsetSuffix(localValue.value, props.kind)}`, true);
|
||||
return;
|
||||
}
|
||||
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
|
||||
@@ -192,9 +192,12 @@ function setNow() {
|
||||
const now = new Date();
|
||||
const dateText = [String(now.getFullYear()).padStart(4, "0"), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("-");
|
||||
const nextTime = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0")].join(":") + nowFractionSuffix(now);
|
||||
// tz-aware columns must carry an explicit offset, otherwise the server parses
|
||||
// the host wall clock in its own session timezone
|
||||
const offsetSuffix = temporalOffsetSuffix(localValue.value, props.kind) ? hostTimezoneOffsetSuffix(now) : "";
|
||||
if (props.kind === "date") setModelValue(dateText, true);
|
||||
else if (props.kind === "time") setModelValue(nextTime, true);
|
||||
else setModelValue(`${dateText} ${nextTime}`, true);
|
||||
else if (props.kind === "time") setModelValue(`${nextTime}${offsetSuffix}`, true);
|
||||
else setModelValue(`${dateText} ${nextTime}${offsetSuffix}`, true);
|
||||
}
|
||||
|
||||
function finishCommit() {
|
||||
@@ -267,7 +270,7 @@ function normalizeTimePart(value: string | number, max: number): string {
|
||||
function setDateTimeValue(year: number, month: number, day: number, time: string) {
|
||||
const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join("-");
|
||||
if (props.kind === "date") setModelValue(dateText, true);
|
||||
else setModelValue(`${dateText} ${time}`, true);
|
||||
else setModelValue(`${dateText} ${time}${temporalOffsetSuffix(localValue.value, props.kind)}`, true);
|
||||
}
|
||||
|
||||
function parseFractionDigits(value: string): string {
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ function createGrid(itemCount: number) {
|
||||
return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options;
|
||||
},
|
||||
toggleColumnVisibility,
|
||||
hideColumns: vi.fn(),
|
||||
showAllColumns: vi.fn(),
|
||||
invertColumnVisibility: vi.fn(),
|
||||
hasCustomColumnOrder: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface DataGridColumnLayoutHandle {
|
||||
orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[];
|
||||
filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[];
|
||||
toggleColumnVisibility: (columnIndex: number) => void;
|
||||
hideColumns: (columnIndexes: number[]) => void;
|
||||
showAllColumns: () => void;
|
||||
invertColumnVisibility: () => void;
|
||||
hasCustomColumnOrder: boolean;
|
||||
|
||||
@@ -32,10 +32,12 @@ import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
|
||||
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
|
||||
const props = defineProps<{
|
||||
showConnectionDialog: boolean;
|
||||
connectionPrefill?: ConnectionDeepLinkDraft | null;
|
||||
connectionPluginProvider?: PluginCenterFocus | null;
|
||||
connectionInitialTab?: ConfigTab;
|
||||
showDangerDialog: boolean;
|
||||
dangerSql: string;
|
||||
@@ -140,7 +142,8 @@ const editConfig = computed(() => {
|
||||
const shouldShowConnectionDialog = computed(() => props.showConnectionDialog || !!editConfig.value);
|
||||
|
||||
watch(editConfig, (v) => {
|
||||
if (v) emit("update:showConnectionDialog", true);
|
||||
if (!v) return;
|
||||
emit("update:showConnectionDialog", true);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -167,6 +170,7 @@ watch(
|
||||
:open="shouldShowConnectionDialog"
|
||||
:edit-config="editConfig"
|
||||
:prefill-config="connectionPrefill"
|
||||
:plugin-provider="connectionPluginProvider"
|
||||
:initial-tab="connectionInitialTab"
|
||||
@update:open="emit('update:showConnectionDialog', $event)"
|
||||
@connect-started="emit('connectStarted', $event)"
|
||||
|
||||
@@ -14,6 +14,8 @@ import "./appTabBar.css";
|
||||
const props = defineProps<{
|
||||
driverStoreOpen?: boolean;
|
||||
driverStoreActive?: boolean;
|
||||
pluginCenterOpen?: boolean;
|
||||
pluginCenterActive?: boolean;
|
||||
settingsPageOpen?: boolean;
|
||||
settingsPageActive?: boolean;
|
||||
agentDriverUpdateCount?: number;
|
||||
@@ -26,6 +28,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"activate-driver-store": [];
|
||||
"close-driver-store": [];
|
||||
"activate-plugin-center": [];
|
||||
"close-plugin-center": [];
|
||||
"activate-settings-page": [];
|
||||
"close-settings-page": [];
|
||||
"activate-tab": [tabId: string];
|
||||
@@ -121,15 +125,21 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
type SpecialRegularSurface = "driverStore" | "settings";
|
||||
type SpecialRegularSurface = "driverStore" | "pluginCenter" | "settings";
|
||||
|
||||
function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
|
||||
if (keep !== "driverStore" && props.driverStoreOpen) {
|
||||
emit("close-driver-store");
|
||||
}
|
||||
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
|
||||
emit("close-plugin-center");
|
||||
}
|
||||
if (keep !== "settings" && props.settingsPageOpen) {
|
||||
emit("close-settings-page");
|
||||
}
|
||||
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
|
||||
emit("close-plugin-center");
|
||||
}
|
||||
}
|
||||
|
||||
// Regular tabs live in editor groups now, so "close other tabs" at App level
|
||||
@@ -143,6 +153,10 @@ function closeOtherActiveTabs() {
|
||||
closeSpecialRegularSurfaces("driverStore");
|
||||
return;
|
||||
}
|
||||
if (props.pluginCenterActive) {
|
||||
closeSpecialRegularSurfaces("pluginCenter");
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTabId = queryStore.activeTabId;
|
||||
if (!activeTabId) {
|
||||
@@ -188,7 +202,7 @@ function handleCancelClose() {
|
||||
<template>
|
||||
<!-- Targets remain mounted while inactive so the original group bars can
|
||||
move here without losing their local presentation state. -->
|
||||
<div v-show="driverStoreActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
|
||||
<div v-show="driverStoreActive || pluginCenterActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
|
||||
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
|
||||
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, FileDown, FolderTree } from "@lucide/vue";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
@@ -38,6 +38,7 @@ const props = defineProps<{
|
||||
sqlLibrarySaveFeedbackId: number;
|
||||
showSqlFilePanel: boolean;
|
||||
showDriverStore: boolean;
|
||||
showPluginCenter: boolean;
|
||||
showSettingsPage: boolean;
|
||||
checkingUpdates: boolean;
|
||||
updateVersion?: string;
|
||||
@@ -64,6 +65,7 @@ const emit = defineEmits<{
|
||||
"open-github": [];
|
||||
"open-settings": [];
|
||||
"open-driver-store": [];
|
||||
"open-plugin-center": [];
|
||||
"check-updates": [];
|
||||
"open-transfer": [];
|
||||
"open-sql-file": [];
|
||||
@@ -438,6 +440,9 @@ const moreItems = computed(() => {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (!toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
|
||||
// "More" menu items (individually toggleable)
|
||||
if (toolbarItems.value.sqlFile) {
|
||||
@@ -504,6 +509,9 @@ const collapsedItems = computed(() => {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
// Always include moreItems (may contain hidden left-side items + overflowed right items)
|
||||
if (moreItems.value.length > 0) {
|
||||
items.push(...moreItems.value);
|
||||
@@ -571,6 +579,10 @@ const toolbarStyle = computed(() => {
|
||||
<!-- 小圆点仅提示"有可更新驱动",具体数量交给对话框内标签页红点展示,避免工具栏长期挂红数字。 -->
|
||||
<span v-if="agentDriverUpdateCount > 0" class="ml-0.5 inline-block h-2 w-2 rounded-full bg-red-500" :aria-label="t('toolbar.updatableDriverCount')" :title="t('toolbar.updatableDriverCount')" />
|
||||
</Button>
|
||||
<Button v-if="toolbarItems.pluginCenter" variant="ghost" size="sm" :class="[toolbarTextButtonClass, { 'bg-accent': showPluginCenter }]" @click="emit('open-plugin-center')">
|
||||
<PlugZap class="h-3.5 w-3.5" />
|
||||
<span :class="toolbarTextLabelClass">{{ t("toolbar.pluginCenter") }}</span>
|
||||
</Button>
|
||||
|
||||
<LightDropdown
|
||||
v-if="showMoreDropdown"
|
||||
|
||||
@@ -121,8 +121,10 @@ const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MyS
|
||||
const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue"));
|
||||
const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admin/XuguServerDashboard.vue"));
|
||||
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||||
|
||||
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
|
||||
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
|
||||
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -155,6 +157,7 @@ import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
|
||||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { supportsTableImport } from "@/lib/database/databaseFeatureSupport";
|
||||
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
|
||||
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
|
||||
import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
|
||||
@@ -173,6 +176,7 @@ import type { QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/t
|
||||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
type DataGridHandle = DataGridColumnLayoutHandle & {
|
||||
@@ -321,6 +325,7 @@ const consulOverviewRef = ref<{ refresh?: () => boolean }>();
|
||||
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
|
||||
const databaseBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const objectBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const pluginFilesystemTabRef = ref<{ refresh: () => Promise<unknown> }>();
|
||||
const activeTableMeta = computed(() => props.activeTab.tableMeta);
|
||||
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
|
||||
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
|
||||
@@ -329,8 +334,13 @@ const activeResultConnectionId = computed(() => activeResultExecutionTarget.valu
|
||||
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
|
||||
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
|
||||
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
|
||||
// 表数据工具箱的「导入数据」与侧边栏、对象浏览器共用同一条能力判断:未适配导入的引擎(如 HANA)不出现入口。
|
||||
const canOpenTableImport = computed(() => supportsTableImport(activeEffectiveDatabaseType.value));
|
||||
const activeVectorConnection = computed(() => connectionStore.getConfig(props.activeTab.connectionId) ?? props.activeConnection);
|
||||
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
|
||||
const activeSqlStatementParameterOptions = computed(() =>
|
||||
sqlStatementParameterOptionsForCompatibility(activeEffectiveDatabaseType.value, activeEffectiveDatabaseType.value === "opengauss" ? connectionStore.databaseCompatibilityMode(activeResultConnectionId.value, activeResultDatabase.value) : undefined),
|
||||
);
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
|
||||
const productionSessionDetail = computed(() => {
|
||||
@@ -463,6 +473,7 @@ const activeStatementExecutionMarkers = computed(() =>
|
||||
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
|
||||
props.activeTab.resultEditorFingerprint ?? "",
|
||||
props.activeTab.batchSqlExecution,
|
||||
activeSqlStatementParameterOptions.value,
|
||||
),
|
||||
);
|
||||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||||
@@ -656,6 +667,7 @@ const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size"))
|
||||
// re-normalize a single pane, and shrinking it would leave a blank dead zone.
|
||||
const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value));
|
||||
const queryRunningElapsed = ref(0);
|
||||
const sourceLoadElapsed = ref(0);
|
||||
|
||||
function toggleResultsPane(): boolean {
|
||||
if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false;
|
||||
@@ -687,25 +699,36 @@ function onResultsResized(payload: { panes: { size: number }[] }) {
|
||||
}
|
||||
let queryRunningElapsedFrame: number | undefined;
|
||||
|
||||
function stopQueryRunningElapsedTimer() {
|
||||
function stopRunningElapsedTimer() {
|
||||
if (queryRunningElapsedFrame !== undefined) {
|
||||
window.cancelAnimationFrame(queryRunningElapsedFrame);
|
||||
queryRunningElapsedFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateQueryRunningElapsed() {
|
||||
const startedAt = props.activeTab.queryExecutionStartedAt;
|
||||
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0;
|
||||
/** 源码仍在加载(未失败)时的开始时间;用于耗时显示与 rAF 循环的续期判断。 */
|
||||
function pendingSourceLoadStartedAt(): number | undefined {
|
||||
const load = props.activeTab.sourceLoad;
|
||||
return load && !load.error ? load.startedAt : undefined;
|
||||
}
|
||||
|
||||
function startQueryRunningElapsedTimer() {
|
||||
stopQueryRunningElapsedTimer();
|
||||
updateQueryRunningElapsed();
|
||||
if (!props.activeTab.isExecuting || !props.activeTab.queryExecutionStartedAt) return;
|
||||
// 一个 rAF 循环同时驱动「查询执行中」与「对象源码加载中」两个耗时显示:
|
||||
// issue #9035 的核心体感就是「不知道要等多久」,所以源码加载也要显示已耗时。
|
||||
function updateRunningElapsed() {
|
||||
const startedAt = props.activeTab.queryExecutionStartedAt;
|
||||
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0;
|
||||
const sourceStartedAt = pendingSourceLoadStartedAt();
|
||||
sourceLoadElapsed.value = sourceStartedAt ? Math.max(0, Date.now() - sourceStartedAt) : 0;
|
||||
}
|
||||
|
||||
function startRunningElapsedTimer() {
|
||||
stopRunningElapsedTimer();
|
||||
updateRunningElapsed();
|
||||
const isTicking = () => (props.activeTab.isExecuting && !!props.activeTab.queryExecutionStartedAt) || pendingSourceLoadStartedAt() !== undefined;
|
||||
if (!isTicking()) return;
|
||||
const updateOnNextFrame = () => {
|
||||
updateQueryRunningElapsed();
|
||||
if (props.activeTab.isExecuting && props.activeTab.queryExecutionStartedAt) {
|
||||
updateRunningElapsed();
|
||||
if (isTicking()) {
|
||||
queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||||
}
|
||||
};
|
||||
@@ -713,11 +736,12 @@ function startQueryRunningElapsedTimer() {
|
||||
}
|
||||
|
||||
const queryRunningElapsedSeconds = computed(() => formatElapsedSeconds(queryRunningElapsed.value));
|
||||
const sourceLoadElapsedSeconds = computed(() => formatElapsedSeconds(sourceLoadElapsed.value));
|
||||
|
||||
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt] as const, startQueryRunningElapsedTimer, { immediate: true });
|
||||
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt, props.activeTab.sourceLoad?.startedAt, props.activeTab.sourceLoad?.error] as const, startRunningElapsedTimer, { immediate: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
stopQueryRunningElapsedTimer();
|
||||
stopRunningElapsedTimer();
|
||||
standaloneResultToolbarResizeObserver?.disconnect();
|
||||
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
|
||||
window.removeEventListener("resize", updateStandaloneResultToolbarDimensions);
|
||||
@@ -959,6 +983,10 @@ function refreshData(): boolean {
|
||||
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.refresh?.() ?? false;
|
||||
if (props.activeTab.mode === "plugin-filesystem") {
|
||||
void pluginFilesystemTabRef.value?.refresh();
|
||||
return true;
|
||||
}
|
||||
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
|
||||
if (canReloadUnavailableDataTab(props.activeTab)) {
|
||||
reloadUnavailableDataTab();
|
||||
@@ -981,6 +1009,25 @@ function onRefreshActiveKvBrowser(event: Event) {
|
||||
void nextTick(() => refreshData());
|
||||
}
|
||||
|
||||
function openPluginResultView(pluginId: string, contributionId: string, label: string) {
|
||||
const result = props.activeTab.result;
|
||||
if (!result) return;
|
||||
// Plugin workbenches receive a bounded snapshot; plugins re-query through
|
||||
// their backend when they need the full or streamed result set.
|
||||
const cappedRows = result.rows.slice(0, 500);
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: label,
|
||||
connectionId: props.activeTab.connectionId || "",
|
||||
database: props.activeTab.database || "",
|
||||
context: {
|
||||
connectionId: props.activeTab.connectionId || "",
|
||||
database: props.activeTab.database || "",
|
||||
sql: props.activeTab.sql,
|
||||
result: { columns: result.columns, rows: cappedRows, truncated: result.rows.length > cappedRows.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function exportResultArchive() {
|
||||
if (resultArchiveExporting.value) return;
|
||||
resultArchiveExporting.value = true;
|
||||
@@ -1110,7 +1157,7 @@ function toggleResultAutoSave() {
|
||||
function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
|
||||
queryStore.setActiveResultIndex(props.activeTab.id, item.index);
|
||||
emit("update:activeOutputView", props.activeTab.id, "result");
|
||||
const range = resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value) ?? null;
|
||||
const range = resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value, activeSqlStatementParameterOptions.value) ?? null;
|
||||
if (queryEditorRef.value) {
|
||||
nextTick(() => queryEditorRef.value?.previewStatementRange(range));
|
||||
} else {
|
||||
@@ -1122,7 +1169,7 @@ function executionSummaryItemRange(item: ExecutionSummaryItem) {
|
||||
if (typeof item.sourceFrom === "number" && typeof item.sourceTo === "number" && item.sql && props.activeTab.sql.slice(item.sourceFrom, item.sourceTo) === item.sql) {
|
||||
return { from: item.sourceFrom, to: item.sourceTo };
|
||||
}
|
||||
return item.result ? resultSourceRange(props.activeTab.sql, item.result, item.statementIndex, activeEffectiveDatabaseType.value) : undefined;
|
||||
return item.result ? resultSourceRange(props.activeTab.sql, item.result, item.statementIndex, activeEffectiveDatabaseType.value, activeSqlStatementParameterOptions.value) : undefined;
|
||||
}
|
||||
|
||||
function previewExecutionSummaryItem(item: ExecutionSummaryItem) {
|
||||
@@ -1278,7 +1325,18 @@ defineExpose({
|
||||
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
|
||||
</div>
|
||||
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
|
||||
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。 -->
|
||||
<QueryLoadingState v-if="activeTab.sourceLoad && !activeTab.sourceLoad.error" class="relative z-0 flex-1" :elapsed-seconds="sourceLoadElapsedSeconds" />
|
||||
<div v-else-if="activeTab.sourceLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-object-source-load-error>
|
||||
<p class="max-w-[80%] text-center break-words text-destructive">{{ activeTab.sourceLoad.error }}</p>
|
||||
<Button variant="outline" size="sm" class="gap-1.5" @click="queryStore.retryObjectSourceTab(activeTab.id)">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ t("common.retry") }}
|
||||
</Button>
|
||||
</div>
|
||||
<QueryEditor
|
||||
v-else
|
||||
ref="queryEditorRef"
|
||||
class="relative z-0 flex-1"
|
||||
:auto-focus="autoFocus !== false"
|
||||
@@ -1712,9 +1770,11 @@ defineExpose({
|
||||
:can-export-archive="canExportResultArchive"
|
||||
:archive-exporting="resultArchiveExporting"
|
||||
:compact="standaloneResultToolbarCompact"
|
||||
:has-result="!!activeTab.result"
|
||||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||||
@export-archive="exportResultArchive"
|
||||
@open-result-view="openPluginResultView"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1739,13 +1799,13 @@ defineExpose({
|
||||
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 flex-col bg-background">
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 min-w-0 overflow-auto bg-background">
|
||||
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
|
||||
<template v-else>{{ t("executionSummary.empty") }}</template>
|
||||
</div>
|
||||
<div v-else class="flex h-full min-h-0 min-w-[46rem] flex-col">
|
||||
<div v-else class="flex min-h-full min-w-[46rem] flex-col">
|
||||
<div v-if="batchExecutionProgress" class="z-10 shrink-0 border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div class="mb-1.5 flex items-center gap-3 text-xs">
|
||||
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
|
||||
@@ -1895,7 +1955,7 @@ defineExpose({
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
|
||||
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
|
||||
:query-result-export-request="
|
||||
(options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined>; insertMode?: SqlInsertMode }) =>
|
||||
(options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'json' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined>; insertMode?: SqlInsertMode }) =>
|
||||
queryStore.buildQueryResultExportRequest(activeTab.id, options)
|
||||
"
|
||||
:all-export-results="allResultExportSheets"
|
||||
@@ -1953,9 +2013,11 @@ defineExpose({
|
||||
:can-export-archive="canExportResultArchive"
|
||||
:archive-exporting="resultArchiveExporting"
|
||||
:compact="compact"
|
||||
:has-result="!!activeTab.result"
|
||||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||||
@export-archive="exportResultArchive"
|
||||
@open-result-view="openPluginResultView"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
|
||||
@@ -2041,7 +2103,7 @@ defineExpose({
|
||||
<Database class="h-4 w-4" />
|
||||
{{ t("tableToolbox.generateData") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem class="gap-2" @click="handleTableImport">
|
||||
<DropdownMenuItem v-if="canOpenTableImport" class="gap-2" @click="handleTableImport">
|
||||
<Download class="h-4 w-4" />
|
||||
{{ t("tableToolbox.importData") }}
|
||||
</DropdownMenuItem>
|
||||
@@ -2501,6 +2563,22 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- plugin-workbench tabs render in the always-mounted layer in App.vue
|
||||
(v-show visibility): remounting the webview per tab switch would
|
||||
reload the plugin iframe and drop its live session state. -->
|
||||
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<PluginFilesystemTab
|
||||
ref="pluginFilesystemTabRef"
|
||||
:key="activeTab.id"
|
||||
:plugin-id="activeTab.pluginFilesystem.pluginId"
|
||||
:provider-id="activeTab.pluginFilesystem.providerId"
|
||||
:connection-id="activeTab.connectionId || undefined"
|
||||
:root-uri="activeTab.pluginFilesystem.rootUri"
|
||||
:initial-uri="activeTab.pluginFilesystem.currentUri"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
|
||||
<div class="min-w-0 flex-1 min-h-0">
|
||||
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
|
||||
|
||||
@@ -30,6 +30,8 @@ const props = defineProps<
|
||||
tabBarCollapsed?: boolean;
|
||||
canDetachTabs?: boolean;
|
||||
detachedDropTarget?: boolean;
|
||||
/** Collapse the group to its tab strip while a plugin workbench tab owns the layout. */
|
||||
contentSuppressed?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -98,6 +100,7 @@ const tabBarTarget = computed(() => {
|
||||
if (!tabBarPortal?.active.value) return undefined;
|
||||
return tabBarPortal.targets.get(props.groupId);
|
||||
});
|
||||
|
||||
const groupTabs = computed(() => {
|
||||
const byId = new Map(queryStore.tabs.map((tab) => [tab.id, tab]));
|
||||
return props.tabIds.map((id) => byId.get(id)).filter((tab): tab is QueryTab => !!tab);
|
||||
@@ -138,7 +141,7 @@ const groupExecutableSql = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-group flex h-full min-h-0 min-w-0 overflow-hidden" :class="[groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
|
||||
<div class="editor-group flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? '' : 'h-full', groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
|
||||
<Teleport defer v-if="showTabNavigation !== false" :to="tabBarTarget" :disabled="!tabBarPortal?.active.value || !tabBarTarget">
|
||||
<EditorGroupTabBar
|
||||
:group-id="groupId"
|
||||
@@ -157,13 +160,17 @@ const groupExecutableSql = computed(() => {
|
||||
@detach-tab="$emit('detach-tab', $event)"
|
||||
@activate-settings="toolbar.activateSettingsPage()"
|
||||
@close-settings="toolbar.closeSettingsPage()"
|
||||
@activate-plugin-center="toolbar.activatePluginCenter()"
|
||||
@close-plugin-center="toolbar.closePluginCenter()"
|
||||
@activate-driver-store="toolbar.activateDriverStore()"
|
||||
@close-driver-store="toolbar.closeDriverStore()"
|
||||
/>
|
||||
</Teleport>
|
||||
<!-- The toolbar stays at the top of the pane's content column in every
|
||||
placement; only the tab bar moves around it. -->
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
placement; only the tab bar moves around it. While contentSuppressed
|
||||
(plugin workbench tab active) the whole column yields to the
|
||||
always-mounted plugin layer and the group collapses to its strip. -->
|
||||
<div v-show="!contentSuppressed" class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<EditorToolbar
|
||||
v-if="activeTab && showGroupToolbar"
|
||||
:active-tab="activeTab"
|
||||
@@ -190,8 +197,8 @@ const groupExecutableSql = computed(() => {
|
||||
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
|
||||
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
|
||||
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
|
||||
@execute-pointer-down="toolbar.captureExecutionSnapshot()"
|
||||
@toolbar-execute="toolbar.toolbarExecute($event)"
|
||||
@execute-pointer-down="toolbar.captureExecutionSnapshot(activeTab.id)"
|
||||
@toolbar-execute="toolbar.toolbarExecute($event, activeTab.id)"
|
||||
@multi-execute="toolbar.multiExecute()"
|
||||
@preview-changes="activeTab && toolbar.previewChanges(activeTab.id)"
|
||||
@cancel="activeTab && toolbar.cancelExecution(activeTab.id)"
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
PanelTop,
|
||||
Pencil,
|
||||
Pin,
|
||||
PlugZap,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Search,
|
||||
@@ -98,8 +99,8 @@ const props = defineProps<{
|
||||
canDetachTabs?: boolean;
|
||||
/** A detached tab is being dragged over this bar — highlight it as the drop target. */
|
||||
detachedDropTarget?: boolean;
|
||||
/** App-level special pages (settings / driver store) appended after the tabs. */
|
||||
specialPageTabs?: { settingsOpen: boolean; settingsActive: boolean; driverStoreOpen: boolean; driverStoreActive: boolean; driverUpdateCount: number };
|
||||
/** App-level special pages appended after the tabs. */
|
||||
specialPageTabs?: { settingsOpen: boolean; settingsActive: boolean; driverStoreOpen: boolean; driverStoreActive: boolean; pluginCenterOpen: boolean; pluginCenterActive: boolean; driverUpdateCount: number };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -113,6 +114,8 @@ const emit = defineEmits<{
|
||||
"close-settings": [];
|
||||
"activate-driver-store": [];
|
||||
"close-driver-store": [];
|
||||
"activate-plugin-center": [];
|
||||
"close-plugin-center": [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -135,8 +138,16 @@ const suppressNextTabClick = ref(false);
|
||||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||||
// Special pages append to the focused group's strip only: one instance at a
|
||||
// time, in the pane the user is working in (v0.6.2 kept them in the single strip).
|
||||
const showSpecialPageTabs = computed(() => !!props.specialPageTabs && (props.specialPageTabs.settingsOpen || props.specialPageTabs.driverStoreOpen) && queryStore.focusedGroupId === props.groupId);
|
||||
const specialPageActive = computed(() => !!(props.specialPageTabs?.settingsActive || props.specialPageTabs?.driverStoreActive));
|
||||
const showSpecialPageTabs = computed(() => {
|
||||
if (!props.specialPageTabs || !(props.specialPageTabs.settingsOpen || props.specialPageTabs.driverStoreOpen || props.specialPageTabs.pluginCenterOpen)) return false;
|
||||
// With no regular query tabs there is no focus event to establish the
|
||||
// focused group. Render the special-page tab in the sole (main) group so
|
||||
// opening Plugin Center or Driver Manager by itself still creates a tab.
|
||||
const isFocusedGroup = queryStore.focusedGroupId === props.groupId;
|
||||
const isEmptyWorkspaceMainGroup = queryStore.tabs.length === 0 && props.groupId === queryStore.groups[0]?.id;
|
||||
return isFocusedGroup || isEmptyWorkspaceMainGroup;
|
||||
});
|
||||
const specialPageActive = computed(() => !!(props.specialPageTabs?.settingsActive || props.specialPageTabs?.driverStoreActive || props.specialPageTabs?.pluginCenterActive));
|
||||
|
||||
function isTabActive(tab: QueryTab): boolean {
|
||||
return !specialPageActive.value && tab.id === props.activeTabId;
|
||||
@@ -153,9 +164,11 @@ function specialPageTabClass(active: boolean): string[] {
|
||||
}
|
||||
|
||||
function specialPageTabStyle(active: boolean) {
|
||||
if (isVerticalLayout.value) return active ? { "--app-tab-background": "var(--accent)" } : undefined;
|
||||
if (!isClassicLayout.value) return undefined;
|
||||
return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
|
||||
if (!active) return undefined;
|
||||
const activeBackground = "color-mix(in srgb, var(--foreground) 18%, var(--background))";
|
||||
if (isVerticalLayout.value) return { "--app-tab-background": "var(--accent)" };
|
||||
if (!isClassicLayout.value) return { "--app-tab-background": activeBackground, borderColor: "var(--ring)" };
|
||||
return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };
|
||||
}
|
||||
const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
|
||||
const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");
|
||||
@@ -192,16 +205,22 @@ function toggleCompactTabTitle() {
|
||||
compactTabTitle.value = !compactTabTitle.value;
|
||||
}
|
||||
|
||||
function getSpecialPageTabMenuItems(surface: "settings" | "driverStore"): ContextMenuItem[] {
|
||||
const closeCurrent = surface === "settings" ? () => emit("close-settings") : () => emit("close-driver-store");
|
||||
const otherOpen = surface === "settings" ? props.specialPageTabs?.driverStoreOpen : props.specialPageTabs?.settingsOpen;
|
||||
function getSpecialPageTabMenuItems(surface: "settings" | "driverStore" | "pluginCenter"): ContextMenuItem[] {
|
||||
const closeCurrent = surface === "settings" ? () => emit("close-settings") : surface === "driverStore" ? () => emit("close-driver-store") : () => emit("close-plugin-center");
|
||||
const otherOpen =
|
||||
surface === "settings" ? props.specialPageTabs?.driverStoreOpen || props.specialPageTabs?.pluginCenterOpen : surface === "driverStore" ? props.specialPageTabs?.settingsOpen || props.specialPageTabs?.pluginCenterOpen : props.specialPageTabs?.settingsOpen || props.specialPageTabs?.driverStoreOpen;
|
||||
const closeOthers = () => {
|
||||
if (surface !== "settings") emit("close-settings");
|
||||
if (surface !== "driverStore") emit("close-driver-store");
|
||||
if (surface !== "pluginCenter") emit("close-plugin-center");
|
||||
};
|
||||
return [
|
||||
{ label: compactTabTitle.value ? t("contextMenu.fullTabTitle") : t("contextMenu.compactTabTitle"), action: toggleCompactTabTitle, icon: compactTabTitle.value ? Maximize2 : Minimize2 },
|
||||
{ label: "", separator: true },
|
||||
{ label: t("contextMenu.closeTab"), action: closeCurrent, icon: X },
|
||||
{
|
||||
label: t("contextMenu.closeOtherTabs"),
|
||||
action: () => (surface === "settings" ? emit("close-driver-store") : emit("close-settings")),
|
||||
action: closeOthers,
|
||||
disabled: !otherOpen,
|
||||
icon: X,
|
||||
},
|
||||
@@ -1392,7 +1411,7 @@ watch(
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?.driverStoreActive, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => {
|
||||
watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?.driverStoreActive, () => props.specialPageTabs?.pluginCenterActive, () => settingsStore.editorSettings.tabPlacement, () => props.tabBarCollapsed], () => {
|
||||
nextTick(() => {
|
||||
updateScrollButtons();
|
||||
if (showSpecialPageTabs.value && specialPageActive.value) {
|
||||
@@ -1614,6 +1633,31 @@ watch([() => props.specialPageTabs?.settingsActive, () => props.specialPageTabs?
|
||||
</CustomContextMenu>
|
||||
</template>
|
||||
<template v-if="!section.pinned && showSpecialPageTabs">
|
||||
<CustomContextMenu v-if="specialPageTabs?.pluginCenterOpen" :items="getSpecialPageTabMenuItems('pluginCenter')" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
data-plugin-center-tab
|
||||
class="app-tab-pill group flex shrink-0 cursor-default items-center gap-1 px-2 text-xs transition-colors whitespace-nowrap select-none"
|
||||
:class="specialPageTabClass(!!specialPageTabs?.pluginCenterActive)"
|
||||
:style="specialPageTabStyle(!!specialPageTabs?.pluginCenterActive)"
|
||||
:data-active-tab="specialPageTabs?.pluginCenterActive"
|
||||
:title="t('toolbar.pluginCenter')"
|
||||
:aria-label="t('toolbar.pluginCenter')"
|
||||
:aria-pressed="!!specialPageTabs?.pluginCenterActive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('activate-plugin-center')"
|
||||
@keydown.enter.self.prevent="emit('activate-plugin-center')"
|
||||
@keydown.space.self.prevent="emit('activate-plugin-center')"
|
||||
@contextmenu="onContextMenu"
|
||||
@mousedown.middle.prevent="emit('close-plugin-center')"
|
||||
>
|
||||
<PlugZap class="h-3.5 w-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<span v-if="!isTabBarCollapsed" class="min-w-0 flex-1 truncate">{{ t("toolbar.pluginCenter") }}</span>
|
||||
<button v-if="!isTabBarCollapsed" class="shrink-0 rounded p-0.5 hover:bg-muted-foreground/20" :aria-label="t('common.close')" :title="t('common.close')" @click.stop="emit('close-plugin-center')">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
<CustomContextMenu v-if="specialPageTabs?.settingsOpen" :items="getSpecialPageTabMenuItems('settings')" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
data-settings-page-tab
|
||||
|
||||
@@ -520,6 +520,8 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="blockDangerousRedisCommands !== false ? 'text-orange-600 bg-orange-100 dark:text-orange-300 dark:bg-orange-900/30' : 'text-muted-foreground/50'"
|
||||
:aria-label="t('toolbar.blockDangerousRedisCommands')"
|
||||
:aria-pressed="blockDangerousRedisCommands !== false"
|
||||
@click="emit('update:blockDangerousRedisCommands', blockDangerousRedisCommands === false)"
|
||||
>
|
||||
<Shield class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { GitBranch, Gauge, Loader2, Upload } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { GitBranch, Gauge, Loader2, PlugZap, Upload } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry, type PluginContributionEntry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin, PluginResultViewContribution } from "@/types/database";
|
||||
|
||||
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeView: OutputView;
|
||||
canShowExplain: boolean;
|
||||
@@ -14,17 +18,30 @@ withDefaults(
|
||||
canExportArchive: boolean;
|
||||
archiveExporting: boolean;
|
||||
compact?: boolean;
|
||||
hasResult?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
{ compact: false, hasResult: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectExplain: [];
|
||||
selectProfile: [];
|
||||
exportArchive: [];
|
||||
openResultView: [pluginId: string, contributionId: string, label: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const resultViews = computed<PluginContributionEntry<PluginResultViewContribution>[]>(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listResultViews());
|
||||
const visibleResultViews = computed(() => (props.hasResult ? resultViews.value.slice(0, 4) : []));
|
||||
|
||||
void api.listPlugins().then(
|
||||
(plugins) => {
|
||||
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,5 +96,19 @@ const { t } = useI18n();
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.exportResultArchive") }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip v-for="view in visibleResultViews" :key="view.plugin.manifest.id + ':' + view.contribution.id" :text="t('pluginPlatform.openResultView', { label: view.contribution.label })" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 shrink-0 text-xs leading-none text-muted-foreground hover:text-foreground"
|
||||
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
|
||||
:title="t('pluginPlatform.openResultView', { label: view.contribution.label })"
|
||||
:aria-label="t('pluginPlatform.openResultView', { label: view.contribution.label })"
|
||||
@click="emit('openResultView', view.plugin.manifest.id, view.contribution.id, view.contribution.label)"
|
||||
>
|
||||
<PlugZap class="block h-3.5 w-3.5 self-center" />
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ view.contribution.label }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,8 @@ const props = defineProps<
|
||||
tabBarCollapsed?: boolean;
|
||||
canDetachTabs?: boolean;
|
||||
detachedDropTarget?: boolean;
|
||||
/** Render only the groups' tab strips (a plugin workbench tab owns the layout). */
|
||||
contentSuppressed?: boolean;
|
||||
}
|
||||
>();
|
||||
const emit = defineEmits<
|
||||
@@ -92,9 +94,9 @@ defineExpose({
|
||||
const group = groupForElement(target) ?? activeEditorGroup();
|
||||
return group?.handleModRTarget(target) ?? false;
|
||||
},
|
||||
requestQueryEditorExecute: () => activeEditorGroup()?.requestQueryEditorExecute() ?? false,
|
||||
captureQueryEditorExecutionSnapshot: () => activeEditorGroup()?.captureQueryEditorExecutionSnapshot(),
|
||||
requestQueryEditorExecuteInNewResultTab: () => activeEditorGroup()?.requestQueryEditorExecuteInNewResultTab() ?? false,
|
||||
requestQueryEditorExecute: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecute() ?? false,
|
||||
captureQueryEditorExecutionSnapshot: (tabId?: string) => editorGroupForTab(tabId)?.captureQueryEditorExecutionSnapshot(),
|
||||
requestQueryEditorExecuteInNewResultTab: (tabId?: string) => editorGroupForTab(tabId)?.requestQueryEditorExecuteInNewResultTab() ?? false,
|
||||
requestQueryEditorPreviewChanges: (stackSql?: string) => activeEditorGroup()?.requestQueryEditorPreviewChanges(stackSql) ?? false,
|
||||
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => activeEditorGroup()?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
|
||||
cancelQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.cancelQueryEditorExecutionViewport(requestId) ?? false,
|
||||
@@ -116,12 +118,13 @@ const globalTabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null);
|
||||
const workspaceTabBarPortal = createGroupTabBarPortal(isVerticalTabLayout);
|
||||
// A special page owns navigation while active. Otherwise side tabs stay
|
||||
// outside the editor/result split, and horizontal tabs return to their group.
|
||||
provide(GROUP_TAB_BAR_PORTAL, {
|
||||
const providedPortal = {
|
||||
active: computed(() => !!globalTabBarPortal?.active.value || isVerticalTabLayout.value),
|
||||
get targets() {
|
||||
return globalTabBarPortal?.active.value ? globalTabBarPortal.targets : workspaceTabBarPortal.targets;
|
||||
},
|
||||
});
|
||||
};
|
||||
provide(GROUP_TAB_BAR_PORTAL, providedPortal);
|
||||
const tabNavigationStyle = computed(() => {
|
||||
const width = props.tabBarCollapsed ? "var(--collapsed-tab-rail-width)" : `${props.tabBarWidth ?? 240}px`;
|
||||
return { width, flex: `0 0 ${width}` };
|
||||
@@ -214,6 +217,11 @@ function activeEditorGroup() {
|
||||
const group = queryStore.groups.find((item) => item.id === queryStore.focusedGroupId) ?? queryStore.groups[0];
|
||||
return group ? (groupRefs.get(group.id) ?? null) : null;
|
||||
}
|
||||
function editorGroupForTab(tabId?: string): InstanceType<typeof EditorGroup> | null {
|
||||
if (!tabId) return activeEditorGroup();
|
||||
const group = queryStore.groups.find((item) => item.activeTabId === tabId);
|
||||
return group ? (groupRefs.get(group.id) ?? null) : null;
|
||||
}
|
||||
function groupForElement(element: Element | null): InstanceType<typeof EditorGroup> | null {
|
||||
const groupElement = element?.closest<HTMLElement>("[data-group-id]");
|
||||
const groupId = groupElement?.dataset.groupId;
|
||||
@@ -249,12 +257,45 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sql-editor-workspace relative flex h-full min-h-0 min-w-0 flex-1 overflow-hidden" :class="[workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
|
||||
<!-- contentSuppressed (plugin workbench tab active): the App.vue wrapper is
|
||||
flex-none with indefinite height, so h-full/flex-1 here would collapse
|
||||
the workspace to 0 and clip the group tab strips (overflow-hidden).
|
||||
Size to content instead — same contract as EditorGroup's suppressed
|
||||
h-auto; normal mode keeps h-full/flex-1 to fill the column. -->
|
||||
<div class="sql-editor-workspace relative flex min-h-0 min-w-0 overflow-hidden" :class="[contentSuppressed ? 'h-auto' : 'h-full flex-1', workspaceClass, settingsStore.editorSettings.tabPlacement === 'right' ? 'flex-row-reverse' : 'flex-row']">
|
||||
<div v-show="isVerticalTabLayout && showTabNavigation !== false" data-workspace-tab-navigation class="flex min-h-0 shrink-0 flex-col overflow-hidden" :style="tabNavigationStyle">
|
||||
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-workspace-tab-target="group.id" class="flex min-h-0 min-w-0 flex-1" @pointerdown.capture="queryStore.focusGroup(group.id)" @focusin="queryStore.focusGroup(group.id)" />
|
||||
</div>
|
||||
<div data-workspace-content class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
|
||||
<!-- Suppressed mode: a plugin workbench tab owns the layout. Render the
|
||||
groups' tab strips directly (no Splitpanes, no shared result pane) so
|
||||
App.vue's always-mounted plugin layer can fill the remaining column
|
||||
while the strips stay visible and switchable. -->
|
||||
<template v-if="contentSuppressed">
|
||||
<EditorGroup
|
||||
v-for="group in queryStore.groups"
|
||||
:key="group.id"
|
||||
:ref="(el: unknown) => setGroupRef(group.id, el)"
|
||||
:group-id="group.id"
|
||||
:tab-ids="group.tabIds"
|
||||
:active-tab-id="group.activeTabId"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
:can-detach-tabs="canDetachTabs"
|
||||
:detached-drop-target="detachedDropTarget"
|
||||
:content-suppressed="true"
|
||||
:show-tab-navigation="showTabNavigation"
|
||||
v-bind="editorGroupBindings"
|
||||
@focus-group="queryStore.focusGroup($event)"
|
||||
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
|
||||
@locate-tab="emit('locate-tab', $event)"
|
||||
@toggle-zen-mode="emit('toggle-zen-mode')"
|
||||
@start-resize="emit('start-resize', $event)"
|
||||
@toggle-collapse="emit('toggle-collapse')"
|
||||
@detach-tab="emit('detach-tab', $event)"
|
||||
/>
|
||||
</template>
|
||||
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
|
||||
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
|
||||
<Splitpanes
|
||||
:horizontal="queryStore.orientation === 'horizontal'"
|
||||
|
||||
@@ -6,26 +6,24 @@ import { queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling">;
|
||||
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling" | "sourceLoad">;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const status = computed(() => {
|
||||
if (props.tab.mode !== "query" || !props.tab.isExecuting) return undefined;
|
||||
// 源码加载中(issue #9035)与查询执行中共用这一个 tab 栏转圈:两者都是
|
||||
// 「这个 tab 正在等后端」,用户需要的反馈是同一种。
|
||||
const status = computed<"running" | "cancelling" | "source-loading" | undefined>(() => {
|
||||
if (props.tab.mode !== "query") return undefined;
|
||||
if (props.tab.sourceLoad && !props.tab.sourceLoad.error) return "source-loading";
|
||||
if (!props.tab.isExecuting) return undefined;
|
||||
return props.tab.isCancelling ? "cancelling" : "running";
|
||||
});
|
||||
|
||||
const labelKey = computed(() => (status.value === "source-loading" ? "common.loading" : queryExecutionLabelKey(props.tab)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="status"
|
||||
data-tab-execution-status
|
||||
role="status"
|
||||
class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center"
|
||||
:class="status === 'cancelling' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'"
|
||||
:aria-label="t(queryExecutionLabelKey(tab))"
|
||||
:title="t(queryExecutionLabelKey(tab))"
|
||||
>
|
||||
<span v-if="status" data-tab-execution-status role="status" class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center" :class="status === 'cancelling' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'" :aria-label="t(labelKey)" :title="t(labelKey)">
|
||||
<Loader2 aria-hidden="true" class="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</span>
|
||||
<slot v-else />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, Braces, CalendarClock, Clock, Code2, Database, Eye, FileCode, Gauge, KeyRound, Link2, ListTree, Network, Package, PencilRuler, ScrollText, ShieldCheck, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { isEventObjectBrowserTab, tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
@@ -38,5 +39,7 @@ defineProps<{ tab: QueryTab }>();
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
|
||||
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
|
||||
<PluginIcon v-else-if="tab.mode === 'plugin-workbench' && tab.pluginWorkbench" :plugin-id="tab.pluginWorkbench.pluginId" :contribution-id="tab.pluginWorkbench.contributionId" />
|
||||
<PluginIcon v-else-if="tab.mode === 'plugin-filesystem' && tab.pluginFilesystem" :plugin-id="tab.pluginFilesystem.pluginId" :contribution-id="tab.pluginFilesystem.providerId" />
|
||||
<Code2 v-else />
|
||||
</template>
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("AppTabBar shared group navigation", () => {
|
||||
});
|
||||
|
||||
it("retains targets while hiding inactive special content and follows all placements", () => {
|
||||
expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
|
||||
expect(tabBarSource).toContain('v-show="driverStoreActive || pluginCenterActive || settingsPageActive"');
|
||||
expect(tabBarSource).toContain("data-special-page-navigation");
|
||||
expect(tabBarSource).toContain("data-special-page-content");
|
||||
expect(tabBarSource).toContain("<slot />");
|
||||
@@ -63,8 +63,10 @@ describe("Group strip special page tabs", () => {
|
||||
it("keeps special page tabs and their update badge in the focused group", () => {
|
||||
expect(groupTabBarSource).toContain("queryStore.focusedGroupId === props.groupId");
|
||||
expect(groupTabBarSource).toContain("data-settings-page-tab");
|
||||
expect(groupTabBarSource).toContain("data-plugin-center-tab");
|
||||
expect(groupTabBarSource).toContain("data-driver-store-tab");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-settings')\"");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-plugin-center')\"");
|
||||
expect(groupTabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
|
||||
expect(groupTabBarSource).toContain('t("toolbar.driverManager")');
|
||||
expect(groupTabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
|
||||
@@ -74,6 +76,8 @@ describe("Group strip special page tabs", () => {
|
||||
it("routes special-page actions and preserves ordinary-tab dirty closing", () => {
|
||||
expect(groupSource).toContain(':special-page-tabs="toolbar.specialPageTabs.value"');
|
||||
expect(groupSource).toContain('@activate-settings="toolbar.activateSettingsPage()"');
|
||||
expect(groupSource).toContain('@activate-plugin-center="toolbar.activatePluginCenter()"');
|
||||
expect(groupSource).toContain('@close-plugin-center="toolbar.closePluginCenter()"');
|
||||
expect(groupSource).toContain('@close-driver-store="toolbar.closeDriverStore()"');
|
||||
expect(groupTabBarSource).toContain('@mousedown.middle.prevent="closeTab(entry.tab)"');
|
||||
expect(groupTabBarSource).toContain('@click.stop="closeTab(entry.tab)"');
|
||||
@@ -83,8 +87,10 @@ describe("Group strip special page tabs", () => {
|
||||
it("uses one presentation path for active special pages and inactive ordinary tabs", () => {
|
||||
expect(groupTabBarSource).toContain("return !specialPageActive.value && tab.id === props.activeTabId;");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.settingsActive)");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.pluginCenterActive)");
|
||||
expect(groupTabBarSource).toContain("specialPageTabClass(!!specialPageTabs?.driverStoreActive)");
|
||||
expect(groupTabBarSource).toContain('return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;');
|
||||
expect(groupTabBarSource).toContain("if (!active) return undefined;");
|
||||
expect(groupTabBarSource).toContain('return { "--app-tab-background": activeBackground, boxShadow: "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" };');
|
||||
expect(groupTabBarSource).toContain('import "./appTabBar.css"');
|
||||
expect(groupTabBarSource).toContain("dirty-tab-marker");
|
||||
expect(groupTabBarSource).toContain("dirtyTabTitleStyle");
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
// 能打开「导入表数据」向导的三个入口。侧边栏的守卫在菜单宿主(SidebarTreeRuntimeHost.vue)里,
|
||||
// 写入函数 composables/useSidebarTreeToolRuntime.ts 本身不带守卫,所以这里列的是入口界面而不是写入点。
|
||||
const TABLE_IMPORT_ENTRY_SURFACES = ["../ContentArea.vue", "../../objects/ObjectBrowser.vue", "../../sidebar/SidebarTreeRuntimeHost.vue"];
|
||||
|
||||
describe("ContentArea table import entry", () => {
|
||||
it("gates the data-tab toolbox import item on the shared capability check", () => {
|
||||
const source = read("../ContentArea.vue");
|
||||
// HANA 的能力清单是 tableImport:false。入口不跟能力走时,向导仍能打开并停在「选项」步:
|
||||
// 目标表字段元数据取不回来,下一步恒为禁用(QA #hana-import 的复现路径)。
|
||||
expect(source).toContain("const canOpenTableImport = computed(");
|
||||
expect(source).toContain("supportsTableImport(activeEffectiveDatabaseType.value)");
|
||||
|
||||
const item = source.indexOf('t("tableToolbox.importData")');
|
||||
expect(item).toBeGreaterThan(-1);
|
||||
const openTag = source.lastIndexOf("<DropdownMenuItem", item);
|
||||
expect(openTag).toBeGreaterThan(-1);
|
||||
expect(source.slice(openTag, item)).toContain('v-if="canOpenTableImport"');
|
||||
});
|
||||
|
||||
it("keeps every entry surface consulting the same capability", () => {
|
||||
for (const surface of TABLE_IMPORT_ENTRY_SURFACES) {
|
||||
expect(read(surface), surface).toContain("supportsTableImport");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -198,6 +198,8 @@ describe("EditorGroup mount contract", () => {
|
||||
|
||||
const actions: EditorToolbarActions = {
|
||||
...createNoopEditorToolbarActions(),
|
||||
captureExecutionSnapshot: vi.fn(),
|
||||
toolbarExecute: vi.fn(),
|
||||
cancelExecution: vi.fn(),
|
||||
explain: vi.fn(),
|
||||
saveSql: vi.fn(),
|
||||
@@ -229,10 +231,14 @@ describe("EditorGroup mount contract", () => {
|
||||
expect(toolbar?.textContent).toBe(queryId);
|
||||
|
||||
// Toolbar events flow to the injected actions with the group's tab id.
|
||||
(toolbar as any).__vueParentComponent.emit("executePointerDown");
|
||||
(toolbar as any).__vueParentComponent.emit("toolbarExecute", "pointer");
|
||||
(toolbar as any).__vueParentComponent.emit("cancel");
|
||||
(toolbar as any).__vueParentComponent.emit("explain");
|
||||
(toolbar as any).__vueParentComponent.emit("saveSql", queryId);
|
||||
await nextTick();
|
||||
expect(actions.captureExecutionSnapshot).toHaveBeenCalledWith(queryId);
|
||||
expect(actions.toolbarExecute).toHaveBeenCalledWith("pointer", queryId);
|
||||
expect(actions.cancelExecution).toHaveBeenCalledWith(queryId);
|
||||
expect(actions.explain).toHaveBeenCalledWith(queryId);
|
||||
expect(actions.saveSql).toHaveBeenCalledWith(queryId);
|
||||
|
||||
+35
-1
@@ -24,6 +24,16 @@ vi.mock("@/components/ui/popover", () => ({
|
||||
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/plugins/pluginIconResolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/plugins/pluginIconResolver")>()),
|
||||
resolvePluginIcon: vi.fn(() => Promise.resolve("assets/plugin.svg")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/backend/api")>()),
|
||||
readPluginAsset: vi.fn(() => Promise.resolve({ contentType: "image/svg+xml", dataBase64: "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" })),
|
||||
}));
|
||||
|
||||
const specDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
|
||||
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
|
||||
@@ -713,6 +723,30 @@ describe("EditorGroupTabBar group behavior", () => {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("renders plugin workbench tabs with the plugin icon instead of the code fallback", async () => {
|
||||
const store = useQueryStore();
|
||||
const pluginTabId = store.openPluginWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench", { title: "SSH server", connectionId: "ssh-1", forceNew: true });
|
||||
const { app, host } = mountBar(store.groups[0].id, store.tabs.slice(), pluginTabId, pinia);
|
||||
await settle();
|
||||
// PluginIcon loads the asset asynchronously; wait for the blob <img> to appear.
|
||||
for (let i = 0; i < 20 && !host.querySelector(`[data-tab-id="${pluginTabId}"] img`); i += 1) {
|
||||
await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 5));
|
||||
}
|
||||
|
||||
const pill = host.querySelector<HTMLElement>(`[data-tab-id="${pluginTabId}"]`);
|
||||
expect(pill).toBeTruthy();
|
||||
expect(pill!.querySelector("img")).toBeTruthy();
|
||||
// The pill and the overflow popup list both render via TabModeIcon, whose
|
||||
// per-mode chain must keep the plugin-workbench plugin icon branch.
|
||||
const tabModeIconSource = readFileSync(resolve(specDir, "../TabModeIcon.vue"), "utf8");
|
||||
expect(tabModeIconSource).toContain("tab.mode === 'plugin-workbench' && tab.pluginWorkbench");
|
||||
expect(tabModeIconSource).toContain(':contribution-id="tab.pluginWorkbench.contributionId"');
|
||||
expect(source).toContain('<TabModeIcon :tab="entry.tab"');
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditorGroupTabBar special page navigation", () => {
|
||||
@@ -758,7 +792,7 @@ describe("EditorGroupTabBar special page navigation", () => {
|
||||
expect(host.querySelector(".app-tab-bar")?.classList.contains("vertical-tab-layout")).toBe(vertical);
|
||||
const special = host.querySelector<HTMLElement>("[data-settings-page-tab]")!;
|
||||
expect(special.classList.contains("h-8")).toBe(vertical);
|
||||
expect(special.style.boxShadow).toBe(vertical ? "" : layout === "classic" ? "inset 0 -2px 0 var(--ring)" : "");
|
||||
expect(special.style.boxShadow).toBe(vertical ? "" : layout === "classic" ? "inset 0 -2px 0 color-mix(in srgb, var(--foreground) 72%, transparent)" : "");
|
||||
}
|
||||
app.unmount();
|
||||
host.remove();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createApp, h, nextTick, reactive, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -63,6 +63,9 @@ vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
|
||||
|
||||
import EditorToolbar from "../EditorToolbar.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { resolveExecutableSql, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
function createHost(): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
@@ -141,6 +144,169 @@ describe("EditorToolbar mount contract", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps the manually selected SQL from pointer-down through toolbar execution", async () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "conn",
|
||||
db_type: "postgres",
|
||||
color: "",
|
||||
} as never,
|
||||
];
|
||||
|
||||
const fullSql = "SELECT first_value;\nSELECT selected_value;";
|
||||
const selectedSql = "SELECT selected_value;";
|
||||
const snapshot = {
|
||||
fullSql,
|
||||
selectedSql,
|
||||
cursorPos: fullSql.length,
|
||||
selectionFrom: fullSql.indexOf(selectedSql),
|
||||
selectionTo: fullSql.length,
|
||||
};
|
||||
let pendingSnapshot: typeof snapshot | undefined;
|
||||
let executedSql = "";
|
||||
const onExecutePointerDown = vi.fn(() => {
|
||||
pendingSnapshot = snapshot;
|
||||
});
|
||||
const onToolbarExecute = vi.fn((source: "pointer" | "keyboard") => {
|
||||
executedSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
|
||||
});
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(EditorToolbar, {
|
||||
activeTab: {
|
||||
id: "tab-1",
|
||||
title: "SQL",
|
||||
connectionId: "conn-1",
|
||||
database: "db",
|
||||
sql: fullSql,
|
||||
mode: "query",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
},
|
||||
activeConnection: connectionStore.getConfig("conn-1"),
|
||||
executableSql: selectedSql,
|
||||
explainMode: "explain",
|
||||
blockDangerousRedisCommands: false,
|
||||
sqlKeywordCase: "preserve",
|
||||
databaseRequiredSignal: 0,
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
onExecutePointerDown,
|
||||
onToolbarExecute,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const runButton = host.querySelectorAll("button")[0];
|
||||
runButton?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }));
|
||||
runButton?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(onExecutePointerDown).toHaveBeenCalledTimes(1);
|
||||
expect(onToolbarExecute).toHaveBeenCalledWith("pointer");
|
||||
expect(onExecutePointerDown.mock.invocationCallOrder[0]).toBeLessThan(onToolbarExecute.mock.invocationCallOrder[0]!);
|
||||
expect(executedSql).toBe(selectedSql);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("executes the exact CodeMirror selection after the toolbar click moves focus", async () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
name: "conn",
|
||||
db_type: "postgres",
|
||||
color: "",
|
||||
} as never;
|
||||
connectionStore.connections = [connection];
|
||||
|
||||
const fullSql = "SELECT first_value;\nSELECT selected_value;";
|
||||
const selectedSql = "SELECT selected_value;";
|
||||
const selectionFrom = fullSql.indexOf(selectedSql);
|
||||
const editorRef = ref<{ captureExecutionSnapshot?: () => SqlExecutionSnapshot | undefined } | null>(null);
|
||||
const state = reactive({ sql: fullSql });
|
||||
let pendingSnapshot: SqlExecutionSnapshot | undefined;
|
||||
let executedSql = "";
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp({
|
||||
setup() {
|
||||
return () =>
|
||||
h("div", [
|
||||
h(QueryEditor, {
|
||||
ref: editorRef,
|
||||
modelValue: state.sql,
|
||||
tabId: "tab-1",
|
||||
connectionId: "conn-1",
|
||||
database: "db",
|
||||
databaseType: "postgres",
|
||||
dialect: "postgres",
|
||||
autoFocus: false,
|
||||
"onUpdate:modelValue": (value: string) => (state.sql = value),
|
||||
}),
|
||||
h(EditorToolbar, {
|
||||
activeTab: {
|
||||
id: "tab-1",
|
||||
title: "SQL",
|
||||
connectionId: "conn-1",
|
||||
database: "db",
|
||||
sql: fullSql,
|
||||
mode: "query",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
},
|
||||
activeConnection: connection,
|
||||
executableSql: fullSql,
|
||||
explainMode: "explain",
|
||||
blockDangerousRedisCommands: false,
|
||||
sqlKeywordCase: "preserve",
|
||||
databaseRequiredSignal: 0,
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
onExecutePointerDown: () => {
|
||||
pendingSnapshot = editorRef.value?.captureExecutionSnapshot?.();
|
||||
},
|
||||
onToolbarExecute: (source: "pointer" | "keyboard") => {
|
||||
executedSql = source === "pointer" && pendingSnapshot ? resolveExecutableSql(pendingSnapshot.fullSql, pendingSnapshot.selectedSql, { mode: "current", cursorPos: pendingSnapshot.cursorPos }) : fullSql;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
|
||||
|
||||
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
|
||||
view.dispatch({ selection: { anchor: selectionFrom, head: fullSql.length } });
|
||||
expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe(selectedSql);
|
||||
|
||||
const runButton = host.querySelector<HTMLElement>(".app-editor-toolbar button");
|
||||
runButton?.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }));
|
||||
runButton?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(pendingSnapshot?.selectedSql).toBe(selectedSql);
|
||||
expect(executedSql).toBe(selectedSql);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("does not expose SQL formatting for Redis connections", async () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
|
||||
+143
-2
@@ -21,11 +21,12 @@ const groupHandleModRCalls = vi.hoisted(() => [] as Element[]);
|
||||
const groupFocusSearchCalls = vi.hoisted(() => [] as Array<Element | null>);
|
||||
const resultHandleModRCalls = vi.hoisted(() => [] as Element[]);
|
||||
const resultFocusSearchCalls = vi.hoisted(() => [] as boolean[]);
|
||||
const groupExecutionCalls = vi.hoisted(() => ({ capture: [] as string[], request: [] as string[] }));
|
||||
|
||||
vi.mock("@/components/layout/EditorGroup.vue", () => ({
|
||||
default: {
|
||||
name: "EditorGroupStub",
|
||||
props: ["groupId", "tabIds", "activeTabId"],
|
||||
props: ["groupId", "tabIds", "activeTabId", "contentSuppressed"],
|
||||
emits: ["execute"],
|
||||
methods: {
|
||||
previewStatementRange(tabId: string, range: unknown) {
|
||||
@@ -44,8 +45,16 @@ vi.mock("@/components/layout/EditorGroup.vue", () => ({
|
||||
groupFocusSearchCalls.push(target ?? null);
|
||||
return true;
|
||||
},
|
||||
captureQueryEditorExecutionSnapshot(this: { groupId: string }) {
|
||||
groupExecutionCalls.capture.push(this.groupId);
|
||||
return { fullSql: `SELECT ${this.groupId}`, selectedSql: `SELECT ${this.groupId}`, cursorPos: 0, selectionFrom: 0, selectionTo: 13 };
|
||||
},
|
||||
requestQueryEditorExecute(this: { groupId: string }) {
|
||||
groupExecutionCalls.request.push(this.groupId);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
|
||||
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId" :data-content-suppressed="contentSuppressed ? 'true' : 'false'"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -149,6 +158,8 @@ describe("SqlEditorWorkspace mount contract", () => {
|
||||
groupFocusSearchCalls.length = 0;
|
||||
resultHandleModRCalls.length = 0;
|
||||
resultFocusSearchCalls.length = 0;
|
||||
groupExecutionCalls.capture.length = 0;
|
||||
groupExecutionCalls.request.length = 0;
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
i18n = createI18n({
|
||||
@@ -158,6 +169,47 @@ describe("SqlEditorWorkspace mount contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes toolbar execution to the requested tab's editor group", async () => {
|
||||
const store = useQueryStore();
|
||||
const tabs = [tab("tab-a"), tab("tab-b")];
|
||||
store.tabs = tabs;
|
||||
store.activeTabId = "tab-a";
|
||||
store.groups = [
|
||||
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
|
||||
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
|
||||
];
|
||||
store.focusedGroupId = "g1";
|
||||
store.sizes = [50, 50];
|
||||
const host = createHost();
|
||||
const app = createApp(SqlEditorWorkspace, {
|
||||
activeTab: tabs[0]!,
|
||||
activeConnection: undefined,
|
||||
executableSql: "SELECT 1",
|
||||
activeOutputView: "result",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
const vm = app.mount(host) as unknown as {
|
||||
captureQueryEditorExecutionSnapshot: (tabId?: string) => { selectedSql: string } | undefined;
|
||||
requestQueryEditorExecute: (tabId?: string) => boolean;
|
||||
};
|
||||
await nextTick();
|
||||
|
||||
const snapshot = vm.captureQueryEditorExecutionSnapshot("tab-b");
|
||||
expect(snapshot?.selectedSql).toBe("SELECT g2");
|
||||
expect(groupExecutionCalls.capture).toEqual(["g2"]);
|
||||
expect(vm.requestQueryEditorExecute("tab-b")).toBe(true);
|
||||
expect(groupExecutionCalls.request).toEqual(["g2"]);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("gives an idle query the full editor pane without a result surface or re-show button", async () => {
|
||||
const { host, vm, cleanup } = mountOutputWorkspace([{ ...tab("idle"), result: undefined }]);
|
||||
await nextTick();
|
||||
@@ -284,6 +336,95 @@ describe("SqlEditorWorkspace mount contract", () => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps group tab strips mounted and drops the splitpanes workspace while suppressed", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a"), { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>];
|
||||
store.activeTabId = "tab-b";
|
||||
store.groups = [
|
||||
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
|
||||
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
|
||||
];
|
||||
store.focusedGroupId = "g2";
|
||||
store.orientation = "vertical";
|
||||
store.sizes = [50, 50];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(SqlEditorWorkspace, {
|
||||
activeTab: { ...tab("tab-b"), mode: "plugin-workbench" } as unknown as ReturnType<typeof tab>,
|
||||
activeConnection: undefined,
|
||||
executableSql: "",
|
||||
activeOutputView: "result",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
contentSuppressed: true,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
// Suppressed mode renders the groups' tab strips directly (no Splitpanes,
|
||||
// no shared result pane) so App.vue's always-mounted plugin workbench
|
||||
// layer can own the remaining column while the strips stay visible.
|
||||
expect(host.querySelector('[data-test="splitpanes-stub"]')).toBeNull();
|
||||
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
|
||||
const groups = host.querySelectorAll('[data-test="editor-group"]');
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1]?.getAttribute("data-group-id")).toBe("g2");
|
||||
expect(groups[1]?.getAttribute("data-content-suppressed")).toBe("true");
|
||||
|
||||
// The App.vue wrapper is flex-none with indefinite height while a plugin
|
||||
// tab is active: the workspace must size to its strip content, or the
|
||||
// h-full/flex-1 fill contract collapses it to zero height and clips the
|
||||
// strips away (overflow-hidden) — the "plugin tab bar vanishes" regression.
|
||||
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
|
||||
expect(workspaceRoot?.classList.contains("h-auto")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("h-full")).toBe(false);
|
||||
expect(workspaceRoot?.classList.contains("flex-1")).toBe(false);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps the fill-height contract on the workspace root outside suppressed mode", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a")];
|
||||
store.activeTabId = "tab-a";
|
||||
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
|
||||
store.focusedGroupId = "g1";
|
||||
store.orientation = "vertical";
|
||||
store.sizes = [100];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(SqlEditorWorkspace, {
|
||||
activeTab: tab("tab-a"),
|
||||
activeConnection: undefined,
|
||||
executableSql: "",
|
||||
activeOutputView: "result",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
contentSuppressed: false,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const workspaceRoot = host.querySelector<HTMLElement>(".sql-editor-workspace");
|
||||
expect(workspaceRoot?.classList.contains("h-full")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("flex-1")).toBe(true);
|
||||
expect(workspaceRoot?.classList.contains("h-auto")).toBe(false);
|
||||
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("forwards editor execute events from EditorGroup to the App listener", async () => {
|
||||
const store = useQueryStore();
|
||||
store.tabs = [tab("tab-a")];
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock("vue-i18n", () => ({
|
||||
|
||||
const mountedApps: ReturnType<typeof createApp>[] = [];
|
||||
|
||||
function mountStatus(initial: { mode: "query" | "data"; isExecuting: boolean; isCancelling?: boolean }, withFallback = false) {
|
||||
function mountStatus(initial: { mode: "query" | "data"; isExecuting: boolean; isCancelling?: boolean; sourceLoad?: { startedAt: number; error?: string; request: { name: string; objectType: string } } }, withFallback = false) {
|
||||
const state = reactive(initial);
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
@@ -82,4 +82,31 @@ describe("TabExecutionStatus", () => {
|
||||
expect(idle.root.querySelector("[data-tab-execution-status]")).toBeNull();
|
||||
expect(data.root.querySelector("[data-tab-execution-status]")).toBeNull();
|
||||
});
|
||||
|
||||
// issue #9035:源码 tab 先出现再加载,加载中的等待必须有反馈。
|
||||
it("shows the loading indicator while an object source tab is still loading", async () => {
|
||||
const { root, state } = mountStatus({ mode: "query", isExecuting: false, sourceLoad: { startedAt: Date.now(), request: { name: "v_orders", objectType: "VIEW" } } }, true);
|
||||
await nextTick();
|
||||
|
||||
const status = root.querySelector<HTMLElement>("[data-tab-execution-status]");
|
||||
expect(status).not.toBeNull();
|
||||
expect(status?.getAttribute("aria-label")).toBe("common.loading");
|
||||
expect(status?.className).toContain("text-blue-600");
|
||||
// 加载态占用图标位,与查询执行中的表现一致
|
||||
expect(root.querySelector("[data-tab-icon]")).toBeNull();
|
||||
|
||||
state.sourceLoad = undefined;
|
||||
await nextTick();
|
||||
expect(root.querySelector("[data-tab-execution-status]")).toBeNull();
|
||||
expect(root.querySelector("[data-tab-icon]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stops the loading indicator once the object source load failed", async () => {
|
||||
const { root } = mountStatus({ mode: "query", isExecuting: false, sourceLoad: { startedAt: Date.now(), error: "ORA-00942", request: { name: "v_orders", objectType: "VIEW" } } }, true);
|
||||
await nextTick();
|
||||
|
||||
// 失败态由 tab 内容区的错误 + Retry 表达,tab 栏不应继续转圈
|
||||
expect(root.querySelector("[data-tab-execution-status]")).toBeNull();
|
||||
expect(root.querySelector("[data-tab-icon]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-4
@@ -10,7 +10,7 @@ const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "ut
|
||||
// assertions pin the sibling order so a future merge cannot re-introduce it.
|
||||
describe("App main content surface structure", () => {
|
||||
it("hides only the editor workspace via the surface guard, never the special pages", () => {
|
||||
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
|
||||
const guard = 'v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive"';
|
||||
// The empty-state slot shares the query surface's visibility guard.
|
||||
expect(appSource.split(guard).length - 1).toBe(1);
|
||||
|
||||
@@ -22,15 +22,17 @@ describe("App main content surface structure", () => {
|
||||
});
|
||||
|
||||
it("keeps navigation independent from the workspace welcome content", () => {
|
||||
expect(appSource).toContain(':show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"');
|
||||
expect(appSource).toContain(':show-tab-navigation="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen || pluginCenterTabOpen"');
|
||||
expect(appSource).toContain(':active-tab="activeTab ?? undefined"');
|
||||
expect(appSource).toMatch(/<template #empty>\s*<WelcomeScreen/);
|
||||
expect(appSource).not.toContain('v-if="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen"');
|
||||
expect(appSource).not.toContain('v-if="queryStore.tabs.length > 0 || settingsPageTabOpen || driverStoreTabOpen || pluginCenterTabOpen"');
|
||||
});
|
||||
|
||||
it("returns to an open special page after the final query tab closes", () => {
|
||||
expect(appSource).toContain("else if (previousId) activateOpenSpecialPageFallback();");
|
||||
expect(appSource).toMatch(/function activateOpenSpecialPageFallback\(\)[\s\S]*?settingsPageTabOpen\.value[\s\S]*?activateMainContentSurface\("settings"\)[\s\S]*?driverStoreTabOpen\.value[\s\S]*?activateMainContentSurface\("driverStore"\)/);
|
||||
expect(appSource).toMatch(
|
||||
/function activateOpenSpecialPageFallback\(\)[\s\S]*?settingsPageTabOpen\.value[\s\S]*?activateMainContentSurface\("settings"\)[\s\S]*?driverStoreTabOpen\.value[\s\S]*?activateMainContentSurface\("driverStore"\)[\s\S]*?pluginCenterTabOpen\.value[\s\S]*?activateMainContentSurface\("pluginCenter"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => {
|
||||
|
||||
@@ -9,12 +9,14 @@ import type { InjectionKey, Ref } from "vue";
|
||||
* the acting tab from the explicit tabId argument (never re-reading the global
|
||||
* active tab after an await).
|
||||
*/
|
||||
/** Open/active state of the App-level special pages (settings / driver store). */
|
||||
/** Open/active state of the App-level special pages. */
|
||||
export interface SpecialPageTabsState {
|
||||
settingsOpen: boolean;
|
||||
settingsActive: boolean;
|
||||
driverStoreOpen: boolean;
|
||||
driverStoreActive: boolean;
|
||||
pluginCenterOpen: boolean;
|
||||
pluginCenterActive: boolean;
|
||||
driverUpdateCount: number;
|
||||
}
|
||||
|
||||
@@ -24,8 +26,8 @@ export interface EditorToolbarActions {
|
||||
/** Highlights the database selector of the tab that needs a database choice. */
|
||||
databaseRequiredSignalFor(tabId: string): number;
|
||||
/** Captures the acting editor's execution snapshot before a toolbar click. */
|
||||
captureExecutionSnapshot(): void;
|
||||
toolbarExecute(source: "pointer" | "keyboard"): void;
|
||||
captureExecutionSnapshot(tabId: string): void;
|
||||
toolbarExecute(source: "pointer" | "keyboard", tabId: string): void;
|
||||
cancelExecution(tabId: string): void;
|
||||
explain(tabId: string): void;
|
||||
formatSql(tabId: string): void;
|
||||
@@ -49,6 +51,8 @@ export interface EditorToolbarActions {
|
||||
closeSettingsPage(): void;
|
||||
activateDriverStore(): void;
|
||||
closeDriverStore(): void;
|
||||
activatePluginCenter(): void;
|
||||
closePluginCenter(): void;
|
||||
}
|
||||
|
||||
export const EDITOR_TOOLBAR_ACTIONS: InjectionKey<EditorToolbarActions> = Symbol("dbx:editor-toolbar-actions");
|
||||
@@ -63,7 +67,7 @@ export function createNoopEditorToolbarActions(): EditorToolbarActions {
|
||||
const mode = { value: "explain" } as Ref<"explain" | "autotrace">;
|
||||
const flag = { value: true } as Ref<boolean>;
|
||||
const specialPageTabs = {
|
||||
value: { settingsOpen: false, settingsActive: false, driverStoreOpen: false, driverStoreActive: false, driverUpdateCount: 0 },
|
||||
value: { settingsOpen: false, settingsActive: false, driverStoreOpen: false, driverStoreActive: false, pluginCenterOpen: false, pluginCenterActive: false, driverUpdateCount: 0 },
|
||||
} as Ref<SpecialPageTabsState>;
|
||||
return {
|
||||
explainMode: mode,
|
||||
@@ -93,5 +97,7 @@ export function createNoopEditorToolbarActions(): EditorToolbarActions {
|
||||
closeSettingsPage: noop,
|
||||
activateDriverStore: noop,
|
||||
closeDriverStore: noop,
|
||||
activatePluginCenter: noop,
|
||||
closePluginCenter: noop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface ContentAreaSurfaceProps {
|
||||
* global active tab after an await (see redevelopment guide §6.1).
|
||||
*/
|
||||
export interface ContentAreaSurfaceEmits {
|
||||
closeTab: [tabId: string];
|
||||
"update:activeOutputView": [tabId: string, value: TabOutputView];
|
||||
fixWithAi: [tabId: string, errorMessage: string];
|
||||
sendSelectionToAi: [tabId: string, sql: string];
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
import { createNacosNamespaceRequestGuard, subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
||||
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosServiceDetailMatches } from "@/lib/nacos/nacosServiceManagement";
|
||||
import { loadReadableNacosNamespaces, nacosNamespaceIdentity } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { clipboardLineEndings, copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
|
||||
@@ -1188,7 +1188,8 @@ async function copyConfigIdentity() {
|
||||
await copyToClipboard(text);
|
||||
try {
|
||||
const copiedText = await readTextFromClipboard();
|
||||
if (copiedText !== text) {
|
||||
// The clipboard holds the platform-normalized text (CRLF on Windows).
|
||||
if (copiedText !== text && copiedText !== clipboardLineEndings(text)) {
|
||||
throw new Error(t("nacos.copyVerifyFailed"));
|
||||
}
|
||||
} catch (verifyError) {
|
||||
|
||||
@@ -72,7 +72,7 @@ import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, Inde
|
||||
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
|
||||
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
|
||||
import { supportsAiAssistantContext, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
|
||||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||||
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
@@ -2531,7 +2531,7 @@ async function confirmPasteTable() {
|
||||
function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boolean }): TableAdminSqlOptions {
|
||||
const result: TableAdminSqlOptions = {
|
||||
databaseType: effectiveDatabaseType.value,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
schema: connectionTableSqlSchema(props.connection, row.schema || selectedSchema.value),
|
||||
tableName: row.name,
|
||||
// Cloud Spanner's dialect decides the quote; the static per-type mapping cannot.
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||||
|
||||
@@ -87,6 +87,10 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!props.databaseType) throw new Error("Connection type is unavailable.");
|
||||
// issue #9035:连接建立也纳入弹窗的加载态。调用方不再先 await ensureConnected
|
||||
// 才打开弹窗,否则弹窗挂载前会有一段没有任何反馈的等待。
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
connectionStore.activeConnectionId = props.connectionId;
|
||||
const schema = props.schema || props.database;
|
||||
const { source: result, objectType: resolvedType } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName);
|
||||
const editableAllowed = result.editable !== false;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { alterOracleDatabaseLinkSql, createOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, testOracleDatabaseLinkSql, type OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
const props = defineProps<{ open: boolean; connectionId: string; database: string; name?: string; owner?: string }>();
|
||||
const emit = defineEmits<{ "update:open": [boolean]; changed: [] }>();
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const links = ref<OracleDatabaseLink[]>([]);
|
||||
const sessionUser = ref("");
|
||||
const selected = ref<OracleDatabaseLink>();
|
||||
const search = ref("");
|
||||
const busy = ref(false);
|
||||
const message = ref("");
|
||||
const error = ref("");
|
||||
const mode = ref<"create" | "alter" | "drop" | "">("");
|
||||
const name = ref("");
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const host = ref("");
|
||||
const publicLink = ref(false);
|
||||
const filtered = computed(() => links.value.filter((link) => `${link.name} ${link.owner} ${link.username} ${link.host}`.toLowerCase().includes(search.value.toLowerCase())));
|
||||
const selectedIsShadowed = computed(() => selected.value?.owner === "PUBLIC" && links.value.some((link) => link.owner !== "PUBLIC" && link.name.toUpperCase() === selected.value?.name.toUpperCase()));
|
||||
let revision = 0;
|
||||
watch(
|
||||
() => [props.open, props.connectionId, props.database],
|
||||
() => {
|
||||
++revision;
|
||||
mode.value = "";
|
||||
password.value = "";
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (props.open) void reload();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
async function reload() {
|
||||
const requested = ++revision;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await store.listOracleDatabaseLinks(props.connectionId, props.database);
|
||||
const identity = await api.executeQuery(props.connectionId, props.database, "SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL", undefined, undefined, { maxRows: 1, timeoutSecs: 15 });
|
||||
if (requested !== revision) return;
|
||||
sessionUser.value = String(identity.rows[0]?.[0] || "");
|
||||
links.value = result;
|
||||
selected.value = result.find((link) => link.name === (selected.value?.name || props.name) && link.owner === (selected.value?.owner || props.owner));
|
||||
} catch (e) {
|
||||
if (requested === revision) error.value = String(e);
|
||||
} finally {
|
||||
if (requested === revision) busy.value = false;
|
||||
}
|
||||
}
|
||||
function begin(next: "create" | "alter" | "drop") {
|
||||
mode.value = next;
|
||||
password.value = "";
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
if (next === "create") {
|
||||
name.value = "";
|
||||
username.value = "";
|
||||
host.value = "";
|
||||
publicLink.value = false;
|
||||
}
|
||||
}
|
||||
async function execute(sql: string) {
|
||||
if (!sessionUser.value) throw new Error("Unable to determine the Oracle session user");
|
||||
return executeWithProductionSqlGuard({ connection: store.getConfig(props.connectionId), database: props.database, sql, source: t("tree.databaseLinks"), execute: () => api.executeQuery(props.connectionId, props.database, sql, sessionUser.value, undefined, { maxRows: 1, timeoutSecs: 15 }) });
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const sql =
|
||||
mode.value === "create"
|
||||
? createOracleDatabaseLinkSql({ name: name.value.trim(), username: username.value.trim(), password: password.value, host: host.value, public: publicLink.value })
|
||||
: mode.value === "alter" && selected.value
|
||||
? alterOracleDatabaseLinkSql(selected.value, password.value)
|
||||
: mode.value === "drop" && selected.value
|
||||
? dropOracleDatabaseLinkSql(selected.value)
|
||||
: "";
|
||||
if (!sql) return;
|
||||
const result = await execute(sql);
|
||||
if (result === undefined) return;
|
||||
mode.value = "";
|
||||
password.value = "";
|
||||
message.value = t("databaseLinks.saved");
|
||||
emit("changed");
|
||||
await reload();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function test() {
|
||||
if (!selected.value || busy.value || selectedIsShadowed.value) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
try {
|
||||
const result = await execute(testOracleDatabaseLinkSql(selected.value));
|
||||
if (result === undefined) return;
|
||||
message.value = t("databaseLinks.testSuccess");
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("tree.databaseLinks") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="search" :placeholder="t('databaseLinks.search')" :aria-label="t('databaseLinks.search')" />
|
||||
<Button variant="outline" :disabled="busy" @click="reload">{{ t("common.refresh") }}</Button>
|
||||
<Button :disabled="busy" @click="begin('create')">{{ t("databaseLinks.create") }}</Button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<th class="p-2">{{ t("databaseLinks.name") }}</th>
|
||||
<th class="p-2">{{ t("databaseLinks.owner") }}</th>
|
||||
<th class="p-2">{{ t("databaseLinks.username") }}</th>
|
||||
<th class="p-2">{{ t("databaseLinks.host") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="link in filtered"
|
||||
:key="`${link.owner}:${link.name}`"
|
||||
class="cursor-pointer hover:bg-muted/60"
|
||||
:class="selected === link ? 'bg-muted' : ''"
|
||||
@click="
|
||||
selected = link;
|
||||
mode = '';
|
||||
password = '';
|
||||
"
|
||||
>
|
||||
<td class="p-2">
|
||||
<button class="text-left" @click="selected = link">{{ link.name }}</button>
|
||||
</td>
|
||||
<td class="p-2">{{ link.owner }}</td>
|
||||
<td class="p-2">{{ link.username || "—" }}</td>
|
||||
<td class="p-2 break-all">{{ link.host }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!busy && !filtered.length" class="p-4 text-sm text-muted-foreground">{{ t("databaseLinks.empty") }}</p>
|
||||
</div>
|
||||
<template v-if="selected && !mode">
|
||||
<p class="text-sm break-all">{{ selected.name }} · {{ selected.owner }} · {{ selected.created }}</p>
|
||||
<p v-if="selectedIsShadowed" class="text-sm text-muted-foreground">{{ t("databaseLinks.shadowed") }}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="busy || selectedIsShadowed" @click="test">{{ t("databaseLinks.test") }}</Button
|
||||
><Button variant="outline" :disabled="busy || !selected.username" @click="begin('alter')">{{ t("databaseLinks.alter") }}</Button
|
||||
><Button variant="destructive" :disabled="busy" @click="begin('drop')">{{ t("common.delete") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
<form v-if="mode" class="space-y-3 border-t pt-3" @submit.prevent="save">
|
||||
<template v-if="mode === 'create'">
|
||||
<label class="block text-sm">{{ t("databaseLinks.name") }}<Input v-model="name" required autocomplete="off" /></label>
|
||||
<label class="block text-sm">{{ t("databaseLinks.username") }}<Input v-model="username" required autocomplete="off" /></label>
|
||||
<label class="block text-sm">{{ t("databaseLinks.host") }}<Input v-model="host" required placeholder="//host:1521/service" autocomplete="off" /></label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="publicLink" type="checkbox" />{{ t("databaseLinks.public") }}</label>
|
||||
</template>
|
||||
<p v-if="mode === 'alter'" class="text-sm">{{ t("databaseLinks.alterHint", { name: selected?.name }) }}</p>
|
||||
<label v-if="mode !== 'drop'" class="block text-sm">{{ t("databaseLinks.password") }}<Input v-model="password" type="password" required autocomplete="new-password" /></label>
|
||||
<p v-else class="text-sm">{{ t("databaseLinks.dropConfirm", { name: selected?.name, owner: selected?.owner }) }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="
|
||||
mode = '';
|
||||
password = '';
|
||||
"
|
||||
>{{ t("common.cancel") }}</Button
|
||||
><Button type="submit" :disabled="busy" :variant="mode === 'drop' ? 'destructive' : 'default'">{{ t(mode === "drop" ? "common.delete" : "common.save") }}</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="busy" role="status" class="text-sm text-muted-foreground">{{ t("common.loading") }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive whitespace-pre-wrap break-all">{{ error }}</p>
|
||||
<p v-if="message" role="status" class="text-sm">{{ message }}</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,330 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const invokePluginMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listLocalSshKeys: vi.fn(async () => [
|
||||
{ path: "/home/dev/.ssh/id_ed25519", algorithm: "ssh-ed25519", fingerprint: "SHA256:abc", hasPassphrase: false },
|
||||
{ path: "/home/dev/.ssh/id_rsa", algorithm: "ssh-rsa", fingerprint: "", hasPassphrase: true },
|
||||
]),
|
||||
invokePlugin: (...args: unknown[]) => invokePluginMock(...args),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const contribution: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "example.connection",
|
||||
label: "Example connection",
|
||||
database_type: "example",
|
||||
fields: [
|
||||
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
],
|
||||
};
|
||||
|
||||
async function mountFields(initialValues: Record<string, PluginFormFieldValue> = {}, hiddenBindings: PluginFormFieldBinding[] = [], layout: "stacked" | "connection-dialog" = "stacked") {
|
||||
const state = reactive({ values: initialValues });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(PluginConnectionFields, {
|
||||
contribution,
|
||||
modelValue: state.values,
|
||||
hiddenBindings,
|
||||
layout,
|
||||
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
|
||||
state.values = value;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("PluginConnectionFields", () => {
|
||||
it("preserves multiline secret input and masks it again after its branch is reopened", async () => {
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "pem.connection",
|
||||
label: "PEM",
|
||||
database_type: "example",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "tls" },
|
||||
{ key: "key", label: "Private key", type: "textarea", binding: "secret", visible_when: { field: "mode", one_of: ["tls"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(provider, {});
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
const pem = "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n";
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(true);
|
||||
textarea.value = pem;
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(state.values.key).toBe(pem);
|
||||
textarea.parentElement!.querySelector<HTMLButtonElement>("button")!.click();
|
||||
await nextTick();
|
||||
expect(textarea.classList.contains("secret-masked")).toBe(false);
|
||||
expect(textarea.value).toBe(pem);
|
||||
state.values = { ...state.values, mode: "plain" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#pem-connection-key")).toBeNull();
|
||||
state.values = { ...state.values, mode: "tls" };
|
||||
await nextTick();
|
||||
const reopened = document.querySelector<HTMLTextAreaElement>("#pem-connection-key")!;
|
||||
expect(reopened.value).toBe(pem);
|
||||
expect(reopened.classList.contains("secret-masked")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders declared fields and emits immutable model updates", async () => {
|
||||
const state = await mountFields({ password: "secret" });
|
||||
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
|
||||
const passwordInput = document.querySelector<HTMLInputElement>("#example-connection-password");
|
||||
|
||||
expect(hostInput?.placeholder).toBe("localhost");
|
||||
expect(passwordInput?.value).toBe("secret");
|
||||
|
||||
if (!hostInput) throw new Error("host input not mounted");
|
||||
hostInput.value = "db.internal";
|
||||
hostInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.values).toEqual({ password: "secret", host: "db.internal" });
|
||||
});
|
||||
|
||||
it("can hide host-owned common bindings", async () => {
|
||||
contribution.fields[0].binding = "host";
|
||||
await mountFields({}, ["host"]);
|
||||
expect(document.querySelector("#example-connection-host")).toBeNull();
|
||||
expect(document.querySelector("#example-connection-password")).not.toBeNull();
|
||||
contribution.fields[0].binding = undefined;
|
||||
});
|
||||
|
||||
it("uses the native four-column connection layout without a nested card", async () => {
|
||||
await mountFields({}, [], "connection-dialog");
|
||||
|
||||
const root = document.querySelector(".contents");
|
||||
const hostInput = document.querySelector("#example-connection-host");
|
||||
const fieldRow = hostInput?.closest(".grid");
|
||||
|
||||
expect(root).not.toBeNull();
|
||||
expect(root?.textContent).not.toContain("Example connection");
|
||||
expect(fieldRow?.classList.contains("grid-cols-4")).toBe(true);
|
||||
expect(document.querySelector(".rounded-lg.border")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides conditional fields until visible_when matches and marks required_when", async () => {
|
||||
const conditional: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "cond.connection",
|
||||
label: "Conditional",
|
||||
database_type: "cond",
|
||||
fields: [
|
||||
{ key: "mode", label: "Mode", type: "text", default: "password" },
|
||||
{ key: "private_key_path", label: "Private key", type: "text", visible_when: { field: "mode", one_of: ["key"] }, required_when: { field: "mode", one_of: ["key"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(conditional, {});
|
||||
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
|
||||
state.values = { mode: "key" };
|
||||
await nextTick();
|
||||
|
||||
const keyInput = document.querySelector<HTMLInputElement>("#cond-connection-private_key_path");
|
||||
expect(keyInput).not.toBeNull();
|
||||
const label = document.querySelector('label[for="cond-connection-private_key_path"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
state.values = { mode: "password" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#cond-connection-private_key_path")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps grandchild fields hidden while an intermediate default-only value would match (kafka msk_region regression)", async () => {
|
||||
// sasl_mechanism (PLAIN default, hidden without SASL) → oauth_token_source
|
||||
// (msk_iam default) → msk_region: the nested default must not surface an
|
||||
// always-required AWS region on a plain PLAINTEXT/SCRAM connection.
|
||||
const kafka: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "kafka.connection",
|
||||
label: "Kafka",
|
||||
database_type: "kafka",
|
||||
fields: [
|
||||
{ key: "bootstrap_servers", label: "Bootstrap servers", type: "textarea", required: true },
|
||||
{
|
||||
key: "security_protocol",
|
||||
label: "Security protocol",
|
||||
type: "select",
|
||||
default: "PLAINTEXT",
|
||||
options: [
|
||||
{ label: "PLAINTEXT", value: "PLAINTEXT" },
|
||||
{ label: "SASL_SSL", value: "SASL_SSL" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sasl_mechanism",
|
||||
label: "SASL mechanism",
|
||||
type: "select",
|
||||
default: "PLAIN",
|
||||
options: [
|
||||
{ label: "PLAIN", value: "PLAIN" },
|
||||
{ label: "OAUTHBEARER", value: "OAUTHBEARER" },
|
||||
],
|
||||
visible_when: { field: "security_protocol", one_of: ["SASL_PLAINTEXT", "SASL_SSL"] },
|
||||
},
|
||||
{
|
||||
key: "oauth_token_source",
|
||||
label: "OAuth token source",
|
||||
type: "select",
|
||||
default: "msk_iam",
|
||||
options: [
|
||||
{ label: "MSK IAM", value: "msk_iam" },
|
||||
{ label: "Static token", value: "static_token" },
|
||||
],
|
||||
visible_when: { field: "sasl_mechanism", one_of: ["OAUTHBEARER"] },
|
||||
},
|
||||
{ key: "msk_region", label: "MSK AWS region", type: "text", visible_when: { field: "oauth_token_source", one_of: ["msk_iam"] }, required_when: { field: "oauth_token_source", one_of: ["msk_iam"] } },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(kafka, {}, "io.dbx.kafka");
|
||||
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
|
||||
state.values = { security_protocol: "SASL_SSL", sasl_mechanism: "OAUTHBEARER" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).not.toBeNull();
|
||||
const label = document.querySelector('label[for="kafka-connection-msk_region"]');
|
||||
expect(label?.textContent).toContain("*");
|
||||
|
||||
// Selecting the static token source hides the region field again.
|
||||
state.values = { sasl_mechanism: "OAUTHBEARER", oauth_token_source: "static_token" };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
|
||||
const withKey: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "keys.connection",
|
||||
label: "Keys",
|
||||
database_type: "keys",
|
||||
fields: [{ key: "private_key_path", label: "Private key", type: "text" }],
|
||||
};
|
||||
const state = await mountContribution(withKey, {});
|
||||
await flushAsync();
|
||||
|
||||
const select = document.querySelector<HTMLSelectElement>("select");
|
||||
expect(select).not.toBeNull();
|
||||
expect(select?.disabled).toBe(false);
|
||||
const encryptedOption = select?.querySelector<HTMLOptionElement>('option[value="/home/dev/.ssh/id_rsa"]');
|
||||
expect(encryptedOption?.textContent).toContain("id_rsa");
|
||||
expect(encryptedOption?.textContent).toContain("ssh-rsa");
|
||||
expect(encryptedOption?.textContent).toMatch(/encrypted|已加密/);
|
||||
|
||||
select!.value = "/home/dev/.ssh/id_rsa";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.private_key_path).toBe("/home/dev/.ssh/id_rsa");
|
||||
});
|
||||
|
||||
it("renders options_action fields as a dynamic select and keeps a stored value visible", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "dyn.connection",
|
||||
label: "Dyn",
|
||||
database_type: "dyn",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockResolvedValueOnce({
|
||||
options: [
|
||||
{ value: "p1", label: "Ops" },
|
||||
{ value: "p2", label: "QA" },
|
||||
],
|
||||
});
|
||||
// "ghost" is stored but no longer offered: the dropdown must keep it visible.
|
||||
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
|
||||
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
|
||||
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.textContent).toContain("ghost");
|
||||
});
|
||||
|
||||
it("falls back to the text input when the options_action call fails or returns nothing", async () => {
|
||||
const dynamic: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "degrade.connection",
|
||||
label: "Degrade",
|
||||
database_type: "degrade",
|
||||
fields: [{ key: "sudo_profile", label: "Profile", type: "text", options_action: "sudo/profiles/options" }],
|
||||
};
|
||||
invokePluginMock.mockRejectedValueOnce(new Error("method not registered"));
|
||||
await mountContribution(dynamic, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>("input#degrade-connection-sudo_profile");
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.value).toBe("");
|
||||
|
||||
// An empty option list degrades the same way.
|
||||
invokePluginMock.mockResolvedValueOnce({ options: [] });
|
||||
const empty = { ...dynamic, id: "empty.connection", label: "Empty" };
|
||||
await mountContribution(empty, {}, "io.dbx.ssh");
|
||||
await flushAsync();
|
||||
expect(document.querySelector("input#empty-connection-sudo_profile")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function flushAsync() {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function mountContribution(contribution: PluginConnectionProviderContribution, initialValues: Record<string, PluginFormFieldValue>, pluginId?: string) {
|
||||
const state = reactive({ values: initialValues });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(PluginConnectionFields, {
|
||||
contribution,
|
||||
modelValue: state.values,
|
||||
pluginId,
|
||||
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
|
||||
state.values = value;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
|
||||
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
|
||||
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
contribution: PluginConnectionProviderContribution;
|
||||
modelValue: Record<string, PluginFormFieldValue>;
|
||||
hiddenBindings?: PluginFormFieldBinding[];
|
||||
layout?: "stacked" | "connection-dialog";
|
||||
pluginId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
/** Value of any sibling field by key (condition evaluation reads current form values). */
|
||||
function readFieldValue(key: string): PluginFormFieldValue {
|
||||
const field = props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
return field ? (formValues.value[key] ?? field.default) : undefined;
|
||||
}
|
||||
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
function fieldVisible(field: PluginFormField): boolean {
|
||||
if (field.binding && props.hiddenBindings?.includes(field.binding)) return false;
|
||||
return pluginFieldIsVisible(field, readFieldValue, resolveSiblingField);
|
||||
}
|
||||
function fieldRequired(field: PluginFormField): boolean {
|
||||
return pluginFieldIsRequired(field, readFieldValue);
|
||||
}
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => fieldVisible(field)));
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
return formValues.value[field.key] ?? field.default ?? defaultValueFor(field);
|
||||
}
|
||||
|
||||
function updateField(field: PluginFormField, value: PluginFormFieldValue) {
|
||||
emit("update:modelValue", { ...formValues.value, [field.key]: value });
|
||||
}
|
||||
|
||||
function updateTextField(field: PluginFormField, value: string | number) {
|
||||
if (field.type === "number") {
|
||||
updateField(field, value === "" ? undefined : Number(value));
|
||||
return;
|
||||
}
|
||||
updateField(field, String(value));
|
||||
}
|
||||
|
||||
function updateBooleanField(field: PluginFormField, value: boolean) {
|
||||
updateField(field, value);
|
||||
}
|
||||
|
||||
function updateSelectField(field: PluginFormField, value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
updateField(field, undefined);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") updateField(field, value);
|
||||
}
|
||||
|
||||
function fieldId(field: PluginFormField): string {
|
||||
return `${props.contribution.id}-${field.key}`.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
if (field.type === "boolean") return false;
|
||||
if (field.type === "number") return undefined;
|
||||
return "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic option lists (fields declaring `options_action`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A field may point `options_action` at a plugin method returning
|
||||
// `{ options: [{ value, label }] }`. The host fetches it once per dialog and
|
||||
// renders the field as a select; a missing plugin id, a failed call, or an
|
||||
// empty result falls back to the declared text input (older hosts without
|
||||
// this extension never fetch and simply keep the text field).
|
||||
const dynamicOptions = ref<Record<string, PluginFormFieldOption[]>>({});
|
||||
const dynamicOptionsRequested = ref(new Set<string>());
|
||||
|
||||
const optionsActionFields = computed(() => visibleFields.value.filter((field) => field.options_action));
|
||||
|
||||
watch(
|
||||
optionsActionFields,
|
||||
(fields) => {
|
||||
for (const field of fields) {
|
||||
const action = field.options_action;
|
||||
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
|
||||
dynamicOptionsRequested.value.add(field.key);
|
||||
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
|
||||
.then((result) => {
|
||||
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
|
||||
})
|
||||
.catch(() => {
|
||||
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: [] };
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function selectOptionsFor(field: PluginFormField): PluginFormFieldOption[] | null {
|
||||
if (!field.options_action) return null;
|
||||
const options = dynamicOptions.value[field.key];
|
||||
if (!options || options.length === 0) return null;
|
||||
// Keep a stored value visible even when its profile disappeared so the
|
||||
// dialog does not silently look "unset" on reopen.
|
||||
const current = fieldValue(field);
|
||||
if (current !== undefined && current !== "" && !options.some((option) => String(option.value) === String(current))) {
|
||||
return [{ value: String(current), label: String(current) }, ...options];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local SSH key suggestions (Host API: private_key_path convenience picker)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOCAL_SSH_KEY_FIELD = "private_key_path";
|
||||
|
||||
function isLocalSshKeyField(field: PluginFormField): boolean {
|
||||
return field.key === LOCAL_SSH_KEY_FIELD && (field.type === "text" || field.type === "password");
|
||||
}
|
||||
|
||||
const hasLocalSshKeyField = computed(() => visibleFields.value.some((field) => isLocalSshKeyField(field)));
|
||||
|
||||
const localSshKeys = ref<LocalSshKey[]>([]);
|
||||
const localSshKeysRequested = ref(false);
|
||||
|
||||
async function loadLocalSshKeys() {
|
||||
if (localSshKeysRequested.value) return;
|
||||
localSshKeysRequested.value = true;
|
||||
try {
|
||||
localSshKeys.value = await listLocalSshKeys();
|
||||
} catch {
|
||||
localSshKeys.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
hasLocalSshKeyField,
|
||||
(present) => {
|
||||
if (present) void loadLocalSshKeys();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function localSshKeyFile(path: string): string {
|
||||
return path.replaceAll("\\", "/").split("/").pop() || path;
|
||||
}
|
||||
|
||||
function localSshKeyLabel(key: LocalSshKey): string {
|
||||
const algorithm = key.algorithm || "?";
|
||||
return key.hasPassphrase ? `${localSshKeyFile(key.path)} · ${algorithm} · ${t("connection.pluginSshKeyEncrypted")}` : `${localSshKeyFile(key.path)} · ${algorithm}`;
|
||||
}
|
||||
|
||||
function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const selected = localSshKeys.value.find((key) => key.path === select.value);
|
||||
select.value = "";
|
||||
if (!selected) return;
|
||||
updateField(field, selected.path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isConnectionDialogLayout ? 'contents' : 'space-y-4'">
|
||||
<div v-if="!isConnectionDialogLayout && (contribution.label || contribution.description)" class="space-y-1">
|
||||
<div v-if="contribution.label" class="text-sm font-medium">{{ contribution.label }}</div>
|
||||
<div v-if="contribution.description" class="text-xs text-muted-foreground">{{ contribution.description }}</div>
|
||||
</div>
|
||||
<div v-else-if="isConnectionDialogLayout && contribution.description" class="col-span-full grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ contribution.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
|
||||
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
|
||||
{{ field.label }}
|
||||
<span v-if="fieldRequired(field)" class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
|
||||
<template v-if="field.type === 'text' || field.type === 'number'">
|
||||
<Select v-if="field.type === 'text' && selectOptionsFor(field)" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
|
||||
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
|
||||
<SelectValue :placeholder="field.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in selectOptionsFor(field)!" :key="String(option.value)" :value="String(option.value)">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<select
|
||||
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="localSshKeys.length === 0"
|
||||
:title="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
:aria-label="t('connection.pluginSshKeyPickerPlaceholder')"
|
||||
@change="applyLocalSshKey(field, $event)"
|
||||
>
|
||||
<option value="" disabled selected>{{ localSshKeys.length ? t("connection.pluginSshKeyPickerPlaceholder") : t("connection.pluginSshKeyPickerEmpty") }}</option>
|
||||
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
</template>
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="fieldId(field)"
|
||||
:value="String(fieldValue(field) ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-2.5 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
@input="updateField(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<Select v-else-if="field.type === 'select'" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
|
||||
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
|
||||
<SelectValue :placeholder="field.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in field.options || []" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
<div v-if="field.description" class="text-[11px] leading-5 text-muted-foreground">{{ field.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,875 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, LayoutGrid, List, Loader2, PackageCheck, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
|
||||
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
|
||||
import { buildMarketplacePluginListings, filterMarketplacePluginListings, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { InstalledPlugin, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
focusTarget?: PluginCenterFocus | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
newConnection: [pluginId: string, providerId: string];
|
||||
}>();
|
||||
|
||||
// lucide no longer ships brand icons; mirror the inline glyph used in AppToolbar.
|
||||
const GithubIcon = {
|
||||
render() {
|
||||
return h("svg", { class: "size-3", viewBox: "0 0 24 24", fill: "currentColor" }, [
|
||||
h("path", {
|
||||
d: "M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
|
||||
|
||||
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const activeSection = ref<"marketplace" | "installed" | "settings">("marketplace");
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const trustedKeys = ref<PluginTrustedKey[]>([]);
|
||||
const repositories = ref<PluginRepository[]>([]);
|
||||
const catalogResults = ref<PluginRepositoryCatalogResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const marketplaceLoading = ref(false);
|
||||
const installing = ref(false);
|
||||
const marketplaceInstallingKey = ref("");
|
||||
const operating = ref(false);
|
||||
const error = ref("");
|
||||
const selectedPluginId = ref("");
|
||||
const selectedContributionId = ref("");
|
||||
const selectedConnectionId = ref("");
|
||||
const allowUnsigned = ref(
|
||||
((): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const trustedKeyId = ref("");
|
||||
const trustedPublicKey = ref("");
|
||||
const repositoryId = ref("");
|
||||
const repositoryName = ref("");
|
||||
const repositoryCatalogUrl = ref("");
|
||||
const marketplaceQuery = ref("");
|
||||
const marketplaceRepositoryId = ref("all");
|
||||
const marketplaceViewMode = ref<"grid" | "list">("grid");
|
||||
const webFileInput = ref<HTMLInputElement | null>(null);
|
||||
const panelRootRef = ref<HTMLElement | null>(null);
|
||||
const draggingPackage = ref(false);
|
||||
let webDragDepth = 0;
|
||||
|
||||
const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
const definitions = computed(() => registry.value.listPlugins());
|
||||
const connectionProviders = computed(() => registry.value.listConnectionProviders());
|
||||
const selectedEntry = computed(() => connectionProviders.value.find((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value) || null);
|
||||
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
|
||||
const selectedWorkbenches = computed(() => registry.value.listWorkbenches().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const selectedFilesystems = computed(() => registry.value.listFilesystemProviders().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const providerConnections = computed(() => {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return [];
|
||||
return connectionStore.connections.filter((connection) => connection.db_type === "plugin" && connection.plugin_id === entry.plugin.manifest.id && connection.plugin_connection_provider === entry.contribution.id);
|
||||
});
|
||||
const selectedConnection = computed(() => providerConnections.value.find((connection) => connection.id === selectedConnectionId.value));
|
||||
const marketplaceListings = computed(() => buildMarketplacePluginListings(catalogResults.value, installedPlugins.value, appLocale.value));
|
||||
const filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value));
|
||||
const catalogErrors = computed(() => catalogResults.value.filter((result) => result.error));
|
||||
const customRepositories = computed(() => repositories.value.filter((repository) => !repository.managed));
|
||||
const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0);
|
||||
|
||||
function openExternal(url?: string) {
|
||||
const target = url?.trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
const parsed = new URL(target);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (isTauriRuntime()) void import("@tauri-apps/plugin-shell").then(({ open }) => open(target));
|
||||
else window.open(target, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function refresh(preferredPluginId = props.focusTarget?.pluginId || selectedPluginId.value) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
[installedPlugins.value, trustedKeys.value, repositories.value] = await Promise.all([api.listPlugins(), api.listPluginTrustedKeys(), api.listPluginRepositories()]);
|
||||
await refreshMarketplace();
|
||||
if (props.focusTarget) applyFocusTarget(props.focusTarget);
|
||||
else selectFirstProvider(preferredPluginId);
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMarketplace() {
|
||||
marketplaceLoading.value = true;
|
||||
try {
|
||||
catalogResults.value = await api.fetchPluginMarketplaceCatalogs();
|
||||
} catch (cause) {
|
||||
catalogResults.value = [];
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
marketplaceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFocusTarget(focus: PluginCenterFocus) {
|
||||
activeSection.value = "installed";
|
||||
if (!focus.pluginId) return selectFirstProvider();
|
||||
const provider = connectionProviders.value.find((entry) => entry.plugin.manifest.id === focus.pluginId && (!focus.providerId || entry.contribution.id === focus.providerId));
|
||||
if (!provider) {
|
||||
selectPlugin(focus.pluginId);
|
||||
return;
|
||||
}
|
||||
selectProvider(focus.pluginId, provider.contribution.id);
|
||||
}
|
||||
|
||||
async function installMarketplaceListing(listing: MarketplacePluginListing) {
|
||||
if (!listing.artifact || listing.status === "installed") return;
|
||||
marketplaceInstallingKey.value = listing.key;
|
||||
try {
|
||||
const result = await api.installMarketplacePlugin({
|
||||
repositoryId: listing.repository.id,
|
||||
pluginId: listing.plugin.id,
|
||||
version: listing.plugin.latestVersion,
|
||||
});
|
||||
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 8000);
|
||||
} finally {
|
||||
marketplaceInstallingKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepository() {
|
||||
const id = repositoryId.value.trim();
|
||||
const name = repositoryName.value.trim();
|
||||
const catalogUrl = repositoryCatalogUrl.value.trim();
|
||||
if (!id || !name || !catalogUrl) return toast(t("pluginPlatform.repositoryFieldsRequired"));
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.savePluginRepository({ id, name, catalogUrl, kind: "custom", enabled: true, managed: false });
|
||||
repositoryId.value = "";
|
||||
repositoryName.value = "";
|
||||
repositoryCatalogUrl.value = "";
|
||||
toast(t("pluginPlatform.repositorySaved", { name }));
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRepository(repository: PluginRepository) {
|
||||
if (repository.managed) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.savePluginRepository({ ...repository, enabled: !repository.enabled });
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRepository(repository: PluginRepository) {
|
||||
if (repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.removePluginRepository(repository.id);
|
||||
if (marketplaceRepositoryId.value === repository.id) marketplaceRepositoryId.value = "all";
|
||||
toast(t("pluginPlatform.repositoryRemoved", { name: repository.name }));
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrustedKey() {
|
||||
const keyId = trustedKeyId.value.trim();
|
||||
const publicKey = trustedPublicKey.value.trim();
|
||||
if (!keyId || !publicKey) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
trustedKeys.value = await api.savePluginTrustedKey(keyId, publicKey);
|
||||
trustedKeyId.value = "";
|
||||
trustedPublicKey.value = "";
|
||||
toast(t("pluginPlatform.repositoryKeyAdded", { keyId }));
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTrustedKey(keyId: string) {
|
||||
if (!window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
trustedKeys.value = await api.removePluginTrustedKey(keyId);
|
||||
toast(t("pluginPlatform.repositoryKeyRemoved", { keyId }));
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function abbreviatedPublicKey(publicKey: string): string {
|
||||
return publicKey.length <= 24 ? publicKey : `${publicKey.slice(0, 12)}…${publicKey.slice(-8)}`;
|
||||
}
|
||||
|
||||
function selectFirstProvider(preferredPluginId = "") {
|
||||
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === preferredPluginId) || connectionProviders.value[0];
|
||||
if (first) return selectProvider(first.plugin.manifest.id, first.contribution.id);
|
||||
selectedPluginId.value = definitions.value.find((definition) => definition.plugin.manifest.id === preferredPluginId)?.plugin.manifest.id || definitions.value[0]?.plugin.manifest.id || "";
|
||||
selectedContributionId.value = "";
|
||||
selectedConnectionId.value = "";
|
||||
}
|
||||
|
||||
function selectProvider(pluginId: string, contributionId: string) {
|
||||
selectedPluginId.value = pluginId;
|
||||
selectedContributionId.value = contributionId;
|
||||
const existing = connectionStore.connections.find((connection) => connection.db_type === "plugin" && connection.plugin_id === pluginId && connection.plugin_connection_provider === contributionId);
|
||||
selectedConnectionId.value = existing?.id || "";
|
||||
}
|
||||
|
||||
function selectPlugin(pluginId: string) {
|
||||
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId);
|
||||
if (first) return selectProvider(pluginId, first.contribution.id);
|
||||
selectedPluginId.value = pluginId;
|
||||
selectedContributionId.value = "";
|
||||
selectedConnectionId.value = "";
|
||||
}
|
||||
|
||||
function selectConnection(connectionId: string) {
|
||||
selectedConnectionId.value = connectionId;
|
||||
}
|
||||
|
||||
function editConnection(connectionId: string) {
|
||||
connectionStore.startEditing(connectionId);
|
||||
}
|
||||
|
||||
function createConnection() {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return toast(t("pluginPlatform.selectConnectionProvider"));
|
||||
emit("newConnection", entry.plugin.manifest.id, entry.contribution.id);
|
||||
}
|
||||
|
||||
async function openFilesystem(pluginId: string, providerId: string, label: string, rootUri?: string) {
|
||||
const connection = selectedConnection.value;
|
||||
try {
|
||||
if (connection) await connectionStore.ensureConnected(connection.id);
|
||||
queryStore.openPluginFilesystem(pluginId, providerId, {
|
||||
title: connection?.name || label,
|
||||
connectionId: connection?.id,
|
||||
rootUri,
|
||||
});
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, label: string) {
|
||||
const connection = selectedConnection.value;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: connection?.name || label,
|
||||
connectionId: connection?.id,
|
||||
context: connection
|
||||
? {
|
||||
connectionId: connection.id,
|
||||
providerId: connection.plugin_connection_provider,
|
||||
connectionType: connection.plugin_connection_type,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function choosePluginPackage() {
|
||||
if (!isTauriRuntime()) {
|
||||
webFileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await open({ multiple: false, filters: [{ name: t("pluginPlatform.packageFileType"), extensions: ["dbxp"] }] });
|
||||
if (typeof path === "string") await installPlugin(path);
|
||||
}
|
||||
|
||||
async function handleWebPackage(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (file) await installPlugin(file);
|
||||
}
|
||||
|
||||
async function installPlugin(source: string | File) {
|
||||
installing.value = true;
|
||||
try {
|
||||
const result = await api.installPluginPackage(source, allowUnsigned.value);
|
||||
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
|
||||
clearPluginIconCache();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
activeSection.value = "installed";
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 8000);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPluginPackagePath(path: string): boolean {
|
||||
return /\.dbxp$/i.test(path);
|
||||
}
|
||||
|
||||
function webDropPluginPackage(event: DragEvent): File | null {
|
||||
const files = event.dataTransfer?.files;
|
||||
if (!files) return null;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (isPluginPackagePath(files[i].name)) return files[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onWebDragEnter(event: DragEvent) {
|
||||
if (!webDropPluginPackage(event)) return;
|
||||
event.preventDefault();
|
||||
webDragDepth++;
|
||||
draggingPackage.value = true;
|
||||
}
|
||||
|
||||
function onWebDragOver(event: DragEvent) {
|
||||
if (!webDropPluginPackage(event)) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onWebDragLeave() {
|
||||
if (webDragDepth > 0 && --webDragDepth === 0) draggingPackage.value = false;
|
||||
}
|
||||
|
||||
function onWebDrop(event: DragEvent) {
|
||||
const claimed = draggingPackage.value;
|
||||
webDragDepth = 0;
|
||||
draggingPackage.value = false;
|
||||
const file = webDropPluginPackage(event);
|
||||
if (!file) {
|
||||
if (claimed) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (installing.value) return;
|
||||
void installPlugin(file);
|
||||
}
|
||||
|
||||
function dropInsidePanel(payload: Exclude<TauriFileDropPayload, { type: "leave" }>): boolean {
|
||||
const root = panelRootRef.value;
|
||||
if (!root) return false;
|
||||
return physicalDropPositionInsideRect(payload.position, root.getBoundingClientRect(), window.devicePixelRatio);
|
||||
}
|
||||
|
||||
function onTauriPluginDrop(event: Event) {
|
||||
const routedEvent = event as CustomEvent<TauriFileDropPayload>;
|
||||
const payload = routedEvent.detail;
|
||||
if (!payload) return;
|
||||
if (payload.type === "leave") {
|
||||
draggingPackage.value = false;
|
||||
return;
|
||||
}
|
||||
const inside = dropInsidePanel(payload);
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
const relevant = payload.type === "enter" ? inside && payload.paths.some(isPluginPackagePath) : inside;
|
||||
if (relevant) routedEvent.preventDefault();
|
||||
draggingPackage.value = relevant;
|
||||
return;
|
||||
}
|
||||
draggingPackage.value = false;
|
||||
const path = payload.paths.find(isPluginPackagePath);
|
||||
if (!inside || !path || installing.value) return;
|
||||
routedEvent.preventDefault();
|
||||
void installPlugin(path);
|
||||
}
|
||||
|
||||
async function rollbackSelectedPlugin() {
|
||||
if (!selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
const result = await api.rollbackPlugin(selectedPluginId.value);
|
||||
toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version }));
|
||||
clearPluginIconCache();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallSelectedPlugin() {
|
||||
const definition = selectedDefinition.value;
|
||||
if (!definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
|
||||
clearPluginIconCache();
|
||||
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
|
||||
selectFirstProvider();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(connectionProviders, (providers) => {
|
||||
if (!providers.some((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value)) selectFirstProvider(selectedPluginId.value);
|
||||
});
|
||||
watch(providerConnections, (connections) => {
|
||||
if (selectedConnectionId.value && connections.some((connection) => connection.id === selectedConnectionId.value)) return;
|
||||
selectedConnectionId.value = connections[0]?.id || "";
|
||||
});
|
||||
watch(
|
||||
() => props.focusTarget,
|
||||
(focus) => {
|
||||
if (focus && installedPlugins.value.length) applyFocusTarget(focus);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
watch(allowUnsigned, (value) => {
|
||||
try {
|
||||
localStorage.setItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
// storage unavailable (private mode); the flag stays session-only
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="panelRootRef" class="plugin-center-view relative mx-auto flex h-full w-full max-w-6xl flex-col gap-4 overflow-hidden px-6 py-6" @dragenter="onWebDragEnter" @dragover="onWebDragOver" @dragleave="onWebDragLeave" @drop="onWebDrop">
|
||||
<input ref="webFileInput" type="file" accept=".dbxp" class="hidden" @change="handleWebPackage" />
|
||||
<div v-if="error" class="shrink-0 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<Tabs v-model="activeSection" class="min-h-0 flex-1 gap-3">
|
||||
<TabsList class="grid h-9 w-full grid-cols-3">
|
||||
<TabsTrigger value="marketplace" class="gap-1.5 text-xs"><Store class="size-3.5" />{{ t("pluginPlatform.marketplace") }}</TabsTrigger>
|
||||
<TabsTrigger value="installed" class="gap-1.5 text-xs"><PackageCheck class="size-3.5" />{{ t("pluginPlatform.installed") }}</TabsTrigger>
|
||||
<TabsTrigger value="settings" class="gap-1.5 text-xs"><Settings2 class="size-3.5" />{{ t("pluginPlatform.settings") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="marketplace" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="flex min-h-full w-full flex-col gap-4 pb-2">
|
||||
<div class="flex w-full flex-col gap-2 rounded-xl border bg-card/70 p-3 sm:flex-row sm:items-center">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
|
||||
<Select v-model="marketplaceRepositoryId">
|
||||
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-52 sm:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t("pluginPlatform.allRepositories") }}</SelectItem>
|
||||
<SelectItem v-for="repository in repositories.filter((entry) => entry.enabled)" :key="repository.id" :value="repository.id">{{ repository.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex shrink-0 items-center rounded-md border bg-muted/20 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
||||
:class="marketplaceViewMode === 'grid' ? 'bg-background text-foreground shadow-sm' : ''"
|
||||
:aria-label="t('structure.viewGrid')"
|
||||
:aria-pressed="marketplaceViewMode === 'grid'"
|
||||
@click="marketplaceViewMode = 'grid'"
|
||||
>
|
||||
<LayoutGrid class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
||||
:class="marketplaceViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : ''"
|
||||
:aria-label="t('structure.viewList')"
|
||||
:aria-pressed="marketplaceViewMode === 'list'"
|
||||
@click="marketplaceViewMode = 'list'"
|
||||
>
|
||||
<List class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" class="shrink-0" :disabled="marketplaceLoading" :title="t('common.refresh')" :aria-label="t('common.refresh')" @click="refreshMarketplace"><RefreshCw class="size-3.5" :class="marketplaceLoading ? 'animate-spin' : ''" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="result in catalogErrors" :key="result.repository.id" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-medium">{{ result.repository.name }}:</span> {{ result.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceLoading" class="flex min-h-[440px] flex-1 items-center justify-center gap-2 rounded-lg border border-dashed p-12 text-xs text-muted-foreground"><Loader2 class="size-4 animate-spin" />{{ t("pluginPlatform.loadingMarketplace") }}</div>
|
||||
<div v-else-if="!filteredMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<Store class="size-7 text-muted-foreground" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noMarketplacePlugins") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
|
||||
</div>
|
||||
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
|
||||
<div class="flex items-start gap-3">
|
||||
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-11 rounded-xl border bg-background p-1.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
|
||||
<Badge v-if="listing.verified" variant="secondary" class="h-5 gap-1 px-1.5 text-[10px]"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
|
||||
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
<p class="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-auto flex items-end justify-between gap-3 pt-4">
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
<span v-if="listing.status === 'unsupported'">{{ t("pluginPlatform.unsupportedTarget", { target: listing.target }) }}</span>
|
||||
<span v-else-if="listing.installed">{{ t("pluginPlatform.installedVersion", { version: listing.installed.manifest.version }) }}</span>
|
||||
<span v-else>{{ listing.plugin.license || t("pluginPlatform.licenseUnknown") }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
|
||||
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
|
||||
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
|
||||
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
|
||||
<Download v-else class="size-3.5" />
|
||||
{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col gap-2">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
|
||||
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-10 rounded-lg border bg-background p-1.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
|
||||
<Badge v-if="listing.verified" variant="secondary" class="hidden h-5 shrink-0 gap-1 px-1.5 text-[10px] sm:inline-flex"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
|
||||
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
|
||||
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
|
||||
</div>
|
||||
<div class="hidden max-w-52 shrink-0 gap-1.5 lg:flex">
|
||||
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
|
||||
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
|
||||
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
|
||||
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
|
||||
<Download v-else class="size-3.5" />
|
||||
<span class="hidden sm:inline">{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="installed" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div v-if="loading && !installedPlugins.length" class="py-10 text-center text-xs text-muted-foreground">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="!installedPlugins.length" class="rounded-lg border border-dashed p-10 text-center">
|
||||
<PackageCheck class="mx-auto size-7 text-muted-foreground" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noInstalledPlugins") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noInstalledPluginsDescription") }}</div>
|
||||
<Button class="mt-4 gap-1.5" size="sm" @click="activeSection = 'marketplace'"><Store class="size-3.5" />{{ t("pluginPlatform.browseMarketplace") }}</Button>
|
||||
</div>
|
||||
<div v-else class="grid min-h-[440px] gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="space-y-1 rounded-lg border bg-muted/10 p-2">
|
||||
<button
|
||||
v-for="definition in definitions"
|
||||
:key="definition.plugin.manifest.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-muted"
|
||||
:class="selectedPluginId === definition.plugin.manifest.id ? 'bg-muted ring-1 ring-primary/30' : ''"
|
||||
@click="selectPlugin(definition.plugin.manifest.id)"
|
||||
>
|
||||
<Check v-if="selectedPluginId === definition.plugin.manifest.id" class="mt-0.5 size-3.5 shrink-0 text-primary" /><span v-else class="mt-0.5 size-3.5 shrink-0" />
|
||||
<PluginIcon :plugin-id="definition.plugin.manifest.id" :icon="definition.plugin.manifest.icon" class="size-8 rounded-md border bg-background p-1" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
|
||||
<span class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
|
||||
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border p-4">
|
||||
<template v-if="selectedDefinition">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b pb-4">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<PluginIcon :plugin-id="selectedDefinition.plugin.manifest.id" :icon="selectedDefinition.plugin.manifest.icon" class="size-10 rounded-lg border bg-background p-1.5" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ selectedDefinition.plugin.manifest.name }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ selectedDefinition.plugin.manifest.description }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-mono text-[10px] text-muted-foreground">{{ selectedDefinition.plugin.manifest.id }}</span>
|
||||
<button
|
||||
v-if="selectedDefinition.plugin.manifest.source"
|
||||
type="button"
|
||||
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
|
||||
:title="t('pluginPlatform.sourceRepository')"
|
||||
:aria-label="t('pluginPlatform.sourceRepository')"
|
||||
@click="openExternal(selectedDefinition.plugin.manifest.source)"
|
||||
>
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedDefinition.plugin.manifest.homepage"
|
||||
type="button"
|
||||
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
|
||||
:title="t('pluginPlatform.pluginHomepage')"
|
||||
:aria-label="t('pluginPlatform.pluginHomepage')"
|
||||
@click="openExternal(selectedDefinition.plugin.manifest.homepage)"
|
||||
>
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="outline" class="gap-1.5" :disabled="operating" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
|
||||
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="operating" @click="uninstallSelectedPlugin"><Trash2 class="size-3.5" />{{ t("pluginPlatform.uninstall") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="connectionProviders.some((entry) => entry.plugin.manifest.id === selectedPluginId)" class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="entry in connectionProviders.filter((candidate) => candidate.plugin.manifest.id === selectedPluginId)"
|
||||
:key="entry.contribution.id"
|
||||
size="sm"
|
||||
:variant="selectedContributionId === entry.contribution.id ? 'secondary' : 'outline'"
|
||||
@click="selectProvider(entry.plugin.manifest.id, entry.contribution.id)"
|
||||
><PluginIcon :plugin-id="entry.plugin.manifest.id" :icon="pluginConnectionProviderIcon(entry)" class="mr-1 size-3.5" />{{ entry.contribution.label }}</Button
|
||||
>
|
||||
</div>
|
||||
<div v-if="selectedEntry" class="space-y-4 rounded-lg border p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ selectedEntry.contribution.label }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ selectedEntry.contribution.description || selectedEntry.contribution.database_type }}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ t("pluginPlatform.connectionProvider") }}</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" class="gap-1.5" @click="createConnection"><Plus class="size-3.5" />{{ t("pluginPlatform.newConnection") }}</Button>
|
||||
<div v-for="connection in providerConnections" :key="connection.id" class="inline-flex items-center">
|
||||
<Button size="sm" class="rounded-r-none" :variant="selectedConnectionId === connection.id ? 'secondary' : 'outline'" @click="selectConnection(connection.id)">{{ connection.name }}</Button>
|
||||
<Button size="icon" variant="outline" class="h-8 w-8 rounded-l-none border-l-0" :title="t('common.edit')" :aria-label="t('common.edit')" @click="editConnection(connection.id)"><Pencil class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-dashed bg-muted/20 p-3 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.connectionManagedInDialog") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedWorkbenches.length" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.workbenches") }}</div>
|
||||
<div v-for="entry in selectedWorkbenches" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ entry.contribution.description || entry.contribution.id }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="gap-1.5" @click="openWorkbench(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label)"><ExternalLink class="size-3.5" />{{ t("pluginPlatform.open") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFilesystems.length" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.filesystemProviders") }}</div>
|
||||
<div v-for="entry in selectedFilesystems" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ entry.contribution.schemes.join(", ") }} · {{ (entry.contribution.capabilities || []).join(", ") }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="gap-1.5" @click="openFilesystem(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label, entry.contribution.root_uri)"><FolderTree class="size-3.5" />{{ t("pluginPlatform.browse") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4 pb-2">
|
||||
<section class="space-y-3 rounded-xl border p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-md bg-primary/10 p-2 text-primary"><FileUp class="size-4" /></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.localInstallTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.localInstallDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><ShieldCheck class="size-3.5 text-emerald-600 dark:text-emerald-400" />{{ t("pluginPlatform.signedPackagesVerifiedAutomatically") }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><FileUp class="size-3.5" />{{ t("pluginPlatform.dropInstallHint") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-xl border p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-md bg-primary/10 p-2 text-primary"><Store class="size-4" /></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoriesTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoriesDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y rounded-md border">
|
||||
<div v-for="repository in repositories" :key="repository.id" class="flex flex-wrap items-center gap-3 px-3 py-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<span>{{ repository.name }}</span
|
||||
><Badge variant="outline" class="h-4 px-1.5 text-[9px]">{{ t(`pluginPlatform.repositoryKind.${repository.kind}`) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate font-mono text-[10px] text-muted-foreground">{{ repository.catalogUrl || t("pluginPlatform.repositoryNotConfigured") }}</div>
|
||||
</div>
|
||||
<Badge :variant="repository.enabled ? 'secondary' : 'outline'" class="h-5 px-1.5 text-[10px]">{{ repository.enabled ? t("pluginPlatform.enabled") : t("pluginPlatform.disabled") }}</Badge>
|
||||
<Button v-if="!repository.managed" size="sm" variant="ghost" class="h-7" :disabled="operating" @click="toggleRepository(repository)">{{ repository.enabled ? t("pluginPlatform.disable") : t("pluginPlatform.enable") }}</Button>
|
||||
<Button v-if="!repository.managed" size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeRepository(repository)"><Trash2 class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 lg:grid-cols-[180px_220px_minmax(260px,1fr)_auto]">
|
||||
<Input v-model="repositoryId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryIdPlaceholder')" />
|
||||
<Input v-model="repositoryName" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryNamePlaceholder')" />
|
||||
<Input v-model="repositoryCatalogUrl" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryCatalogUrlPlaceholder')" />
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveRepository"><Plus class="size-3.5" />{{ t("pluginPlatform.addRepository") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="group rounded-xl border bg-muted/15" open>
|
||||
<summary class="flex cursor-pointer list-none items-start gap-3 p-4 marker:content-none">
|
||||
<div class="rounded-md bg-muted p-2 text-muted-foreground"><Settings2 class="size-4" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.developerOptionsTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.developerOptionsDescription") }}</div>
|
||||
</div>
|
||||
<ChevronRight class="mt-2 size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div class="space-y-4 border-t p-4">
|
||||
<div class="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-amber-800 dark:text-amber-200">
|
||||
<CircleAlert class="mt-0.5 size-4 shrink-0" />
|
||||
<div class="text-xs leading-5">{{ t("pluginPlatform.developerOptionsWarning") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4 rounded-lg border bg-background p-3">
|
||||
<div class="min-w-0">
|
||||
<Label for="allow-unsigned-plugin-package" class="text-xs font-medium">{{ t("pluginPlatform.allowUnsignedDevelopmentPackage") }}</Label>
|
||||
<div class="mt-1 text-[11px] leading-5 text-muted-foreground">{{ t("pluginPlatform.allowUnsignedDevelopmentPackageDescription") }}</div>
|
||||
</div>
|
||||
<Switch id="allow-unsigned-plugin-package" v-model="allowUnsigned" size="sm" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
|
||||
<template v-if="showCustomRepositoryTrustSettings">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoryTrustTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoryTrustDescription") }}</div>
|
||||
</div>
|
||||
<div v-if="trustedKeys.length" class="divide-y rounded-md border bg-background">
|
||||
<div v-for="key in trustedKeys" :key="key.keyId" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium">{{ key.keyId }}</div>
|
||||
<div class="truncate font-mono text-[10px] text-muted-foreground" :title="key.publicKey">{{ abbreviatedPublicKey(key.publicKey) }}</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeTrustedKey(key.keyId)"><Trash2 class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-[180px_minmax(260px,1fr)_auto]">
|
||||
<Input v-model="trustedKeyId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryKeyIdPlaceholder')" />
|
||||
<Input v-model="trustedPublicKey" class="h-8 font-mono text-xs" :placeholder="t('pluginPlatform.repositoryPublicKeyPlaceholder')" />
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveTrustedKey"><ShieldCheck class="size-3.5" />{{ t("pluginPlatform.trustRepository") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="draggingPackage" class="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5">
|
||||
<FileUp class="size-8 text-primary" />
|
||||
<div class="text-sm font-medium text-primary">{{ t("pluginPlatform.dropToInstall") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { AlertTriangle, ArrowUp, File, FileCode2, Folder, Loader2, RefreshCw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem";
|
||||
import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
provider: PluginFilesystemProviderContribution;
|
||||
connectionId?: string;
|
||||
initialUri?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentUri = ref("");
|
||||
const address = ref("");
|
||||
const entries = ref<PluginFilesystemEntry[]>([]);
|
||||
const nextCursor = ref<string>();
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref("");
|
||||
const selected = ref<PluginFilesystemEntry>();
|
||||
const preview = ref("");
|
||||
const previewImageUrl = ref("");
|
||||
const previewKind = ref<"text" | "image" | "binary">("text");
|
||||
const previewContentType = ref("");
|
||||
const previewTruncated = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const previewError = ref("");
|
||||
let listGeneration = 0;
|
||||
let previewGeneration = 0;
|
||||
|
||||
const rootUri = computed(() => pluginFilesystemRootUri(props.provider));
|
||||
const canRead = computed(() => (props.provider.capabilities || []).includes("read"));
|
||||
const sortedEntries = computed(() => sortPluginFilesystemEntries(entries.value));
|
||||
const parentUri = computed(() => pluginFilesystemParentUri(currentUri.value, rootUri.value));
|
||||
|
||||
async function load(uri = address.value || currentUri.value || rootUri.value, append = false) {
|
||||
const targetUri = uri.trim() || rootUri.value;
|
||||
const generation = ++listGeneration;
|
||||
if (append) loadingMore.value = true;
|
||||
else loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.listPluginFilesystemEntries(props.pluginId, props.provider.id, {
|
||||
connectionId: props.connectionId,
|
||||
uri: targetUri,
|
||||
cursor: append ? nextCursor.value : undefined,
|
||||
limit: 200,
|
||||
});
|
||||
if (generation !== listGeneration) return;
|
||||
currentUri.value = targetUri;
|
||||
address.value = targetUri;
|
||||
entries.value = append ? uniquePluginFilesystemEntries([...entries.value, ...result.entries]) : uniquePluginFilesystemEntries(result.entries);
|
||||
nextCursor.value = result.nextCursor;
|
||||
if (!append) clearPreview();
|
||||
} catch (cause) {
|
||||
if (generation !== listGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === listGeneration) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activateEntry(entry: PluginFilesystemEntry) {
|
||||
selected.value = entry;
|
||||
if (entry.kind === "directory") {
|
||||
await load(entry.uri);
|
||||
return;
|
||||
}
|
||||
if (!canRead.value || entry.kind !== "file") {
|
||||
preview.value = "";
|
||||
previewContentType.value = "";
|
||||
previewTruncated.value = false;
|
||||
previewError.value = canRead.value ? t("pluginPlatform.entryCannotPreview") : t("pluginPlatform.providerNoReadCapability");
|
||||
return;
|
||||
}
|
||||
const generation = ++previewGeneration;
|
||||
previewLoading.value = true;
|
||||
previewError.value = "";
|
||||
revokePreviewImage();
|
||||
preview.value = "";
|
||||
previewKind.value = "text";
|
||||
try {
|
||||
const contentType = (entry.contentType || "").split(";", 1)[0].toLowerCase();
|
||||
const isImage = contentType.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(entry.name);
|
||||
const result = await api.readPluginFilesystemFile(props.pluginId, props.provider.id, entry.uri, {
|
||||
connectionId: props.connectionId,
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (generation !== previewGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(result.dataBase64), (character) => character.charCodeAt(0));
|
||||
const resolvedContentType = (result.contentType || entry.contentType || "application/octet-stream").split(";", 1)[0].toLowerCase();
|
||||
previewContentType.value = resolvedContentType;
|
||||
if (isImage || resolvedContentType.startsWith("image/")) {
|
||||
if (result.truncated) {
|
||||
previewError.value = t("pluginPlatform.entryCannotPreview");
|
||||
} else {
|
||||
previewImageUrl.value = URL.createObjectURL(new Blob([bytes], { type: resolvedContentType }));
|
||||
previewKind.value = "image";
|
||||
}
|
||||
} else if (isTextPreview(entry.name, resolvedContentType)) {
|
||||
preview.value = new TextDecoder().decode(bytes);
|
||||
previewKind.value = "text";
|
||||
} else {
|
||||
previewKind.value = "binary";
|
||||
}
|
||||
previewTruncated.value = result.truncated;
|
||||
} catch (cause) {
|
||||
if (generation !== previewGeneration) return;
|
||||
previewError.value = cause instanceof Error ? cause.message : String(cause);
|
||||
preview.value = "";
|
||||
} finally {
|
||||
if (generation === previewGeneration) previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
previewGeneration += 1;
|
||||
selected.value = undefined;
|
||||
preview.value = "";
|
||||
revokePreviewImage();
|
||||
previewKind.value = "text";
|
||||
previewContentType.value = "";
|
||||
previewTruncated.value = false;
|
||||
previewLoading.value = false;
|
||||
previewError.value = "";
|
||||
}
|
||||
|
||||
function revokePreviewImage() {
|
||||
if (previewImageUrl.value) URL.revokeObjectURL(previewImageUrl.value);
|
||||
previewImageUrl.value = "";
|
||||
}
|
||||
|
||||
function isTextPreview(name: string, contentType: string) {
|
||||
return contentType.startsWith("text/") || /^(application\/(json|javascript|xml|yaml)|image\/svg\+xml)$/.test(contentType) || /\.(cjs|css|csv|html?|js|json|md|mjs|toml|ts|tsx|txt|vue|xml|yaml|yml)$/i.test(name);
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
if (size === undefined) return "";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
onMounted(() => void load(props.initialUri || rootUri.value));
|
||||
onBeforeUnmount(() => {
|
||||
revokePreviewImage();
|
||||
});
|
||||
watch(
|
||||
() => [props.pluginId, props.provider.id, props.connectionId, props.initialUri, rootUri.value] as const,
|
||||
() => void load(props.initialUri || rootUri.value),
|
||||
);
|
||||
|
||||
defineExpose({ refresh: () => load(currentUri.value) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 flex-col bg-background">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<Button size="icon" variant="ghost" class="size-8" :disabled="!parentUri || loading" :title="t('pluginPlatform.parentDirectory')" @click="parentUri && load(parentUri)">
|
||||
<ArrowUp class="size-4" />
|
||||
</Button>
|
||||
<Input v-model="address" class="h-8 min-w-0 flex-1 font-mono text-xs" :aria-label="t('pluginPlatform.filesystemUri')" @keydown.enter="load(address)" />
|
||||
<Button size="icon" variant="ghost" class="size-8" :disabled="loading" :title="t('pluginPlatform.refresh')" @click="load(currentUri)">
|
||||
<Loader2 v-if="loading" class="size-4 animate-spin" />
|
||||
<RefreshCw v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="m-3 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid min-h-0 flex-1 grid-cols-[minmax(18rem,42%)_minmax(0,1fr)] divide-x">
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<div v-if="loading && !entries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFiles") }}</div>
|
||||
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
|
||||
<RecycleScroller v-else class="min-h-0 flex-1 min-w-[28rem] text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
|
||||
<template #default="{ item: entry }">
|
||||
<button type="button" class="grid h-[46px] w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" @click="selected = entry" @dblclick="activateEntry(entry)">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
|
||||
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
|
||||
<File v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
|
||||
</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="nextCursor" class="flex shrink-0 justify-center p-3">
|
||||
<Button size="sm" variant="outline" :disabled="loadingMore" @click="load(currentUri, true)"> <Loader2 v-if="loadingMore" class="mr-2 size-3.5 animate-spin" />{{ t("pluginPlatform.loadMore") }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 min-w-0 flex-col">
|
||||
<div v-if="!selected" class="m-auto max-w-sm px-6 text-center text-sm text-muted-foreground">{{ t("pluginPlatform.fileManagerHint") }}</div>
|
||||
<template v-else>
|
||||
<div class="shrink-0 border-b px-4 py-3">
|
||||
<div class="truncate text-sm font-medium" :title="selected.name">{{ selected.name }}</div>
|
||||
<div class="mt-1 truncate font-mono text-[11px] text-muted-foreground" :title="selected.uri">{{ selected.uri }}</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="previewLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingPreview") }}</div>
|
||||
<div v-else-if="previewError" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangle class="mt-0.5 size-4 shrink-0" />{{ previewError }}</div>
|
||||
<template v-else-if="previewKind === 'image' && previewImageUrl">
|
||||
<img :src="previewImageUrl" :alt="selected.name" class="max-h-full max-w-full rounded-md object-contain" />
|
||||
</template>
|
||||
<template v-else-if="previewKind === 'text' && preview">
|
||||
<div class="mb-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ previewContentType }}</span>
|
||||
<span v-if="previewTruncated">{{ t("pluginPlatform.previewTruncated") }}</span>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ preview }}</pre>
|
||||
</template>
|
||||
<div v-else-if="previewKind === 'binary'" class="m-auto max-w-sm text-center text-sm text-muted-foreground">{{ t("pluginPlatform.entryCannotPreview") }}</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t("pluginPlatform.previewSelectedFileHint") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import PluginFileManager from "@/components/plugins/PluginFileManager.vue";
|
||||
import PluginRefreshState from "@/components/plugins/PluginRefreshState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
providerId: string;
|
||||
connectionId?: string;
|
||||
rootUri?: string;
|
||||
initialUri?: string;
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const deferred = ref(false);
|
||||
let loadGeneration = 0;
|
||||
const entry = computed(() =>
|
||||
createFrontendPluginRegistry(plugins.value, appLocale.value)
|
||||
.listFilesystemProviders()
|
||||
.find((candidate) => candidate.plugin.manifest.id === props.pluginId && candidate.contribution.id === props.providerId),
|
||||
);
|
||||
const provider = computed(() => (entry.value ? { ...entry.value.contribution, root_uri: props.rootUri || entry.value.contribution.root_uri } : undefined));
|
||||
const fileManagerRef = ref<InstanceType<typeof PluginFileManager>>();
|
||||
|
||||
async function load() {
|
||||
const generation = ++loadGeneration;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const installed = await api.listPlugins();
|
||||
if (generation !== loadGeneration) return;
|
||||
plugins.value = installed;
|
||||
if (!entry.value) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId: props.pluginId, providerId: props.providerId }));
|
||||
} catch (cause) {
|
||||
if (generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
deferred.value = false;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (props.connectionId) await connectionStore.ensureConnected(props.connectionId);
|
||||
if (fileManagerRef.value?.refresh) {
|
||||
loading.value = false;
|
||||
return await fileManagerRef.value.refresh();
|
||||
}
|
||||
await load();
|
||||
} catch (cause) {
|
||||
loading.value = false;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
deferred.value = !!props.connectionId && !connectionStore.connectedIds.has(props.connectionId);
|
||||
if (deferred.value) {
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(start);
|
||||
watch(() => [props.pluginId, props.providerId, props.connectionId], start);
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0">
|
||||
<PluginRefreshState v-if="deferred" @refresh="void refresh()" />
|
||||
<div v-else-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFilesystem") }}</div>
|
||||
<div v-else-if="error || !provider" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error || t("pluginPlatform.filesystemUnavailableFallback") }}</span>
|
||||
</div>
|
||||
<PluginFileManager v-else ref="fileManagerRef" class="min-h-0 flex-1" :plugin-id="pluginId" :provider="provider" :connection-id="connectionId" :initial-uri="initialUri" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import PluginIcon from "./PluginIcon.vue";
|
||||
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
|
||||
|
||||
const { listPluginsMock, readPluginAssetMock } = vi.hoisted(() => ({
|
||||
listPluginsMock: vi.fn(),
|
||||
readPluginAssetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listPlugins: listPluginsMock,
|
||||
readPluginAsset: readPluginAssetMock,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountIcon(icon?: string, contributionId?: string) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(PluginIcon, { pluginId: "example.plugin", icon, contributionId });
|
||||
app.mount(container);
|
||||
mountedApps.push(app);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearPluginIconCache();
|
||||
listPluginsMock.mockResolvedValue([]);
|
||||
readPluginAssetMock.mockReset();
|
||||
vi.stubGlobal("URL", {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => "blob:plugin-icon"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PluginIcon", () => {
|
||||
it("renders a developer-provided image asset", async () => {
|
||||
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
|
||||
|
||||
const container = await mountIcon("assets/icon.svg");
|
||||
|
||||
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/icon.svg");
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
|
||||
});
|
||||
|
||||
it("falls back when the asset cannot be loaded", async () => {
|
||||
readPluginAssetMock.mockRejectedValue(new Error("missing"));
|
||||
|
||||
const container = await mountIcon("assets/missing.svg");
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses the fallback without reading an undeclared asset", async () => {
|
||||
const container = await mountIcon();
|
||||
|
||||
expect(readPluginAssetMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders marketplace icon URLs without reading installed assets", async () => {
|
||||
const container = await mountIcon("https://plugins.example.com/icon.svg");
|
||||
|
||||
expect(readPluginAssetMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("https://plugins.example.com/icon.svg");
|
||||
});
|
||||
|
||||
it("resolves a contribution icon when the caller does not provide a path", async () => {
|
||||
listPluginsMock.mockResolvedValue([
|
||||
{
|
||||
manifest: {
|
||||
id: "example.plugin",
|
||||
icon: "assets/plugin.svg",
|
||||
contributions: [{ type: "workbench", id: "example.workbench", label: "Example", icon: "assets/workbench.svg" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
|
||||
|
||||
const container = await mountIcon(undefined, "example.workbench");
|
||||
|
||||
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/workbench.svg");
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
|
||||
});
|
||||
|
||||
it("resolves a filesystem provider icon", async () => {
|
||||
listPluginsMock.mockResolvedValue([
|
||||
{
|
||||
manifest: {
|
||||
id: "example.plugin",
|
||||
icon: "assets/plugin.svg",
|
||||
contributions: [{ type: "filesystem-provider", id: "example.files", label: "Example files", schemes: ["example"], icon: "assets/files.svg" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
|
||||
|
||||
const container = await mountIcon(undefined, "example.files");
|
||||
|
||||
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/files.svg");
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { PlugZap } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { resolvePluginIcon } from "@/lib/plugins/pluginIconResolver";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
icon?: string;
|
||||
contributionId?: string;
|
||||
}>();
|
||||
|
||||
const objectUrl = ref("");
|
||||
const failed = ref(false);
|
||||
let ownsObjectUrl = false;
|
||||
let requestSequence = 0;
|
||||
|
||||
function clearObjectUrl() {
|
||||
if (!objectUrl.value) return;
|
||||
if (ownsObjectUrl) URL.revokeObjectURL(objectUrl.value);
|
||||
objectUrl.value = "";
|
||||
ownsObjectUrl = false;
|
||||
}
|
||||
|
||||
function showFallback() {
|
||||
clearObjectUrl();
|
||||
failed.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.pluginId, props.icon, props.contributionId] as const,
|
||||
async ([pluginId, icon, contributionId]) => {
|
||||
const sequence = ++requestSequence;
|
||||
clearObjectUrl();
|
||||
if (!pluginId) {
|
||||
failed.value = true;
|
||||
return;
|
||||
}
|
||||
failed.value = false;
|
||||
const resolvedIcon = icon || (await resolvePluginIcon(pluginId, contributionId).catch(() => undefined));
|
||||
if (sequence !== requestSequence || !resolvedIcon) {
|
||||
if (sequence === requestSequence) failed.value = true;
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(resolvedIcon)) {
|
||||
objectUrl.value = resolvedIcon;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const asset = await api.readPluginAsset(pluginId, resolvedIcon);
|
||||
if (!asset.contentType.startsWith("image/")) throw new Error("Plugin icon is not an image");
|
||||
const binary = atob(asset.dataBase64);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: asset.contentType }));
|
||||
if (sequence !== requestSequence) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
objectUrl.value = url;
|
||||
ownsObjectUrl = true;
|
||||
} catch {
|
||||
if (sequence === requestSequence) failed.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestSequence += 1;
|
||||
clearObjectUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex shrink-0 items-center justify-center">
|
||||
<img v-if="objectUrl && !failed" :src="objectUrl" alt="" class="size-full object-contain" @error="showFallback" />
|
||||
<PlugZap v-else aria-hidden="true" class="size-full text-violet-500" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Inbox, RefreshCcw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>();
|
||||
const { t } = useI18n();
|
||||
const modRKeys = formatShortcut("Mod+R")
|
||||
.split("+")
|
||||
.map((key) => (key === "Cmd" ? "⌘" : key));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<Inbox class="h-8 w-8 opacity-60" />
|
||||
<div>{{ t("pluginPlatform.reloadRequired") }}</div>
|
||||
<div class="inline-flex items-center gap-1 text-xs text-muted-foreground/70">
|
||||
<span>{{ t("pluginPlatform.reloadHintPrefix") }}</span>
|
||||
<kbd v-for="key in modRKeys" :key="key" class="min-w-5 rounded border border-border/60 bg-muted/50 px-1.5 py-0.5 text-center font-mono text-[12px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||||
<span>{{ t("pluginPlatform.reloadHintSuffix") }}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5" @click="emit('refresh')">
|
||||
<RefreshCcw class="h-3.5 w-3.5" />
|
||||
{{ t("pluginPlatform.refresh") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginWorkbenchContribution;
|
||||
context?: PluginWorkbenchContext;
|
||||
}>(),
|
||||
{ context: () => ({}) },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [];
|
||||
error: [message: string];
|
||||
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext];
|
||||
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
|
||||
closeTab: [];
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { isDark, themeRevision } = useTheme();
|
||||
const iframe = ref<HTMLIFrameElement>();
|
||||
const source = ref("");
|
||||
const loading = ref(true);
|
||||
// Stays false until the iframe's load event: WKWebView paints a white canvas
|
||||
// for a freshly inserted iframe before the sandbox document's first styled
|
||||
// frame, so the themed overlay must keep covering the frame area until then.
|
||||
const frameReady = ref(false);
|
||||
const error = ref("");
|
||||
let bridge: PluginHostBridge | undefined;
|
||||
let unsubscribeEvents: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let loadGeneration = 0;
|
||||
|
||||
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
|
||||
|
||||
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
|
||||
function currentBridgeTheme(): PluginBridgeTheme {
|
||||
const tokens: Record<string, string> = {};
|
||||
if (typeof document !== "undefined") {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
for (const name of style) {
|
||||
if (!name.startsWith("--") || name.startsWith("--dbx-")) continue;
|
||||
if (/^--(color|radius|font)/.test(name)) {
|
||||
const value = style.getPropertyValue(name).trim();
|
||||
if (value) tokens[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { appearance: isDark.value ? "dark" : "light", tokens };
|
||||
}
|
||||
|
||||
function createBridge() {
|
||||
bridge = new PluginHostBridge(
|
||||
props.plugin,
|
||||
props.contribution,
|
||||
props.context,
|
||||
() => iframe.value?.contentWindow || null,
|
||||
{
|
||||
invoke: api.invokePlugin,
|
||||
notify: api.notifyPlugin,
|
||||
sendBinary: api.sendPluginBinary,
|
||||
readAsset: api.readPluginUiAsset,
|
||||
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
closeTab: () => emit("closeTab"),
|
||||
},
|
||||
appLocale.value,
|
||||
currentBridgeTheme(),
|
||||
);
|
||||
}
|
||||
|
||||
function localUiAssetPath(source: string): string | undefined {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
|
||||
try {
|
||||
const resolved = new URL(trimmed, "https://dbx-plugin.invalid/");
|
||||
if (resolved.origin !== "https://dbx-plugin.invalid") return undefined;
|
||||
const path = decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
|
||||
if (!path || path.split("/").some((segment) => segment === "..")) return undefined;
|
||||
return path;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
|
||||
for (const resource of resources) {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) continue;
|
||||
const asset = await api.readPluginUiAsset(pluginId, path);
|
||||
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
|
||||
if (resource.tagName === "SCRIPT") {
|
||||
const script = document.createElement("script");
|
||||
for (const attribute of [...resource.attributes]) {
|
||||
if (attribute.name !== "src") script.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
script.textContent = content;
|
||||
resource.replaceWith(script);
|
||||
} else {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = content;
|
||||
resource.replaceWith(style);
|
||||
}
|
||||
}
|
||||
return document.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge = undefined;
|
||||
loading.value = true;
|
||||
frameReady.value = false;
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme());
|
||||
await nextTick();
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
createBridge();
|
||||
} catch (cause) {
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
emit("error", error.value);
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent) {
|
||||
bridge?.handleWindowMessage(event);
|
||||
}
|
||||
|
||||
function onFrameLoad() {
|
||||
// The load event can precede the webview's first actual paint (notably on
|
||||
// WKWebView); reveal after two animation frames, with a timer fallback
|
||||
// because rAF stalls in occluded/background webviews. Guarded by generation
|
||||
// so a stale callback from a rebuilt iframe can't lift the new overlay.
|
||||
const generation = loadGeneration;
|
||||
const reveal = () => {
|
||||
if (!disposed && generation === loadGeneration) frameReady.value = true;
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(reveal));
|
||||
setTimeout(reveal, 400);
|
||||
bridge?.sendInit();
|
||||
emit("ready");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("message", onMessage);
|
||||
const unsubscribe = await api.subscribePluginEvents(
|
||||
(event) => bridge?.forwardEvent(event),
|
||||
(event) => bridge?.forwardBinary(event),
|
||||
);
|
||||
if (disposed) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
unsubscribeEvents = unsubscribe;
|
||||
await loadWorkbench();
|
||||
});
|
||||
|
||||
// Identity changes require rebuilding the sandbox document; context and locale
|
||||
// changes are pushed through the bridge so plugin UI state survives them.
|
||||
watch(
|
||||
() => [props.plugin.manifest.id, props.plugin.manifest.version, props.contribution.id] as const,
|
||||
() => void loadWorkbench(),
|
||||
);
|
||||
watch(
|
||||
() => props.context,
|
||||
(context) => bridge?.updateContext(context ?? {}),
|
||||
{ deep: true },
|
||||
);
|
||||
watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
// Keyed on the theme revision (bumped by applyTheme) so every theme change
|
||||
// path — dark/light, palette switch, custom colors — re-pushes the resolved
|
||||
// tokens; watching isDark/custom colors alone misses palette-only switches.
|
||||
watch(themeRevision, () => bridge?.updateTheme(currentBridgeTheme()));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
loadGeneration += 1;
|
||||
bridge = undefined;
|
||||
window.removeEventListener("message", onMessage);
|
||||
unsubscribeEvents?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
<!-- Cover until the frame has actually painted: the iframe stays mounted
|
||||
underneath so its load event can fire (v-else on the overlay would
|
||||
deadlock), it just isn't visible yet. Fully opaque so the covered
|
||||
phase is visually identical to the host background, and faded out
|
||||
instead of removed so the reveal is never a hard swap. -->
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background text-sm text-muted-foreground transition-opacity duration-150 ease-out" :class="frameReady ? 'pointer-events-none opacity-0' : 'opacity-100'">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import PluginRefreshState from "@/components/plugins/PluginRefreshState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
connectionId?: string;
|
||||
context?: PluginWorkbenchContext;
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const emit = defineEmits<{
|
||||
closeTab: [];
|
||||
}>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const deferred = ref(false);
|
||||
let loadGeneration = 0;
|
||||
const resolvedConnectionId = computed(() => props.connectionId || (typeof props.context?.connectionId === "string" ? props.context.connectionId : undefined));
|
||||
const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(props.pluginId, props.contributionId));
|
||||
|
||||
async function load() {
|
||||
const generation = ++loadGeneration;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const installed = await api.listPlugins();
|
||||
if (generation !== loadGeneration) return;
|
||||
plugins.value = installed;
|
||||
if (!entry.value) throw new Error(t("pluginPlatform.workbenchUnavailable", { pluginId: props.pluginId, contributionId: props.contributionId }));
|
||||
} catch (cause) {
|
||||
if (generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
deferred.value = false;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (resolvedConnectionId.value) await connectionStore.ensureConnected(resolvedConnectionId.value);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
loading.value = false;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
const connectionId = resolvedConnectionId.value;
|
||||
deferred.value = !!connectionId && !connectionStore.connectedIds.has(connectionId);
|
||||
if (deferred.value) {
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext) {
|
||||
const target = createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(pluginId, contributionId);
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, { title: target?.contribution.label || contributionId, context });
|
||||
}
|
||||
|
||||
function openFilesystem(pluginId: string, providerId: string, context?: PluginWorkbenchContext) {
|
||||
const target = createFrontendPluginRegistry(plugins.value, appLocale.value)
|
||||
.listFilesystemProviders()
|
||||
.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
|
||||
if (!target) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId, providerId }));
|
||||
queryStore.openPluginFilesystem(pluginId, providerId, {
|
||||
title: target.contribution.label,
|
||||
connectionId: typeof context?.connectionId === "string" ? context.connectionId : undefined,
|
||||
rootUri: target.contribution.root_uri,
|
||||
currentUri: typeof context?.uri === "string" ? context.uri : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(start);
|
||||
watch(() => [props.pluginId, props.contributionId, props.connectionId, props.context?.connectionId], start);
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0">
|
||||
<PluginRefreshState v-if="deferred" @refresh="void refresh()" />
|
||||
<div v-else-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingWorkbench") }}</div>
|
||||
<div v-else-if="error || !entry" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
|
||||
</div>
|
||||
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="context" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, markRaw, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy, X } from "@lucide/vue";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, Radio, Clock, Copy, X, Shield } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -3470,9 +3470,23 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
{{ t("redis.slowlog") }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button v-if="activeSidePanel === 'command'" variant="ghost" size="icon" class="h-6 w-6" :title="t('redis.clearHistory')" @click="clearInMemoryHistory">
|
||||
<History class="size-3.5" />
|
||||
</Button>
|
||||
<div v-if="activeSidePanel === 'command'" class="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
:class="props.blockDangerousRedisCommands ? 'text-orange-600 bg-orange-100 dark:text-orange-300 dark:bg-orange-900/30' : 'text-muted-foreground/50'"
|
||||
:aria-label="t('toolbar.blockDangerousRedisCommands')"
|
||||
:aria-pressed="props.blockDangerousRedisCommands"
|
||||
:title="t('toolbar.blockDangerousRedisCommands')"
|
||||
@click="settingsStore.updateEditorSettings({ blockDangerousRedisCommands: !props.blockDangerousRedisCommands })"
|
||||
>
|
||||
<Shield class="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('redis.clearHistory')" @click="clearInMemoryHistory">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
|
||||
|
||||
@@ -253,12 +253,12 @@ function confirmImport() {
|
||||
<div class="settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" class="shrink-0" :disabled="busy" @click="onExportClick">
|
||||
<Loader2 v-if="exporting" class="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3.5 w-3.5" />
|
||||
<Upload v-else class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("settings.settingsTransferExport") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" class="shrink-0" :disabled="busy" @click="onImportClick">
|
||||
<Loader2 v-if="importing" class="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Upload v-else class="mr-1 h-3.5 w-3.5" />
|
||||
<Download v-else class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("settings.settingsTransferImport") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
|
||||
import {
|
||||
filterLocallySearchedTables,
|
||||
createSidebarSearchSubtreePreserver,
|
||||
filterSidebarSearchRootsByConnectionState,
|
||||
filterSidebarTree,
|
||||
@@ -18,15 +19,14 @@ import {
|
||||
mergeSidebarRegexIndexScopes,
|
||||
resolveSidebarFilterGuards,
|
||||
resolveSidebarObjectSearchFilter,
|
||||
reuseLiveSidebarTreeNodes,
|
||||
type SidebarRegexIndexScope,
|
||||
type SidebarRegexScopeIdentity,
|
||||
localTableSearchParentTypes,
|
||||
} from "@/lib/sidebar/sidebarSearchTree";
|
||||
import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
|
||||
import { createSidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch";
|
||||
import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex";
|
||||
import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState";
|
||||
import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker";
|
||||
import { buildTableTreeNodes } from "@/lib/table/tableTree";
|
||||
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
|
||||
import { objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick";
|
||||
@@ -658,28 +658,6 @@ type InvalidatedTableSearchScope = SidebarRegexScopeIdentity & { parentNodeId: s
|
||||
const pendingInvalidatedTableSearchScopes = new Map<string, InvalidatedTableSearchScope>();
|
||||
const regexTableSearchScopes = shallowRef<SidebarRegexIndexScope[]>([]);
|
||||
|
||||
const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
|
||||
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
|
||||
|
||||
function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
const children = node.children ? filterLocallySearchedTables(node.children) : undefined;
|
||||
const query = settingsStore.editorSettings.sidebarTableSearchLocal && localTableSearchParentTypes.has(node.type) ? store.sidebarTableSearchQueries[node.id]?.trim() : "";
|
||||
if (!query || !children) return children === node.children ? node : { ...node, children };
|
||||
|
||||
const indexed = localTableSearchResults.value[node.id];
|
||||
// matchSidebarLabel compares case-insensitively internally and needs the
|
||||
// ORIGINAL label (and entry name) so camelCase boundaries stay detectable.
|
||||
const matchingChildren =
|
||||
indexed === null
|
||||
? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query))
|
||||
: indexed
|
||||
? reuseLiveSidebarTreeNodes(buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), children)
|
||||
: children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query));
|
||||
return { ...node, children: matchingChildren };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRegexTableSearchIndexes() {
|
||||
if (!regexMode.value || !deferredSearchQuery.value) return;
|
||||
const loadedScopes = await collectSidebarRegexIndexScopes(
|
||||
@@ -781,7 +759,7 @@ const filteredNodes = computed(() => {
|
||||
nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds);
|
||||
}
|
||||
|
||||
nodes = filterLocallySearchedTables(nodes);
|
||||
nodes = filterLocallySearchedTables(nodes, { enabled: settingsStore.editorSettings.sidebarTableSearchLocal, queries: store.sidebarTableSearchQueries, indexedResults: localTableSearchResults.value });
|
||||
nodes = filterGloballyIndexedRegexTables(nodes);
|
||||
|
||||
const q = deferredSearchQuery.value;
|
||||
@@ -1433,7 +1411,38 @@ provide(sidebarTreeContextKey, {
|
||||
scheduleLocalSidebarTableSearchRefresh(parentNodeId, focusRestore);
|
||||
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
|
||||
},
|
||||
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
|
||||
refreshTableSearchIndex: (parentNodeId) => {
|
||||
// Re-fetch the live object list before rebuilding the local index. The
|
||||
// index refresh used to scan the database correctly, but the tree itself
|
||||
// still contained the old first page, so newly-created tables could not
|
||||
// be rendered even though they were present in the refreshed index.
|
||||
const findNode = (nodes: TreeNode[]): TreeNode | undefined => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === parentNodeId) return node;
|
||||
const found = node.children ? findNode(node.children) : undefined;
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
void (async () => {
|
||||
const parent = findNode(store.treeNodes);
|
||||
if (parent?.connectionId && parent.database && (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema" || parent.type === "group-tables")) {
|
||||
// Refresh only the tables group. Refreshing the database/schema node
|
||||
// also reloads views, routines, triggers, etc., causing a visible
|
||||
// redraw of the whole sidebar for a table-only operation.
|
||||
if (parent.type === "group-tables") {
|
||||
await store.loadObjectGroupChildren(parent, { force: true });
|
||||
} else if (localTableSearchParentTypes.has(parent.type)) {
|
||||
await store.loadTables(parent.connectionId, parent.database, parent.schema, { force: true });
|
||||
}
|
||||
}
|
||||
await loadLocalTableSearchResults(parentNodeId, true);
|
||||
})().catch((error) => {
|
||||
// Keep refresh failures inside the UI action boundary instead of
|
||||
// leaving an unhandled Promise rejection when metadata loading fails.
|
||||
toast(error instanceof Error ? error.message : String(error), 5000);
|
||||
});
|
||||
},
|
||||
registerPasteHandler: pasteHandlerRegistry.register,
|
||||
});
|
||||
provide(sidebarTreeRuntimeKey, sidebarTreeRuntime);
|
||||
@@ -2058,18 +2067,11 @@ function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
|
||||
// connections list user-defined types without a CREATE TYPE getter this cycle.
|
||||
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
|
||||
const target = createSidebarActionTarget(node);
|
||||
const requestGeneration = beginSidebarAction();
|
||||
void store
|
||||
.ensureConnected(target.connectionId!)
|
||||
.then(() => {
|
||||
if (requestGeneration !== sidebarActionGeneration) return;
|
||||
store.activeConnectionId = target.connectionId!;
|
||||
sidebarObjectSourceTarget.value = { node: target, initialEditing };
|
||||
sidebarObjectSourceOpen.value = true;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (requestGeneration === sidebarActionGeneration) toast(error?.message || String(error), 5000);
|
||||
});
|
||||
beginSidebarAction();
|
||||
// issue #9035:弹窗立即挂载。此前先 await ensureConnected 再开弹窗,这段时间
|
||||
// 界面上没有任何反馈;现在连接与取源都在弹窗自身的加载态之内完成。
|
||||
sidebarObjectSourceTarget.value = { node: target, initialEditing };
|
||||
sidebarObjectSourceOpen.value = true;
|
||||
}
|
||||
|
||||
function openSidebarSettings(initialTab: string) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Plug,
|
||||
Unplug,
|
||||
Pin,
|
||||
PlugZap,
|
||||
ArrowRightLeft,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -70,6 +71,8 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -153,13 +156,12 @@ import {
|
||||
type TableChildObjectType,
|
||||
} from "@/lib/database/dbAdminSql";
|
||||
import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePreflightSql, databaseRenameMaintenanceDatabase, supportsDatabaseRename, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql";
|
||||
import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
|
||||
import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { buildViewDdl } from "@/lib/table/viewDdl";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
|
||||
import {
|
||||
defaultPasteTableMode,
|
||||
@@ -339,7 +341,7 @@ import {
|
||||
type DuplicateStructureSource,
|
||||
} from "./sidebarTreeDialogState";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -350,6 +352,15 @@ const settingsStore = useSettingsStore();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
|
||||
const { toast } = useToast();
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
|
||||
void api.listPlugins().then(
|
||||
(plugins) => {
|
||||
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const { highlight } = useSqlHighlighter();
|
||||
|
||||
@@ -891,7 +902,8 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === "package" && node.connectionId && supportsPackageMemberExpansion(currentDatabaseType())) {
|
||||
const packageDatabaseType = currentDatabaseType();
|
||||
if (node.type === "package" && node.connectionId && supportsPackageMemberExpansion(packageDatabaseType, packageDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(node.connectionId, node.database) : undefined)) {
|
||||
await connectionStore.loadPackageMembers(node);
|
||||
emitNodeToggled(node, wasExpanded);
|
||||
return;
|
||||
@@ -924,6 +936,9 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.loadNacosNamespaces(node.connectionId, treeLoadSearchOptions);
|
||||
} else if (config?.db_type === "mqtt") {
|
||||
await connectionStore.loadMqttTopics(node.connectionId);
|
||||
} else if (config?.db_type === "plugin") {
|
||||
await queryStore.openPluginConnection(node.connectionId);
|
||||
return;
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId, treeLoadSearchOptions);
|
||||
}
|
||||
@@ -1008,6 +1023,8 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.loadDorisCatalogDatabases(node, treeLoadSearchOptions);
|
||||
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && schemaNodeHasLoadableName(effectiveDatabaseTypeForConnection(connectionStore.getConfig(node.connectionId)), node.schema)) {
|
||||
await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions);
|
||||
} else if (node.type === "oracle-db-links") {
|
||||
await connectionStore.loadTreeNodeChildren(node, treeLoadSearchOptions);
|
||||
} else if (node.type === "linked-server-root" && node.connectionId) {
|
||||
await connectionStore.loadSqlServerLinkedServers(node.connectionId, treeLoadSearchOptions);
|
||||
} else if (node.type === "linked-server" && node.connectionId) {
|
||||
@@ -1626,13 +1643,16 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean
|
||||
const eventCreateRequestId = openEventEditor === "create" && node.type === "group-events" ? ++mysqlEventCreateRequestSeq : undefined;
|
||||
const objectFilter = node.type === "event" || node.type === "group-events" ? "events" : undefined;
|
||||
|
||||
const connection = connectionStore.getConfig(node.connectionId);
|
||||
if (!connection) return;
|
||||
if (connection.db_type === "plugin") {
|
||||
await queryStore.openPluginConnection(node.connectionId);
|
||||
return;
|
||||
}
|
||||
if (hasTreeNodeDatabaseContext(node)) {
|
||||
queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connectionStore.getConfig(node.connectionId);
|
||||
if (!connection) return;
|
||||
const options = await getDatabaseOptions(node.connectionId);
|
||||
const database = resolveDefaultDatabase(connection, options);
|
||||
if (database) {
|
||||
@@ -2505,63 +2525,22 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
|
||||
const database = node.database;
|
||||
const sourceTarget = objectSourceTargetForTreeNode(sourceNode);
|
||||
if (!sourceTarget) return;
|
||||
const openMode = settingsStore.editorSettings.routineSourceOpenMode;
|
||||
if (openMode === "query-tab") {
|
||||
void connectionStore
|
||||
.ensureConnected(connectionId)
|
||||
.then(async () => {
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
const schema = sourceTarget.schema || database;
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
|
||||
if (!databaseType) throw new Error("Connection type is unavailable.");
|
||||
const objectName = sourceTarget.name;
|
||||
const {
|
||||
raw,
|
||||
editableSource,
|
||||
objectType: resolvedType,
|
||||
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
|
||||
connectionId,
|
||||
database,
|
||||
schema,
|
||||
name: objectName,
|
||||
objectType: sourceTarget.objectType as any,
|
||||
databaseType,
|
||||
signature: sourceNode.signature,
|
||||
});
|
||||
const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"].includes(resolvedType);
|
||||
if (sourceIsEditable) {
|
||||
queryStore.openObjectSourceTab({
|
||||
connectionId,
|
||||
database,
|
||||
title: `Source - ${node.label}`,
|
||||
schema,
|
||||
catalog: node.catalog,
|
||||
sql: editableSource,
|
||||
objectSource: {
|
||||
schema,
|
||||
name: objectName,
|
||||
objectType: resolvedType,
|
||||
signature: node.signature,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true, sourceView: true });
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast(e?.message || String(e), 5000);
|
||||
});
|
||||
const schema = sourceTarget.schema || database;
|
||||
// issue #9035:两个分支都不再 await 连接与取源。先把容器挂出来(源码 tab /
|
||||
// 源码弹窗),ensureConnected 与 getObjectSource 都发生在已挂载的 UI 之内,
|
||||
// 加载中有状态、失败就地重试,而不是点击后一段时间毫无反应。
|
||||
if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") {
|
||||
queryStore.openObjectSourceTabPending({
|
||||
connectionId,
|
||||
database,
|
||||
title: `Source - ${node.label}`,
|
||||
schema,
|
||||
catalog: node.catalog,
|
||||
request: { name: sourceTarget.name, objectType: sourceTarget.objectType, signature: sourceNode.signature },
|
||||
});
|
||||
return;
|
||||
}
|
||||
void connectionStore
|
||||
.ensureConnected(connectionId)
|
||||
.then(() => {
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
emit("open-object-source", sourceNode, initialEditing);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast(e?.message || String(e), 5000);
|
||||
});
|
||||
emit("open-object-source", sourceNode, initialEditing);
|
||||
}
|
||||
|
||||
function openProcedureExecution() {
|
||||
@@ -2778,11 +2757,13 @@ function batchTruncateConfirmMessage(): string {
|
||||
|
||||
async function dropSqlForTreeNode(node: TreeNode, options?: { cascade?: boolean }): Promise<string | null> {
|
||||
if (node.type === "table" && node.connectionId && node.database) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
return buildDropTableSql({
|
||||
databaseType: databaseTypeForNode(node),
|
||||
schema: node.schema,
|
||||
schema: connectionTableSqlSchema(config, node.schema),
|
||||
tableName: node.label,
|
||||
cascade: options?.cascade && supportsDropTableCascade(databaseTypeForNode(node)),
|
||||
identifierQuote: connectionStore.connectionIdentifierQuote?.(node.connectionId),
|
||||
});
|
||||
}
|
||||
const objectOptions = dropObjectSqlOptionsForNode(node);
|
||||
@@ -6451,9 +6432,40 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
}
|
||||
|
||||
appendPluginConnectionMenuItems(items, node);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Plugin-contributed native menu entries for saved connections. */
|
||||
function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNode) {
|
||||
if (node.type !== "connection") return;
|
||||
const pluginItems = sidebarPluginRegistry.value.listContextMenuItems("connection");
|
||||
if (pluginItems.length === 0) return;
|
||||
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
|
||||
if (!config) return;
|
||||
items.push({ label: "", separator: true });
|
||||
for (const { plugin, contribution } of pluginItems) {
|
||||
items.push({
|
||||
label: contribution.label,
|
||||
icon: PlugZap,
|
||||
action: () => {
|
||||
api
|
||||
.invokePlugin(plugin.manifest.id, `contextMenu/${contribution.id}`, {
|
||||
connection: { id: config.id, dbType: config.db_type, name: config.name, database: config.database || "" },
|
||||
})
|
||||
.then((result) => {
|
||||
const message = (result as { message?: unknown } | null | undefined)?.message;
|
||||
if (typeof message === "string" && message.trim()) toast(message, 4000);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast(String((error as Error)?.message || error), 5000);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function activateRuntimeNode(node: TreeNode) {
|
||||
activeNode.value = node;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,14 @@ import {
|
||||
CircleX,
|
||||
RefreshCw,
|
||||
} from "@lucide/vue";
|
||||
import OracleDatabaseLinksDialog from "@/components/objects/OracleDatabaseLinksDialog.vue";
|
||||
const showDatabaseLinks = ref(false);
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
@@ -361,6 +364,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Braces, colorClass: "text-amber-500" };
|
||||
case "sequence":
|
||||
return { icon: ListTree, colorClass: "text-emerald-500" };
|
||||
case "oracle-db-links":
|
||||
case "oracle-db-link":
|
||||
case "synonym":
|
||||
return { icon: Link2, colorClass: "text-sky-500" };
|
||||
case "job":
|
||||
@@ -432,7 +437,7 @@ function displayLabel(node: TreeNode): string {
|
||||
// internationalized; those nodes may still contain the old Chinese text.
|
||||
if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel");
|
||||
if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "meilisearch-system") return t(node.label);
|
||||
if (node.type === "linked-server-root") return t(node.label);
|
||||
if (node.type === "oracle-db-links" || node.type === "linked-server-root") return t(node.label);
|
||||
if (node.type === "saved-sql-root") return t(node.label);
|
||||
if (node.type === "mqtt-topic" && node.id.endsWith(":mqtt-topic:__console__")) return t(node.label);
|
||||
if (node.label === "tree.defaultDatabase") return t(node.label);
|
||||
@@ -821,7 +826,7 @@ const canExpand = computed(() => {
|
||||
return canTreeNodeShowExpander({
|
||||
type: activeNode.value.type,
|
||||
childCount: activeNode.value.children?.length ?? 0,
|
||||
explicitContainer: (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
|
||||
explicitContainer: activeNode.value.type === "oracle-db-links" || (activeNode.value.type === "package" && activeNode.value.children !== undefined) || activeNode.value.xuguTypeMembersExpandable === true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -961,6 +966,13 @@ function connectionIconType(connectionId?: string) {
|
||||
return config?.driver_profile || config?.db_type || "postgres";
|
||||
}
|
||||
|
||||
const pluginConnectionIcon = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return undefined;
|
||||
const config = connectionStore.getConfig(activeNode.value.connectionId);
|
||||
if (config?.db_type !== "plugin" || !config.plugin_id) return undefined;
|
||||
return { pluginId: config.plugin_id, contributionId: config.plugin_connection_provider };
|
||||
});
|
||||
|
||||
const connectionColor = computed(() => {
|
||||
const connectionId = activeNode.value.connectionId;
|
||||
return connectionId ? connectionStore.getConfig(connectionId)?.color || "" : "";
|
||||
@@ -1454,14 +1466,27 @@ function onClick(event: MouseEvent) {
|
||||
selectSingleTreeNode(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
if (!shouldActivateTreeNodeOnSingleClick(props.node.type, settingsStore.editorSettings.sidebarActivation) && props.node.type !== "load-more") return;
|
||||
if (props.node.type === "oracle-db-link") {
|
||||
showDatabaseLinks.value = true;
|
||||
return;
|
||||
}
|
||||
treeRuntime.handleRowClick(props.node, event.detail);
|
||||
}
|
||||
|
||||
function onDoubleClick(event: MouseEvent) {
|
||||
if (props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") {
|
||||
showDatabaseLinks.value = true;
|
||||
return;
|
||||
}
|
||||
treeRuntime.handleRowDoubleClick(props.node, event);
|
||||
}
|
||||
|
||||
function onTreeItemContextMenu(event: MouseEvent) {
|
||||
if (props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") {
|
||||
event.preventDefault();
|
||||
showDatabaseLinks.value = true;
|
||||
return;
|
||||
}
|
||||
if (!connectionStore.selectedTreeNodeIds.includes(props.node.id)) selectSingleTreeNode(props.node);
|
||||
else connectionStore.selectedTreeNodeId = props.node.id;
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
@@ -1469,6 +1494,11 @@ function onTreeItemContextMenu(event: MouseEvent) {
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((props.node.type === "oracle-db-link" || props.node.type === "oracle-db-links") && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
showDatabaseLinks.value = true;
|
||||
return;
|
||||
}
|
||||
treeRuntime.handleRowKeydown(props.node, event);
|
||||
}
|
||||
</script>
|
||||
@@ -1548,7 +1578,8 @@ function onKeydown(event: KeyboardEvent) {
|
||||
</template>
|
||||
<span v-else class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="relative flex h-3.5 w-3.5 shrink-0" :class="{ 'overflow-visible': node.valid === false }">
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<PluginIcon v-if="node.type === 'connection' && pluginConnectionIcon" :plugin-id="pluginConnectionIcon.pluginId" :contribution-id="pluginConnectionIcon.contributionId" class="h-3.5 w-3.5 shrink-0" />
|
||||
<DatabaseIcon v-else-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="h-3.5 w-3.5 shrink-0" :class="databaseOpenVisual.iconClass" />
|
||||
<CircleX v-if="node.valid === false" data-invalid-object-indicator="true" class="pointer-events-none absolute -right-1 -bottom-1 h-2.5 w-2.5 rounded-full bg-background text-destructive stroke-[3]" aria-hidden="true" />
|
||||
@@ -1579,6 +1610,9 @@ function onKeydown(event: KeyboardEvent) {
|
||||
]"
|
||||
>{{ visibleLabel(node) }}</span
|
||||
>
|
||||
<button v-if="node.type === 'oracle-db-links'" class="ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted" :aria-label="t('databaseLinks.manage')" :title="t('databaseLinks.manage')" @click.stop="showDatabaseLinks = true" @dblclick.stop>
|
||||
<TableProperties class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span v-if="treeNodeSecondaryValue(node)" class="flex min-w-0 max-w-[55%] shrink items-center gap-1 text-xs text-muted-foreground" :title="node.type === 'elasticsearch-index' ? undefined : treeNodeSecondaryValue(node)">
|
||||
<Link2 v-if="node.type === 'elasticsearch-index'" class="h-3 w-3 shrink-0 text-sky-400" />
|
||||
<span class="min-w-0 truncate">{{ treeNodeSecondaryValue(node) }}</span>
|
||||
@@ -1718,6 +1752,15 @@ function onKeydown(event: KeyboardEvent) {
|
||||
</template>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
<OracleDatabaseLinksDialog
|
||||
v-if="showDatabaseLinks && node.connectionId"
|
||||
v-model:open="showDatabaseLinks"
|
||||
:connection-id="node.connectionId"
|
||||
:database="node.database || ''"
|
||||
:name="node.type === 'oracle-db-link' ? node.label : undefined"
|
||||
:owner="node.schema"
|
||||
@changed="connectionStore.refreshOracleDatabaseLinks(node.connectionId)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const { defineComponent, h, Teleport } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
@@ -27,9 +27,20 @@ vi.mock("@/components/ui/dialog", async () => {
|
||||
return () => (props.open ? h("div", slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
const dialogContent = defineComponent({
|
||||
props: {
|
||||
class: [String, Array, Object],
|
||||
overlayClass: [String, Array, Object],
|
||||
portalClass: [String, Array, Object],
|
||||
},
|
||||
inheritAttrs: false,
|
||||
setup(props, { slots }) {
|
||||
return () => h(Teleport, { to: "body" }, [h("div", { class: ["dialog-overlay", "z-50", props.overlayClass] }), h("div", { class: ["dialog-positioner", "z-50", props.portalClass] }, [h("div", { class: ["dialog-content", props.class] }, slots.default?.())])]);
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: dialog,
|
||||
DialogContent: passthrough,
|
||||
DialogContent: dialogContent,
|
||||
DialogDescription: passthrough,
|
||||
DialogFooter: passthrough,
|
||||
DialogHeader: passthrough,
|
||||
@@ -133,6 +144,51 @@ describe("SshHostKeyPromptDialog web bridge", () => {
|
||||
expect(dialogSource).toContain('portal-class="z-[200]"');
|
||||
});
|
||||
|
||||
it("keeps the prompt above a later body-mounted connection dialog and answerable", async () => {
|
||||
await mountDialog();
|
||||
|
||||
const eventSource = MockEventSource.instances[0];
|
||||
eventSource?.emit({
|
||||
type: "prompt",
|
||||
request: {
|
||||
id: "prompt-layered",
|
||||
kind: "HostKeyVerify",
|
||||
host: "layered.example.test",
|
||||
port: 22,
|
||||
key_type: "ssh-ed25519",
|
||||
fingerprint: "SHA256:layered",
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// Simulate ConnectionDialog opening after the global prompt: its portal is
|
||||
// appended later and remains on the shared default z-50 layer.
|
||||
const connectionPositioner = document.createElement("div");
|
||||
connectionPositioner.className = "dialog-positioner z-50";
|
||||
connectionPositioner.textContent = "New connection";
|
||||
document.body.append(connectionPositioner);
|
||||
|
||||
const promptPositioner = document.body.querySelector<HTMLElement>(".dialog-positioner.z-\\[200\\]");
|
||||
const promptOverlay = document.body.querySelector<HTMLElement>(".dialog-overlay.z-\\[200\\]");
|
||||
expect(promptPositioner).not.toBeNull();
|
||||
expect(promptOverlay).not.toBeNull();
|
||||
expect(connectionPositioner.classList.contains("z-50")).toBe(true);
|
||||
expect(promptPositioner?.classList.contains("z-[200]")).toBe(true);
|
||||
expect(promptOverlay?.classList.contains("z-[200]")).toBe(true);
|
||||
expect(document.body.textContent).toContain("layered.example.test:22");
|
||||
|
||||
const buttons = document.body.querySelectorAll<HTMLButtonElement>("button");
|
||||
buttons.item(buttons.length - 1).click();
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveSshPromptMock).toHaveBeenCalledWith({
|
||||
id: "prompt-layered",
|
||||
action: "accept",
|
||||
remember: true,
|
||||
secret: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an SSE host-key prompt and posts the user's acceptance", async () => {
|
||||
await mountDialog();
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
|
||||
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
@@ -346,7 +346,9 @@ async function fetchDdl(force = false) {
|
||||
ddlLoading.value = true;
|
||||
try {
|
||||
const { ddl } = await loadObjectDdl(ddlRequest(), { force });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
} catch (e: any) {
|
||||
ddlContent.value = `-- Error: ${e?.message || e}`;
|
||||
@@ -1153,6 +1155,18 @@ function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean
|
||||
void loadMysqlAutoIncrementCounter(true);
|
||||
}
|
||||
}
|
||||
function isSqliteAutoIncrement(column: EditableStructureColumn): boolean {
|
||||
return structureDialect.value === "sqlite" && column.isPrimaryKey && isSqliteIntegerType(column.dataType) && column.extra.autoIncrement === true;
|
||||
}
|
||||
function canEditSqliteAutoIncrement(column: EditableStructureColumn): boolean {
|
||||
return structureDialect.value === "sqlite" && column.isPrimaryKey && isSqliteIntegerType(column.dataType) && !columns.value.some((candidate) => candidate !== column && candidate.isPrimaryKey && candidate.extra.autoIncrement);
|
||||
}
|
||||
function setSqliteAutoIncrement(column: EditableStructureColumn, checked: boolean) {
|
||||
column.extra.autoIncrement = checked;
|
||||
}
|
||||
function isSqliteIntegerType(dataType: string): boolean {
|
||||
return /^(integer|int|tinyint|smallint|mediumint|bigint)$/i.test(dataType.trim().split("(")[0]);
|
||||
}
|
||||
function onMysqlAutoIncrementInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (/^\d*$/.test(input.value)) {
|
||||
@@ -1488,7 +1502,9 @@ async function hydrateRestoredDraftFromDatabase() {
|
||||
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
|
||||
try {
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
} catch {
|
||||
@@ -1755,7 +1771,7 @@ async function refreshSqlPreview() {
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : [])];
|
||||
// SQLite type-change apply regenerates this revision-checked plan, so its preview must stay byte-for-byte aligned.
|
||||
pendingStatements.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers || hasSqliteTypeChange.value ? statements : statements.map((statement) => omitDdlIdentifierQuotes(statement, sqlFormatDialectForDbType(databaseType.value)));
|
||||
pendingStatements.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers !== false || hasSqliteTypeChange.value ? statements : statements.map((statement) => formatGeneratedDdlIdentifierQuotes(statement, sqlFormatDialectForDbType(databaseType.value), false));
|
||||
warnings.value = [...result.warnings, ...ownerResult.warnings];
|
||||
sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined;
|
||||
} catch (e: any) {
|
||||
@@ -2146,7 +2162,9 @@ async function loadStructure(
|
||||
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
|
||||
try {
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
ddlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
} catch {
|
||||
@@ -2283,6 +2301,7 @@ async function revalidateCachedStructureMetadata(loadRequestId: number, scope: {
|
||||
const revalidationId = ++structureMetadataRevalidationId;
|
||||
const metadataRequest = { connectionId, database, schema, tableName, catalog };
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
// Force alone only clears this facet's own key; the web backend keeps its
|
||||
// own backend-columns/backend-comment entries under the same table prefix
|
||||
// and would serve them to the forced re-fetch. Drop the whole table scope
|
||||
@@ -3905,10 +3924,18 @@ onMounted(() => {
|
||||
void loadTableOwnerRoles();
|
||||
void loadMysqlTableEngine(props.draft?.mysqlTableEngine !== undefined);
|
||||
if (props.draft?.initialized) {
|
||||
void hydrateRestoredDraftFromDatabase().then(() => {
|
||||
// A clean persisted editor snapshot is not a live schema cache. After an
|
||||
// MCP DDL, restoring its loaded-facet flags would otherwise bypass the
|
||||
// invalidated backend cache entirely. Legacy/dirty drafts remain intact.
|
||||
const revalidateRestoredColumns = props.draft.dirty === false && !isCreateMode.value && loadedMetadataFacets.has("columns") && !(databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl);
|
||||
void hydrateRestoredDraftFromDatabase().then(async () => {
|
||||
applyInitialStructureTarget();
|
||||
void loadMysqlAutoIncrementCounter(true);
|
||||
void loadActiveTableStructureMetadataIfNeeded();
|
||||
await loadActiveTableStructureMetadataIfNeeded();
|
||||
if (revalidateRestoredColumns) {
|
||||
// The existing revalidation checks again for edits made while loading.
|
||||
void revalidateCachedStructureMetadata(structureLoadRequestId, { columns: true, tableComment: false }, undefined);
|
||||
}
|
||||
});
|
||||
} else if (isCreateMode.value) {
|
||||
markDraftHydratedAndSync();
|
||||
@@ -4698,6 +4725,12 @@ watch(
|
||||
</template>
|
||||
</template>
|
||||
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
|
||||
<template v-else-if="structureDialect === 'sqlite'">
|
||||
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
|
||||
<input :checked="isSqliteAutoIncrement(column)" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" :disabled="!canEditSqliteAutoIncrement(column)" @change="setSqliteAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
|
||||
<span>{{ t("structureEditor.autoIncrement") }}</span>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="structureDialect === 'mysql'">
|
||||
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
|
||||
<input :checked="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" @change="setMysqlAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
|
||||
|
||||
Vendored
+62
-1
@@ -252,6 +252,8 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
}));
|
||||
|
||||
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
|
||||
import { createColumnDrafts } from "@/lib/table/tableStructureEditorState";
|
||||
import type { TableStructureEditorDraft } from "@/types/database";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
@@ -272,13 +274,14 @@ async function settle() {
|
||||
}
|
||||
}
|
||||
|
||||
async function mountStructureEditor() {
|
||||
async function mountStructureEditor(draft?: TableStructureEditorDraft) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp(TableStructureEditor, {
|
||||
connectionId: mocks.connection.id,
|
||||
database: "test",
|
||||
tableName: "users",
|
||||
draft,
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
@@ -404,3 +407,61 @@ describe("TableStructureEditor metadata revalidation (#8816)", () => {
|
||||
expect(root.textContent).toContain("email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restored structure snapshots after external DDL", () => {
|
||||
function restoredDraft(dirty: boolean | undefined): TableStructureEditorDraft {
|
||||
return {
|
||||
initialized: true,
|
||||
dirty,
|
||||
activeTab: "columns",
|
||||
newTableName: "",
|
||||
tableComment: "",
|
||||
originalTableComment: "",
|
||||
columns: createColumnDrafts(
|
||||
initialColumns.map((column) => ({ name: column.name, data_type: column.data_type, is_nullable: column.nullable, column_default: column.default_value, is_primary_key: false, extra: null, comment: column.comment })),
|
||||
"mysql",
|
||||
),
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
loadedMetadataFacets: ["columns", "comment"],
|
||||
};
|
||||
}
|
||||
|
||||
it("revalidates an explicitly clean restored snapshot", async () => {
|
||||
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet) => ({ value: facet === "columns" ? rebuiltColumns : facet === "comment" ? "" : [], cacheStatus: "remote" }));
|
||||
const root = await mountStructureEditor(restoredDraft(false));
|
||||
await vi.waitFor(() => expect(Array.from(root.querySelectorAll<HTMLInputElement>("[data-column-name-input]")).map((input) => input.value)).toContain("email"));
|
||||
expect(columnsFacetCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([true, undefined])("preserves restored edits when dirty is %s", async (dirty) => {
|
||||
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet) => ({ value: facet === "columns" ? rebuiltColumns : facet === "comment" ? "" : [], cacheStatus: "remote" }));
|
||||
const draft = restoredDraft(dirty);
|
||||
draft.columns[0].name = "local_edit";
|
||||
const root = await mountStructureEditor(draft);
|
||||
await settle();
|
||||
expect(root.querySelector<HTMLInputElement>("[data-column-name-input]")?.value).toBe("local_edit");
|
||||
expect(columnsFacetCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves an edit started while restored snapshot validation is pending", async () => {
|
||||
let resolveColumns!: (value: unknown) => void;
|
||||
mocks.loadObjectMetadataFacet.mockImplementation((_request, facet) =>
|
||||
facet === "columns"
|
||||
? new Promise((resolve) => {
|
||||
resolveColumns = resolve;
|
||||
})
|
||||
: Promise.resolve({ value: facet === "comment" ? "" : [], cacheStatus: "remote" }),
|
||||
);
|
||||
const root = await mountStructureEditor(restoredDraft(false));
|
||||
await vi.waitFor(() => expect(resolveColumns).toBeDefined());
|
||||
const input = root.querySelector<HTMLInputElement>("[data-column-name-input]")!;
|
||||
input.value = "local_edit";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
await settle();
|
||||
resolveColumns({ value: rebuiltColumns, cacheStatus: "remote" });
|
||||
await settle();
|
||||
expect(root.querySelector<HTMLInputElement>("[data-column-name-input]")?.value).toBe("local_edit");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { connectionColor, connectionDisplayName, tabDisplayTitle, tabModeLabel } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -22,7 +20,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const listRef = ref<HTMLElement>();
|
||||
|
||||
function tabColor(tab: QueryTab): string {
|
||||
@@ -65,7 +62,7 @@ watch(
|
||||
@mouseenter="emit('update:selectedIndex', index)"
|
||||
@click="emit('select', tab.id)"
|
||||
>
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionStore.getConfig(tab.connectionId))" class="h-4 w-4 shrink-0" />
|
||||
<TabModeIcon :tab="tab" class="h-4 w-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ tabDisplayTitle(tab, t) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useI18n } from "vue-i18n";
|
||||
import { Eye, EyeOff } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const model = defineModel<string>();
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Eye, EyeOff } from "@lucide/vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const model = defineModel<string>();
|
||||
const visible = ref(false);
|
||||
const { t } = useI18n();
|
||||
const toggleLabel = computed(() => t(visible.value ? "common.hidePassword" : "common.showPassword"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
v-model="model"
|
||||
v-bind="$attrs"
|
||||
:class="{ 'secret-masked': !visible }"
|
||||
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent py-2 pl-2.5 pr-9 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
:spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground" :aria-label="toggleLabel" :title="toggleLabel" :aria-pressed="visible" @click="visible = !visible">
|
||||
<Eye v-if="!visible" class="size-3.5" />
|
||||
<EyeOff v-else class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.secret-masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user