From 13428e6835889b50ab212d1b2ef1ce58e1d1299f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 04:44:45 -0700 Subject: [PATCH 01/16] =?UTF-8?q?docs(notes):=20=E6=8F=90=E8=AE=AE?= =?UTF-8?q?=E6=8A=8A=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8B=86=E6=88=90=E5=8F=8C?= =?UTF-8?q?=E7=AB=AF=E5=85=B1=E7=94=A8=E7=9A=84=E7=8B=AC=E7=AB=8B=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录数据库模块的双端评估(规则三份不一致、Windows 从未接上后端) 和插件化方案:组织仓库独立插件、网页界面、Rust 后端、官方签名, 主仓库只提供插件后端、宿主凭据、MCP 声明等通用能力。 Co-Authored-By: Claude Opus 5.5 --- .../2026-10-24-database-provider-plugin.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md diff --git a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md new file mode 100644 index 000000000..aa12ed262 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md @@ -0,0 +1,213 @@ +# Agent 笔记:数据库拆成双端共用的独立插件 + +状态:提议中 + +## 先说结论 + +数据库连接功能将从 Lithe 主仓库和主安装包中移出,做成一个独立仓库的官方插件。用户只装 IDE 时,看不到数据库入口;从插件市场安装后才能使用。插件的界面是包内网页,后端用 Rust 编写,macOS 和 Windows 共用同一份代码。 + +主仓库不再保留任何数据库专属代码,只提供通用的插件能力:不依附构建系统的插件后端、宿主凭据存储、插件提供的 MCP 服务,以及按平台区分的可执行文件。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 + +这次评估还发现,数据库模块已经偏离“两套界面、一个核心”:同一条 SQL 安全规则写了三份并且不一致,Windows 界面从导入起就没有后端。插件化之后,这些规则只在插件后端保留一份。 + +## 问题 + +### 体积:数据库在主包里占多少 + +2026-10-24 在本机 Lithe 0.6.0(arm64)上测量: + +| 部分 | 大小 | +| --- | --- | +| 整个 `Lithe.app` | 557 MB | +| `Contents/Resources/LanguageServers` | 397 MB | +| `Contents/MacOS/Lithe`(主程序,含数据库界面代码) | 105 MB | +| `Contents/Helpers/lithe-db-sidecar` + `lithe-db-mcp` | 约 9.4 MB | + +数据库的体积主要来自两个后端程序。不过,这次拆分的目的不只是体积:数据库还应该能单独安装、更新和卸载,并且两端共用一份实现。 + +### 现状:逻辑分布在哪里 + +| 层 | macOS | Windows | 是否共享 | +| --- | --- | --- | --- | +| 驱动与执行 | `rust/lithe-db-sidecar`,约 5400 行,支持 mysql、mariadb、postgresql、sqlite、sqlserver、mongodb、redis、nacos | `package-database-sidecars.ts` 要构建 `lithe-database` crate,仓库中没有这个 crate | 否 | +| 协议 | Swift 结构体(`DatabaseSidecarService.swift`)与 Rust serde 各自定义 | `features/database/types/` 里另一套 TS 类型 | 否,没有共享契约和测试样例(fixtures) | +| 领域规则 | `LitheDatabaseModule` 的 Models、Services,以及 `DatabaseFeatureModel` | TS store | 否 | +| 界面 | `Views/Database/`,约 6700 行 SwiftUI | `features/database/`,约 9400 行 React | 各写一套 | + +### 已经出现的偏差 + +1. **Windows 从来没有能用的数据库功能。** + - 前端调用 `connect_database`、`run_database_provider_command` 等 9 个 Tauri 命令,`windows/tauri/src-tauri/src/main.rs` 的命令注册表里一个都没有。 + - 这批前端代码在 2026-08-14 “React Tauri 替换 Qt 前端”时一次导入。仓库任何分支的历史里都没有对应后端,Qt 时期也没有数据库功能。 + - 用户能碰到这条坏路径:`file-utils.ts` 把 `.db`、`.sqlite`、`.duckdb` 文件交给数据库查看器,随后调用失败。 + - 功能矩阵把 Windows 的四项数据库能力标为 `implemented`;README 把数据库工作台列为核心功能,没有注明只支持 macOS。 +2. **macOS 把领域规则写在了壳里。** + - SQL 分析、语句拆分和格式化(`DatabaseSQLSupport.swift`); + - 结构对比、按方言生成 DDL(`DatabaseSchemaDiff.swift`); + - DBX 导入解密(`DatabaseDBXImportService.swift`); + - 字段遮罩、查询历史和定时备份。 + + 这些都写在 Swift 里,`DatabaseFeatureModel.swift` 有 2700 多行。 +3. **同一条规则写了三份,并且已经不一致。** + - “SQL 是什么类型、是否危险”分别由 Swift 的 `DatabaseSQLAnalyzer`(先去注释再按词判断)、sidecar 的 `is_dangerous_sql`(字符串匹配)和 mcp 的 `classify_sql`(另一套前缀匹配)判断。 + - 一个例子(由代码推导,未实机复现):连接开启生产保护后,执行 `DELETE FROM users`,换行后再写 `WHERE id = 1`。Swift 判断不需要确认,sidecar 却因为找不到 `" WHERE "` 而拒绝执行。macOS 不处理它返回的 `confirmation_required`,用户只看到报错,没有确认入口。 + - 反过来,开头带一行注释或使用 `WITH … DELETE` 的语句,能绕过 sidecar 的判断。 + +### 现有插件契约为什么装不下 + +- **网页界面只能操作构建系统。** 网页桥接对象(`window.lithe`)只能操作本插件声明的构建系统;插件函数在 macOS 上也只由构建 provider 的会话提供。数据库插件只能伪装成构建系统,违反[公共扩展契约](2026-10-13-public-extension-contract-and-provider-api.md)的“不要伪造声明”。 +- **插件拿不到宿主的凭据存储。** 插件调用宿主服务(`lithe/host/*`)在协议 v1 中全部被拒绝,没有地方安全地保存密码。 +- **Agent 找不到插件提供的 MCP 服务。** Agent 现在直接到 app 里查找 `lithe-db-mcp`,不会去已安装的插件里找。 +- **跨平台可执行文件路径未定义。** `packaged` 启动方式只有一个固定路径,没有规定 Windows 可执行文件(`.exe`)怎么声明。 +- **Windows 还没有声明式插件宿主和插件市场。** 声明式插件宿主正在 #1424 中开发;插件市场目前只有 macOS 版。 + +## 提案 + +### 一、最终用户看到什么 + +- 只装 Lithe 时,没有数据库工具窗口、菜单或 Services 条目。 +- 在插件市场安装“数据库”插件后,数据库工具窗口出现:连接树、SQL 控制台、数据表格、结构对比,以及 Redis 和 Nacos 视图,都在宿主管理的窗口里,以插件网页显示。 +- 停用或卸载后,入口消失,插件拥有的进程全部结束。 +- macOS 先提供;Windows 在插件宿主和插件市场就绪后提供,使用同一个插件版本。 + +### 二、代码归属 + +| 位置 | 内容 | +| --- | --- | +| 组织仓库 `Lithe-IDEA/lithe-plugin-database`(新建) | Rust 后端(从 `lithe-db-sidecar` 演进)、MCP 服务(从 `lithe-db-mcp` 演进)、网页界面、`plugin.json`、数据库专属的测试样例和兼容性测试 | +| 主仓库 | 只保留通用能力:契约、Rust Core 校验、两端宿主实现、Rust 后端 SDK 的契约测试 | +| 主仓库最终删除 | `macos/Sources/LitheDatabaseModule/`、`macos/Sources/Lithe/Views/Database/`、`rust/lithe-db-sidecar/`、`rust/lithe-db-mcp/`、`windows/tauri/src/features/database/` 及打包脚本中的数据库部分 | + +插件仓库只依赖公开契约:`lithe-plugin-docs` 中锁定的契约副本加官方 SDK,和 Cargo、SVN 插件的做法相同。主仓库不再按“数据库”写任何分支。 + +### 三、插件身份与分发 + +- 使用官方 ID(`dev.lithe.plugin.database`),带 Lithe 发布者签名。原因是插件会经手用户的数据库密码,不能使用不需要 Lithe 签名的社区插件身份。这要求组织仓库的发布 CI 能使用发布者私钥,或者把签名步骤交给主仓库的发布流程完成。 +- 每个版本按平台发布下载包(`darwin-arm64`、`darwin-x64`,Windows 就绪后再加 Windows 的包),在插件索引里登记。网页界面和清单在各平台通用,只有 Rust 可执行文件按平台编译。 +- 构建阶段固定依赖并签名,规则沿用[LSP 插件构建与资源归属](../../implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。安装目录只读,运行状态写进平台存储。 + +### 四、主仓库要新增的通用能力 + +每一项都先写进契约、Rust Core 和测试样例,再由 macOS 宿主实现;Windows 宿主(#1424)按同一份契约实现。 + +1. **插件后端(不依附构建系统)。** 新增一项能力:插件声明一个归属于自己的后端进程,宿主在插件的界面打开时按需启动它,会话按插件和工作区划分。网页桥接新增调用本插件后端函数的方法,复用现有[插件函数协议](../../implemented/architecture/2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。网页原生面板的数据源也可以改为指向这个后端,不再强制要求 `buildSystemID`。 +2. **宿主凭据存储。** 在插件函数协议的新版本中加入第一个宿主能力:插件私有的凭据读写。macOS 使用 Keychain,Windows 使用系统凭据管理器。插件需要声明专门的权限,安装审查会列出这项权限。凭据只按插件 ID 隔离,插件读不到别的插件或宿主自身的凭据。 +3. **插件提供的 MCP 服务。** 插件声明 MCP 服务的入口;Agent 从已启用的插件中发现它。插件停用后,这些工具随之消失。 +4. **按平台的可执行文件。** 契约规定 `packaged` 在不同平台上怎么定位可执行文件,例如允许按平台给出路径,或规定 Windows 自动补 `.exe` 后缀。具体方式在契约 PR 中确定。 +5. **Rust 后端 SDK。** 在 `lithe-plugin-docs` 中新增 `sdk/rust`,实现插件函数协议和宿主能力,并补端到端样例,和 Node、Java SDK 对齐。 + +### 五、规则只保留一份 + +- SQL 分析、语句拆分、标识符引用、DDL 生成和危险语句判定,全部只在插件的 Rust 后端里实现。界面要不要弹出确认框、执行时要不要拦截,都使用同一个分析结果。 +- 插件仓库维护数据库专属的黄金样例。必须覆盖:换行或制表符分隔的 `WHERE`、开头的行注释和块注释、`WITH … DELETE`、多语句、无法识别的语句。 +- 主仓库只维护通用能力的样例(插件后端、凭据、MCP 声明),不再维护数据库样例。 + +### 六、老用户迁移 + +- 现有 macOS 用户的连接配置在宿主的偏好存储里,密码在 Keychain 里。插件首次启用时,宿主把这些数据迁入插件私有的存储和凭据范围,只迁移一次,并保留迁移记录。 +- 升级到不再内置数据库的 Lithe 版本后,如果检测到已保存的连接,宿主提示一次:“数据库已改为插件,点击安装”。不自动在后台下载。 +- 迁移只在插件版本和主程序版本都就绪后进行;在此之前,不从主包删除数据库功能。 + +### 七、Windows + +- **现在就做:** 功能矩阵把 Windows 的数据库能力改为 `missing`;停止把 `.db`、`.sqlite`、`.duckdb` 文件交给数据库查看器;README 注明数据库目前只支持 macOS。 +- **#1424 对齐:** Windows 声明式插件宿主按第四节的同一份契约实现网页桥接、插件后端、凭据和权限。之后由插件仓库补 Windows 构建、WebView2 回归测试和发布。 +- 现有 9400 行 Windows 数据库前端不迁移,跟随主仓库的数据库代码一起删除。暂不支持 DuckDB。 + +### 八、分期 + +| 期 | 内容 | 依赖 | +| --- | --- | --- | +| 1 | Windows 矩阵、`.db` 拦截、README 修正;修复 `WHERE` 误判,让 sidecar 与界面使用一致的词法判断,并让 macOS 处理 `confirmation_required` | 无 | +| 2 | 契约:插件后端、凭据宿主能力、MCP 声明、按平台可执行文件;Rust Core 校验与样例 | 无 | +| 3 | macOS 宿主实现第 2 期能力;`lithe-plugin-docs` 新增 Rust SDK | 第 2 期 | +| 4 | 新建 `lithe-plugin-database`:Rust 后端、MCP、网页界面、样例;先在 macOS 上以本地开发包验证 | 第 3 期 | +| 5 | macOS 发布插件,加入迁移提示,从主仓库删除数据库代码 | 第 4 期 | +| 6 | Windows 宿主按同一契约实现(#1424),Windows 插件市场;插件补 Windows 构建后发布 | 第 2 期、#1424 | + +第 1 到第 5 期不需要等待 Windows。第 6 期只补 Windows 构建与测试,插件本体不重写。 + +### 正确做法 + +- 新增“执行前检查是否含 DROP”:在插件后端的 SQL 分析中加规则和黄金样例,网页界面根据返回结果显示提示。两端自动一致。 +- 新增一种数据库:只改插件后端和样例。主仓库不改一行代码。 + +### 不要这样做 + +- 在主仓库的 Swift 或 TS 中,为数据库再写判断逻辑或专属界面。 +- 为了让数据库插件通过校验,把它声明成构建系统,或者造一个空语言。 +- 让插件把密码写进自己的状态文件,或者写进插件安装目录。 +- Windows 宿主为数据库插件单独做一套桥接方法。 + +## 考虑过的备选方案 + +### 界面留在宿主,两端各画一套原生界面,驱动做成插件 + +它最吸引人的地方是 macOS 保持 SwiftUI 原生体验,宿主侧改动小。不采用,因为主仓库仍要保留数据库专属界面,两端还是各写一套;“代码移出主仓库、双端一份”的目标达不到。 + +### 宿主提供通用的表格、SQL 编辑器组件,插件只提供数据 + +它最吸引人的地方是两端都能保持原生控件,组件也能给其他插件复用。暂不采用,因为两端都要实现带编辑的数据表格和 SQL 编辑器这类重组件,契约面很大,会拖慢数据库上线。以后可以先从数据库树这类简单结构开始,逐步改成宿主原生渲染。 + +### 做成社区插件,不要求 Lithe 签名 + +它最吸引人的地方是发布流程最简单,和现有 7 个社区插件完全一样。不采用,因为插件会经手用户的数据库密码。不需要 Lithe 签名的包可以被替换或冒充,信任边界不够。 + +### 只把两个程序改成按需下载 + +它最吸引人的地方是最快就能减小体积。不采用,因为它不解决规则多份和双端不一致的问题,Windows 也用不上,迟早要返工。 + +## 验收标准 + +- 只安装 Lithe 时没有任何数据库入口;`Lithe.app` 中不含数据库程序和界面代码;主仓库中搜不到数据库专属模块。 +- 从插件市场安装后,现有连接、查询、编辑、导入导出、结构对比、Redis 和 Nacos 功能可用。老用户的连接和密码完成迁移。 +- 停用或卸载后,入口消失,插件进程结束;重新安装后,凭据和连接仍在。 +- 插件仓库中只有一份 SQL 危险判定。换行写的 `DELETE … WHERE` 可以直接执行,不带 `WHERE` 的删除会弹出确认框。 +- 插件后端、凭据和 MCP 声明的通用样例在 Rust Core 测试中通过;Windows 宿主实现后,同一批样例在 Windows 上也通过。 +- `scripts/verify-runtime-bundle-immutability.sh` 覆盖插件安装和典型数据库工作流,发行目录哈希不变。 +- Windows 宿主就绪后,同一插件版本只增加 Windows 下载包即可安装使用。 + +## 风险 + +- **Windows 进度:** 第 6 期依赖 #1424,而 #1424 当前仍有安全阻塞。缓解方式是尽早让它按第四节的契约实现,避免两边各做一套再返工。 +- **网页界面体验:** macOS 上不再是原生 SwiftUI。要使用宿主注入的主题和语言上下文,尽量贴近宿主风格;数据表格和 SQL 编辑器要在插件里自带组件,包体积和性能需要实测。 +- **凭据能力的安全边界:** 宿主凭据是第一项宿主能力,权限、隔离和审查必须先写进契约和样例,不能先实现后补。 +- **签名私钥:** 官方签名需要在组织仓库的 CI 中使用发布者私钥,或者由主仓库代签。要先确定私钥的保管方式。 +- **升级断档:** 第 5 期必须同时具备插件、迁移和提示;任何一项没有完成,都不能从主包删除数据库。 + +## 已定决策 + +2026-10-24 与用户确认: + +- 界面使用插件网页,不保留 SwiftUI 原生数据库界面; +- 插件放在组织仓库,主仓库不保留数据库专属代码; +- 使用带 Lithe 签名的官方身份; +- 后端为长期运行的 Rust 插件后端; +- macOS 先上线,Windows 等宿主和插件市场就绪后跟上; +- 老用户提示一键安装,不自动下载; +- 现有 Windows 数据库前端不迁移; +- 暂不支持 DuckDB。 + +## 待确认 + +- 发布者私钥放在组织仓库的 CI 中,还是由主仓库代签? +- `packaged` 按平台定位可执行文件的具体方式。 +- 远程开发时,数据库后端是否需要在远端运行。 + +## 适用范围 + +- `rust/lithe-db-sidecar/` +- `rust/lithe-db-mcp/` +- `macos/Sources/LitheDatabaseModule/` +- `macos/Sources/Lithe/Views/Database/` +- `macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift` +- `macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift` +- `macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift` +- `windows/tauri/src/features/database/` +- `windows/tauri/src/extensions/tooling/package-database-sidecars.ts` +- `windows/tauri/src/features/file-system/controllers/file-utils.ts` +- `shared/contracts/plugins/v1/` +- `shared/platform-feature-matrix/features/` +- `scripts/package-app.sh` + +相关 Note:[统一 VCS 模型与 Provider 接入](2026-10-22-unified-vcs-provider-model.md)(同类 provider 做法)、[模块运行时抽取路线](2026-09-13-module-runtime-extraction-roadmap.md)(数据库被列为适合插件化)、[插件自带网页界面](../../implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md)、[插件市场](../../implemented/feature/2026-10-12-plugin-marketplace.md)、[MySQL 连接下的多数据库树](../../implemented/feature/2026-10-22-database-multi-schema-tree.md)。 From 829c72961718d7131474b50621aefa4064c91fe2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 04:50:32 -0700 Subject: [PATCH 02/16] =?UTF-8?q?fix(windows):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=90=8E=E7=AB=AF=E6=9C=AA=E6=8E=A5=E5=85=A5=E6=97=B6?= =?UTF-8?q?=E5=A6=82=E5=AE=9E=E6=A0=87=E8=AE=B0=E5=B9=B6=E6=8C=89=E6=99=AE?= =?UTF-8?q?=E9=80=9A=E6=96=87=E4=BB=B6=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 的数据库后端从未接入(backend-capabilities 中 database 为 false, 前端调用的数据库命令没有原生实现),但 .db/.sqlite/.duckdb 文件仍被送进 数据库查看器,功能矩阵也标为 implemented。 - getDatabaseTypeFromPath 遵循同一能力开关,后端不可用时这类文件走普通 二进制/文本路由,打开与会话恢复共用此判断 - 功能矩阵中 Windows 的四项数据库能力改为 missing / not-applicable - README 注明数据库工作台目前仅支持 macOS Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- README.zh-CN.md | 2 +- .../features/database-connections.json | 8 ++++---- .../features/database-crud-export.json | 6 +++--- .../features/database-query-history.json | 6 +++--- .../features/database-schema.json | 6 +++--- .../features/file-system/controllers/file-utils.ts | 7 ++++++- .../controllers/workspace-session-restore.test.ts | 12 ++++++++++++ 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9ec101c9a..c4b9784da 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Only use these steps for an app whose source you trust. Homebrew installations u 14. Automatic project entry-point detection with one-click run support for Spring Boot, Java, Maven, Gradle, npm, Cargo, Go, Python, Make, Docker Compose, Procfile, and shell projects. 15. Per-language service switches so language servers can be enabled or disabled independently to match the machine's resources. 16. Multi-line editor tabs for keeping more files visible in the same workspace. -17. Database connection workspace with multiple database types, connection management, SQL history, table browsing, and database operations. +17. Database connection workspace (currently macOS only) with multiple database types, connection management, SQL history, table browsing, and database operations. 18. Ongoing bug fixes and user experience improvements. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0aa149716..5810baaf9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -102,7 +102,7 @@ Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替 14. 自动识别 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python、Make、Docker Compose、Procfile 和 Shell 项目的可运行入口,并支持一键运行。 15. 支持按语言独立开关语言服务,可根据电脑配置自由启用或停用。 16. 支持类似 IDEA 的多行编辑器标签,同时展示更多打开的文件。 -17. 新增数据库连接工作台,支持多种数据库类型、连接管理、SQL 历史、表浏览和数据库操作。 +17. 新增数据库连接工作台(目前仅支持 macOS),支持多种数据库类型、连接管理、SQL 历史、表浏览和数据库操作。 18. 持续修复问题并优化用户体验。 diff --git a/shared/platform-feature-matrix/features/database-connections.json b/shared/platform-feature-matrix/features/database-connections.json index 30c8eaea6..a4e298af2 100644 --- a/shared/platform-feature-matrix/features/database-connections.json +++ b/shared/platform-feature-matrix/features/database-connections.json @@ -25,11 +25,11 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "windows/tauri/src/config/backend-capabilities.ts" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, "owner": "Database", - "verification": "创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。 Search 查询与文件掩码、Database 连接搜索、Redis Key pattern 和 Nacos 配置/服务搜索均直接使用 Git Log 同源的全局唯一 LitheSearchFieldStyle;在深浅主题核对 36pt 外框、30pt 可见高度、4pt 圆角、1pt 常态边框、2pt 聚焦边框、3pt 外留白及 13pt 常规文字和提示色,确认无局部背景或原生边框叠加,并保留搜索、清除与键盘提交行为。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 共享搜索字段未聚焦时悬停立即显示文本编辑光标,离开或禁用时恢复箭头;保留中文组词及连接过滤语义。 macOS:分别在数据库首页、SQL/数据/结构/历史页、Redis/Nacos/MongoDB 工作区及连接/结构比较弹窗打开时退出应用或关闭项目,确认无 SIGTRAP;休眠后重新打开数据库,确认新实例正常工作,旧视图的延迟刷新不会访问已清空的能力缓存。 macOS 数据库入口位于右侧停靠栏,采用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 20pt 描边圆柱 SVG(database/expui/dbms@20x20.svg 及 dark 变体,DatabaseIconMappings 对应数据库工具窗口),沿用共享停靠栏尺寸及普通/悬停/选中配色,打开时保留项目树、代码编辑器和底部工具;无连接时只有右侧空状态,不显示中央统计首页。打开表、查询或历史时复用可关闭的 Database 编辑器标签;隐藏右侧面板不关闭标签,关闭标签保留连接和 SQL 草稿。切换代码、终端、媒体、Diff 后可重新选择数据库;休眠和关闭项目移除数据库标签。Windows 本次保持现有布局。 数据库工具栏和筛选/排序统一为原始 16pt IDEA 明暗 SVG、共享 22pt 按钮,检查无额外边框、正常/悬停/禁用/筛选选中状态和默认箭头;各图标按钮复用 workbenchHoverHelp 即时显示本地化用途,禁用按钮也可显示提示,移开和点击后关闭,提示在数据库面板局部 scope 内绘制;窄面板工具栏可横向滚动,空状态只保留常规灰色文字,在内容区横向居中、纵向约三分之一处显示;移除图标、说明段落和重复 New Connection 按钮,添加连接仍由工具栏加号进入。 数据库连接新建/编辑、文件夹及 DBX 导入复用全局居中弹窗、输入焦点边框与确认/取消按钮;核对重复打开、取消/Escape/标题关闭、引擎选择及语言环境,导入关闭后清空数据。 连接/文件夹表单不再绘制 grouped 卡片背景,展开高级选项仍可滚动;普通/密码输入框复用共享 28pt 居中布局和启用状态下的 I-beam 悬停,深浅主题核对文字、焦点和禁用状态。 数据库面板仅标题下保留共享 LitheToolWindowHeaderDivider(主题边框色、1pt 高度、两侧 1pt 留白),工具栏按钮下方不再绘制额外横线;深浅主题对照项目和其他工具窗口核对。 连接搜索默认折叠为工具栏搜索图标,点击仅展开共享输入框且不自动聚焦,点击输入框后才显示焦点边框,再次点击或 Escape 清空并关闭搜索;筛选和排序固定在同一工具栏最右侧,窄宽度下左侧动作可横向滚动且右侧菜单仍可操作。 新建/编辑连接采用原生 Grid 标签列和输入列,Host/Port 同行、TLS/安全开关在输入列;提示文字跟随输入列,保留现有字段及逻辑。核对 MySQL、SQLite、Redis、Nacos、长标签以及展开安全/网络选项时的对齐和滚动;弹窗按简要/展开内容调整高度,不添加 IDEA 专有控件。 共享搜索输入在点击同窗口输入区域以外的位置时释放原生字段编辑器,焦点边框恢复常态;输入内部点击保留焦点,其他搜索框的观察器不得取消当前编辑,查询文字保持不变。 数据库概览按钮位于搜索左侧,打开独立可关闭且去重的 Database Overview 编辑器标签;用共享标题/分隔线/行样式保留连接数、已连接数、类型数、Quick Start、Common Actions、Recent SQL 六区。核对与 Database 查询标签同时打开、分别关闭和重复打开,草稿/连接不丢失;关闭项目或休眠移除全部数据库标签;明暗主题、窄窗口纵向排版及最近 SQL 恢复到所属连接。 数据库概览和内容标签统一使用侧栏圆柱图标;连接标签保持数据库名(空值用连接名),页面切换不改变名称;无连接全局历史显示 SQL History。 概览常用动作悬停仅覆盖内容和内边距,使用共享圆角,禁用动作无悬停;概览 New Connection 删除 SwiftUI sheet,复用侧栏 LitheCenteredPopup,核对无打开动画/遮罩、原生标题与外框及关闭/取消/Escape。 连接弹窗 Safety 和 Advanced network 标题整行支持点击展开/收起;检查文字、空白、原生箭头均只切换一次,展开后的输入和开关操作不触发收起。 数据库分组文件夹复用项目树 LitheIcon(.folder) 和共享 treeIconSize,删除黄色 folder.fill;核对深浅主题、展开/收起及嵌套分组。 数据库树分组、连接、数据库、表节点复用共享 litheTreeRow/Tree 字体、行高、缩进与选择悬停色;移除连接颜色竖线、Tables 数量胶囊和空表独立卡片,保留状态、只读/保护提示及原动作。 数据工作区四个导航图标复用共享工具栏按钮和选中状态,空状态使用共享 LitheUnavailableView;数据库操作错误由协调器交给全局通知,核对隐藏侧栏仍通知、重复激活不重复通知、休眠取消观察和重新激活恢复;连接树悬停保持默认箭头。 四个数据库工作区内容统一共享页签、工具栏、标题分隔线、滚动条与字体,SQL 编辑器使用主题字体和颜色;Overview Quick Start/Recent SQL 悬停与 Common Actions 共用圆角与内外留白,背景不覆盖行外留白,状态和时间保持右对齐。核对有数据和空状态、查询草稿切换/关闭、结果网格、数据编辑、结构动作、历史恢复以及明暗主题。 数据库树采用共享左右 12pt、上下 4pt 外留白;连接与表的选中行接入共享活动区域观察器,仅树区域获得焦点且应用窗口为 key 时显示主题蓝色,点击编辑区、搜索框或切换窗口后显示非活动灰色。核对重新点击树恢复蓝色,选中对象保持不变。 连接树移除行尾 Provider 类型及状态符号,成功/失败改为品牌图标右下角绿色/红色圆点,未连接不显示点,连接中保留进度提示;MySQL 原有海豚图形使用 IDEA 深色图标蓝色 #548AF7 提升暗背景对比,工具栏与树节点复用同一资源。核对不同连接状态、只读提示及明暗背景可辨性。 Provider 图标替换为 Community c7f91397 的原始 16×16 providers SVG 及可用 dark 变体(Nacos 保持已有资源),避免 DBX 1024px 画布内留白导致图形过小;主题切换使用独立缓存。状态点依据 modifiers/dot.svg 的 5pt 直径,在 Lithe 主题语义色上按用户要求轻微提高饱和度,不改变全局颜色。验证原生资源尺寸、实际像素占用、主题切换及红绿点位置。 Redis Key Browser 标题/DB 标识复用共享工具窗口标题栏,Estimate size 使用原生常规复选框;刷新与 SCAN 搜索使用 IDEA 16pt SVG 和共享 22pt 工具栏按钮及悬停说明。删除局部红色图标底板、DB 胶囊、迷你开关和内联错误条,错误沿用协调器全局通知,空状态复用 LitheUnavailableView 并填充浏览区,确保搜索固定顶部。保留 SCAN 分页、大小估计绑定、重试、详情及写保护逻辑。核对错误/空状态、搜索焦点、键盘提交与切换连接;原生生命周期检查覆盖 SQLite/Redis 工作区关闭项目后的保留视图。 Data 表格按 Community grid/impl 的 TableResultView、CellRenderingUtils、GridColorSchemeUtil 和 SelectionColorLayer 组织:数据工具栏置于 WHERE/ORDER BY 分栏上方,删除无动作 All rows 胶囊;表头 25pt 下限、4pt 左内边距、原始列 SVG、自适应数字宽度行号列,点击行号仍选择行、点击 # 仍全选,保留排序/过滤菜单。数字单元格右对齐、文本左对齐;单元格选择接入原生焦点和共享活动区域,有焦编辑器选择蓝、失焦编辑器选择灰,切换表清理焦点;表头使用背景 RGB +16/-16 的 softHighlightOf、网格线取 Islands 编辑器 INDENT_GUIDE,单元格左右 3pt,行高按字体行高乘编辑器行距 +5pt,顶部工具栏使用共享 30pt 紧凑高度。数据库树 schema/Tables 分组/表使用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 schema/objectGroup/table 16pt 明暗 SVG,Nacos/Redis/MongoDB 节点保持既有语义。验证有数据时焦点/失焦、过滤/排序、行选择/全选、分页、只读及窄宽度。 筛选/排序弹出区域复用共享弹窗按钮、输入框和分段控件,删除原生按钮背景。数据列宽参考 DefaultGridColumnLayout,按表头与前 100 行内容测量,40pt 下限和可用宽度黄金分割上限,按内容比例分配面板剩余空间;不再等分视口。可写连接单击只选择、双击进入单元格编辑,只读只选择不进入编辑;保留上下文菜单和行操作。 数据库树首层文件夹和连接不叠加局部 7pt/4pt 左内边距,只使用共享容器留白和 19pt 层级缩进;核对首层起点、文件夹子连接以及未分组连接。 数据表头依据 primaryKeyColumns 显示原始 16pt columnGoldKey 明暗资源;核对单字段及复合主键、普通列,字段名 id 不作为主键判据。 共享 sidecar 行解码读取实际值类型,SQLite PRAGMA 的 pk/cid/notnull 保持整数而非 unsupported;回归覆盖单字段和复合主键及普通列。 数据网格列宽在内容测量基础上按比例分配面板剩余宽度、移除 full×1.2 上限;拉宽/收窄面板核对铺满与横向滚动。双击可写单元格进入编辑,普通选中保留整格选中色;复用共享 litheFocusEffectHidden 隐藏 macOS 14+ 系统文字焦点框,核对键盘及双击编辑,Git Log 既有行为不变。 数据表删除独立路径/属性工具栏,保留单行共享 30pt 工具栏:左侧原始 16pt/22pt 刷新及编辑动作,右侧 TSV/分页固定,低频原操作合并到 Data Tools,窄宽度左侧横向滚动;连接标签显示数据库/连接名,移除内层表标签。原普通 Text 的单/双击 SwiftUI 手势改为原生 mouseDown 点击计数,第一下同步选择,第二下在可写连接进入编辑,不等待双击判定;已编辑字段不覆盖,右键/Control-click 和滚轮仍交由原控件,核对默认光标、只读、焦点失焦和键盘导航。 每个数据库连接对应一个顶层标签,重复打开去重;切换表仍复用所属连接标签,内层表标签与文字 Data/SQL/Structure/History 栏删除。四个页面使用原始 16pt table/shell/structure/history SVG、共享 22pt 图标按钮及悬停/选中/辅助功能,与表格操作合并为一行;分组分隔项 7pt 槽位,ToolBar.separatorColor 深浅主题色,非 Data 或无表禁用表格操作。验证 Data/Structure/History 页面及独立 SQL 控制台切换、数据库名稳定、多连接标签去重/关闭/恢复、各连接 SQL 草稿和导航位置隔离、表选择及即时单击、全部关闭/休眠清理和窄宽度布局;明暗资源和原动作保留。 工具栏新增/提交/撤销/更多、TSV 和分页复用同一共享按钮状态;核对可用时原色及 hover,禁用时灰色且无 hover,父组禁用与显式参数禁用只降低一次亮度;TSV 保留文字固有宽度。组件检查覆盖深浅主题,实际预览检查只读/可写草稿及撤销后的状态恢复。 行号列使用与数据相同的 13pt 等宽 Regular 字体,普通与当前行文字使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;背景透明,当前行按 SelectionColorLayer 高亮;核对单元格选择、行选择、# 全选、失焦、右键、深浅主题,以及右侧 1pt 网格线。 macOS Data 新增草稿行须立即可见、延续行号并滚动选中,双击编辑后可提交;连续新增与撤销通过原生预览检查,只读连接保持禁用,失败保留草稿。 macOS 连接树只显示保存的连接名和品牌/连接状态图标,移除只读锁;演示名称不含人为追加的类型后缀。 macOS Data 全选后点击单元格清除整行选择,行号仍可逐个取消;可写演示连接单击只选择、双击可编辑,Tables 分类使用普通树文字色,数量维持次要色。 macOS 可写单元格单击立即整格选择、双击进入编辑,编辑保留原背景及内侧 2pt Islands #3871E1 焦点边框;已有行与新增行一致,失焦移除边框,只读不编辑,行号与全选保留草稿。 macOS 异步标签导航:点击连接先同步选中标签,数据刷新完成不重新激活页面;侧栏、Overview、历史恢复和表动作复用同一入口。运行 DatabaseWorkspaceLifecycleTests.delayedConnectionLoadPreservesLaterNavigation,以 TestGate 控制成功/失败加载,核对切到文件或 Overview、关闭单个/全部标签和请求开始前关闭后,标签集合和选择不被迟到结果修改,loading 状态正常结束。 macOS Data 变更背景按 Community MutationsColorLayer 解析:修改蓝色、新增绿色、删除使用 DIFF_DELETED,提交失败覆盖为 Islands Notification.errorBackground 红色;当前行深/浅 RGB 各加/减 0x10,取消整行选择后保留当前行高亮,实际选中单元格覆盖选中色。核对编辑、连续新增、删除标记、失败保留草稿、撤销及深浅主题;每条网格线使用 INDENT_GUIDE_COLOR 深/浅 #323438/#E6E6E6 与 1pt 单边绘制,行列无额外空隙,修改/失败/删除不改变边线或文字透明度。DatabaseGridAppearanceTests 保护动态主题解析与透明度。 macOS Data 透明行号栏使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;普通行号无表头底板;GridRowHeader 横线与 TableResultRowHeader 右侧竖线保留同一 1pt 网格边线,当前行号变亮,选中单元格所在行的其余普通单元格及当前列标题取 GRID_CURRENT_ROW。核对深浅主题、跨行/列点击、键盘移动、全选取消、失焦、双击编辑和新增草稿,变更色及蓝/灰单元格选择保持原优先级。 macOS SQL 控制台:New Query 为每个查询打开独立顶层 console 标签;Data/SQL 切换复用已打开控制台,历史恢复与表 SQL 动作走相同导航入口。无查询结果时编辑器铺满面板,结果出现后使用共享分隔容器,原生行号栏复用普通编辑器;保留补全、格式化、执行选区、风险确认与历史。MySQL/MariaDB 数据库菜单位于控制台右侧,侧栏不显示额外品牌按钮,切库保留查询 ID 和草稿并清理旧结果。DatabaseWorkspaceLifecycleTests.connectionAndConsoleTabsRestoreDraftsAndCloseIndependently 覆盖多查询、跨连接、后台关闭、切库、关闭当前控制台与全部关闭;延迟加载测试增加切到查询后不得抢回标签的场景。 macOS Console 默认 JetBrains Mono 13pt Regular 与 1.2 行距,字号随编辑器设置;浅色注释真实 Italic,其余 token 保持常规,引号内注释标记不误着色。原生测试核对明暗主题、空白编辑视口扩大后仍覆盖、I-beam、当前行号、草稿回传及字体/段落属性。SQL 只读结果复用 Data 自适应列宽、透明行号、1pt 网格和立即选择/键盘/复制;筛选排序、结构编辑及批量/替换/行详情复用共享按钮、输入、选择器和居中弹窗,核对 Esc/取消/标题关闭不提交。Redis/Nacos 输入与工具栏保留原动作;系统确认及文件选择保留原生。远端 Provider 的实际读写仍须有服务验证,不以组件测试代替。 Console 及 Structure 工具栏统一复用 Database 的 30pt 行高;Console 与共享行号使用 TextKit 1 初始化,保护执行结果分栏及标签重建后无需点击即可首次显示草稿。补全候选的原生 delegate 回归确认 SELECT 前缀,不把系统抢占的 Control-Space 快捷键认定为已完成实机验收。 Mac 本地回归增加重名查询列的布局、原生 Data/Console 重建后的表格草稿及后台刷新快照、行号区与编辑区背景连续性、数据库菜单初始选择状态;数据库切换保留现有 MySQL/MariaDB 后端边界。 Mac Console:无焦点时进入/移动空白编辑区显示 I-beam,离开恢复箭头;首次普通点击末行下方任意位置直接聚焦 EOF,清除旧文字选择,不插入虚拟空行,立即输入追加到最后一行;窗口缩放与查询结果分栏后仍保持整块视口命中。数据库下拉的辅助功能报告当前库名,切库保留查询 ID 和草稿。 Mac Console 文字从内容区原点开始,不叠加容器与行片段的水平留白;明暗主题原生图像回归核对空查询和末尾空行输入前后行号基线不变,当前行背景持续覆盖行号栏;Console 行号按 New UI 的 4pt 空注释区、4pt 前置间距及 14pt 最小占位右对齐,核对单个数字的原生绘制位置,测量使用实际 Console 字体。 Mac Console 点击切行时,原生插入光标与当前行背景同步;stillSelecting 为 true 的单个/多个选区入口回归在选择结束通知之前检查行号及背景,保留原生拖选、键盘、输入法和执行选区行为,只重绘旧、新行及行号区域。 Mac Console 按 Community New UI 将分隔线置于行号栏右边缘前 3pt,1pt 线后保留 2pt 编辑背景,文字容器不重复加 padding;明暗主题原生像素检查验证边线及两点背景。 macOS 审查回归:运行 DatabaseWorkspaceLifecycleTests 的 delayedApplyLocksDraftsAndPreservesNavigation、backgroundConnectionManagementPreservesEditorSelection、removingConnectionClosesOwnedTabsAndFallsBack;以有界延迟控制成功/失败提交,核对在途编辑及重复提交被阻止、A→B 和 A→B→A 不被旧提交刷新、后台保存/刷新不改变编辑器选择,删除连接显式移除数据/查询标签并合法回退。原生交互和实际服务验证单独记录。 Mac Data、只读结果、Console 和行号栏结构背景复用全局 editor 调色板,与项目区保持一致;测试遍历全部配色及明暗外观,Console 与普通编辑器一样关闭文本、滚动视口、容器及行号栏的独立底色;在同一原生窗口逐像素比较最终合成背景与侧栏一致,覆盖深浅外观及编辑、缩放、重绘后的当前行状态。WHERE/ORDER BY 空条件沿 INLINE_PARAMETER_HINT 取深/浅 #858A94/#7A7A7A,有条件使用 SQL keyword 色;实际点击筛选/排序与切换面板后颜色不漂移。 Mac Console 数据库选择器在 SQLite 等单库连接下也可展开,使用共享菜单/hover/22pt 按钮和 16pt schema/箭头;当前库初始选中,MySQL/MariaDB 列出当前连接的其他数据库并复用切库,保留查询 ID 和草稿。SQLite 只显示当前文件库,未开放跨连接切换;核对关闭/Esc/禁用、真实名称和 2pt 内容间隔及左右 4pt 留白。 Mac 异步草稿隔离:运行 delayedPageOnlyClearsItsOwnCurrentDrafts,核对成功/失败分页在切表、切连接及后续分页替代后不清空新页面草稿;运行 delayedApplyLocksDraftsAndPreservesNavigation 的 otherConnection 场景,核对后台提交不阻止新连接加载和编辑,旧提交结束不清理新连接草稿,模块仍追踪后台在途写入。 Mac 查询呈现:运行 queryOutputTracksStatementsSelectionAndFailures 与 WorkbenchModuleUIRegistryTests,核对底部 Services/Output、逐语句真实时间/行数/错误、Unicode 与重复语句选区、错误历史、清空日志保留结果,以及执行前按 SQL 子句格式化(保留词大小写,SELECT 内容另起一行并缩进 4 格,FROM 跟随投影)、有原生范围时只改选区并保留重复 SQL、引用及运算符;深浅主题实际打开连接标题的历史窗口,检查 Close/Paste/Esc、只读预览、错误条关闭和 Services 页签切换。SQL 编辑区、历史预览和 Output 共用语法高亮器;querySyntaxColorsProtectQuotedIdentifiersAndLiterals 在明暗主题核对关键词与运算符、指数数字、转义字符串、美元引用、引用标识符和注释,SQL 本文保持不变。原生像素回归核对首列语句左侧 1pt 绿框可见,绘制遵循 BOXED 零外扩并避开 AppKit 裁剪边缘;同窗原生色块校准 HDR 映射,保留确切色值及严格色差断言。格式化后的末尾光标仍位于 EOF。executionMarkersFollowUnchangedStatementsAndExposeStripePositions 核对成功对号、失败图标、右侧 14×2pt 状态条、短文本不拉伸和长文本压缩、Unicode 前置编辑移动范围与语句编辑清除状态;queryCompletionKeepsOnlyUnchangedExecutionMarkers 用 TestGate 控制查询完成,核对在途追加保留状态及修改已执行语句不显示旧标记。右侧标记点击定位,普通滚动和滑块拖动保留共享 CompactScroller;原生明暗主题另验收。共享输出 Copy 复用统一按钮,常态无背景和边框、hover 使用共享圆角底色,禁用只衰减一次,复制内容仍为无 ANSI 的原始输出。Copy 作为原生滚动视图内的真实 NSHostingView 子视图优先命中,仍用共享 SwiftUI 按钮样式;正文排除按钮的 cursor rect,按钮及下层移动事件均保持箭头;copyExcludesNativeTextHitsAndCursorRectsAcrossResize 在深浅主题检查图标、文字、内边距、缩放后命中、文本矩形完整覆盖正文,以及下层 cursorUpdate 事件恢复箭头。回归检查整棵 NSHostingView 视图树命中到真实按钮宿主,覆盖窗口缩放和图标/文字/留白;完整应用 hover 单独检查。multilineStatementOutlineEndsAtFinalLineInsteadOfWidestLine 核对四行 SQL 的绿框在最后一行收窄到实际终点,原生像素验证收窄边线和原最大 X 处背景;沿 Community EditorPainter.paintBorderEffect,保留绿色 token 和 1pt 描边。 审查修复回归:databaseSQLFormatterLeavesSessionDependentEscapesVerbatim 核对 SQLite 普通字符串、E 转义字符串及写入语句含反斜杠时完全保留原文;formattedMiddleSelectionKeepsTheWholeStatement 核对 Unicode 前缀后的中间选区、全选和 EOF 格式化映射;delayedApplyLocksDraftsAndPreservesNavigation 增加 returnToConnection 的成功/失败场景,核对提交结束后补刷并结束 loading、不影响其他连接。真实窗口核对从旧 Console 切到新 Console 后 History Paste 不沿用旧范围。 SQL History 列表与预览之间的分隔条使用现有 highlightsOnHover=false,深浅主题核对 hover、按下及拖动颜色保持常态,同时保留调整光标和宽度拖动。 databaseSQLScannersPreserveCombiningMarksInsideLiterals 覆盖 U+0301/U+FE0F 开头、双引号转义及字面量内分号/危险关键词;格式化保持原值、拆分仍为一条查询。formattedMiddleSelectionKeepsTheWholeStatement 同时覆盖单条替换和整段多语句格式化,选区不包含其他语句,EOF/全选仍正确。databaseTextAndCopyIgnoreLateEventsOverDivider 验证正文、可编辑 Console、只读 History 及 Copy 的延迟 tracking 事件不覆盖真实分隔条光标,移出窗口后不修改光标;正常正文和按钮仍提供各自光标。Copy 的深浅主题实际宿主测试覆盖翻转坐标与缩放。 后台连接保存回归:backgroundSavePreservesCurrentLoading 使用 TestGate 覆盖当前连接空闲、刷新表、打开表及分页,与后台保存成功/失败的两种完成顺序;后台保存不能设置或清除当前页面 Loading,连接弹窗独立显示连接中并防止重复提交。 P1 并发回归:sidebarTableSelectionDuringApplyLoadsLastSelection 经真实 AppModel 入口覆盖提交期间 A→B、A→B→A 与成功/失败,断言旧页清空、最终表数据正确及 Loading 结束;sqlExecutionLockSurvivesConnectionNavigation 覆盖 INSERT/查询、成功/失败及完成前/后返回原连接,验证跨连接 SQL 执行锁、模块忙闲、禁止重复 Run 与完成后释放。 草稿快照与持久日志回归:reselectingFilteredTableDiscardsDraftsBeforeReplacingKeys 覆盖过滤/排序后编辑、重选同表成功/失败,保证旧行索引草稿不再向新快照主键提交;sqlExecutionLockSurvivesConnectionNavigation 重读原连接的持久 History、Execution Events、Audit,覆盖后台成功/失败及部分批次中断,保留实际影响行数且不回填其他连接 UI。 连接目录的多库独立展开与树单选,见 database-multi-schema-tree 能力记录。 macOS 连接和文件夹右键 Move/To Folder:无分组直接输入,已有分组选择目的地、新建后自动移入或移回根层;QA/Quick 创建嵌套路径,验证循环/重名/过期目标不会产生部分变更。名称弹窗与项目新建文件共用 LitheNamePopup,检查 Enter、Esc、错误提示和深浅主题。 macOS 密码保存:新建用户名为空,仅提供直到重启和永久两种范围;验证临时密码不落盘、跨模块实例可读、范围转换先成功更新存储再发布值、写入/删除失败保留旧密码、旧配置缺少范围字段默认永久。测试结果 Copy 点击后关闭、悬停手形。Windows 此保存范围选项未同步,保持 pending。 新连接 Name 默认采用数据库@主机、未填数据库时为@主机,SQLite 使用文件名;手动改名和已有连接名称不被联动覆盖,URI 密码和查询参数不进入名称。" + "verification": "创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。 Search 查询与文件掩码、Database 连接搜索、Redis Key pattern 和 Nacos 配置/服务搜索均直接使用 Git Log 同源的全局唯一 LitheSearchFieldStyle;在深浅主题核对 36pt 外框、30pt 可见高度、4pt 圆角、1pt 常态边框、2pt 聚焦边框、3pt 外留白及 13pt 常规文字和提示色,确认无局部背景或原生边框叠加,并保留搜索、清除与键盘提交行为。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 共享搜索字段未聚焦时悬停立即显示文本编辑光标,离开或禁用时恢复箭头;保留中文组词及连接过滤语义。 macOS:分别在数据库首页、SQL/数据/结构/历史页、Redis/Nacos/MongoDB 工作区及连接/结构比较弹窗打开时退出应用或关闭项目,确认无 SIGTRAP;休眠后重新打开数据库,确认新实例正常工作,旧视图的延迟刷新不会访问已清空的能力缓存。 macOS 数据库入口位于右侧停靠栏,采用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 20pt 描边圆柱 SVG(database/expui/dbms@20x20.svg 及 dark 变体,DatabaseIconMappings 对应数据库工具窗口),沿用共享停靠栏尺寸及普通/悬停/选中配色,打开时保留项目树、代码编辑器和底部工具;无连接时只有右侧空状态,不显示中央统计首页。打开表、查询或历史时复用可关闭的 Database 编辑器标签;隐藏右侧面板不关闭标签,关闭标签保留连接和 SQL 草稿。切换代码、终端、媒体、Diff 后可重新选择数据库;休眠和关闭项目移除数据库标签。Windows 本次保持现有布局。 数据库工具栏和筛选/排序统一为原始 16pt IDEA 明暗 SVG、共享 22pt 按钮,检查无额外边框、正常/悬停/禁用/筛选选中状态和默认箭头;各图标按钮复用 workbenchHoverHelp 即时显示本地化用途,禁用按钮也可显示提示,移开和点击后关闭,提示在数据库面板局部 scope 内绘制;窄面板工具栏可横向滚动,空状态只保留常规灰色文字,在内容区横向居中、纵向约三分之一处显示;移除图标、说明段落和重复 New Connection 按钮,添加连接仍由工具栏加号进入。 数据库连接新建/编辑、文件夹及 DBX 导入复用全局居中弹窗、输入焦点边框与确认/取消按钮;核对重复打开、取消/Escape/标题关闭、引擎选择及语言环境,导入关闭后清空数据。 连接/文件夹表单不再绘制 grouped 卡片背景,展开高级选项仍可滚动;普通/密码输入框复用共享 28pt 居中布局和启用状态下的 I-beam 悬停,深浅主题核对文字、焦点和禁用状态。 数据库面板仅标题下保留共享 LitheToolWindowHeaderDivider(主题边框色、1pt 高度、两侧 1pt 留白),工具栏按钮下方不再绘制额外横线;深浅主题对照项目和其他工具窗口核对。 连接搜索默认折叠为工具栏搜索图标,点击仅展开共享输入框且不自动聚焦,点击输入框后才显示焦点边框,再次点击或 Escape 清空并关闭搜索;筛选和排序固定在同一工具栏最右侧,窄宽度下左侧动作可横向滚动且右侧菜单仍可操作。 新建/编辑连接采用原生 Grid 标签列和输入列,Host/Port 同行、TLS/安全开关在输入列;提示文字跟随输入列,保留现有字段及逻辑。核对 MySQL、SQLite、Redis、Nacos、长标签以及展开安全/网络选项时的对齐和滚动;弹窗按简要/展开内容调整高度,不添加 IDEA 专有控件。 共享搜索输入在点击同窗口输入区域以外的位置时释放原生字段编辑器,焦点边框恢复常态;输入内部点击保留焦点,其他搜索框的观察器不得取消当前编辑,查询文字保持不变。 数据库概览按钮位于搜索左侧,打开独立可关闭且去重的 Database Overview 编辑器标签;用共享标题/分隔线/行样式保留连接数、已连接数、类型数、Quick Start、Common Actions、Recent SQL 六区。核对与 Database 查询标签同时打开、分别关闭和重复打开,草稿/连接不丢失;关闭项目或休眠移除全部数据库标签;明暗主题、窄窗口纵向排版及最近 SQL 恢复到所属连接。 数据库概览和内容标签统一使用侧栏圆柱图标;连接标签保持数据库名(空值用连接名),页面切换不改变名称;无连接全局历史显示 SQL History。 概览常用动作悬停仅覆盖内容和内边距,使用共享圆角,禁用动作无悬停;概览 New Connection 删除 SwiftUI sheet,复用侧栏 LitheCenteredPopup,核对无打开动画/遮罩、原生标题与外框及关闭/取消/Escape。 连接弹窗 Safety 和 Advanced network 标题整行支持点击展开/收起;检查文字、空白、原生箭头均只切换一次,展开后的输入和开关操作不触发收起。 数据库分组文件夹复用项目树 LitheIcon(.folder) 和共享 treeIconSize,删除黄色 folder.fill;核对深浅主题、展开/收起及嵌套分组。 数据库树分组、连接、数据库、表节点复用共享 litheTreeRow/Tree 字体、行高、缩进与选择悬停色;移除连接颜色竖线、Tables 数量胶囊和空表独立卡片,保留状态、只读/保护提示及原动作。 数据工作区四个导航图标复用共享工具栏按钮和选中状态,空状态使用共享 LitheUnavailableView;数据库操作错误由协调器交给全局通知,核对隐藏侧栏仍通知、重复激活不重复通知、休眠取消观察和重新激活恢复;连接树悬停保持默认箭头。 四个数据库工作区内容统一共享页签、工具栏、标题分隔线、滚动条与字体,SQL 编辑器使用主题字体和颜色;Overview Quick Start/Recent SQL 悬停与 Common Actions 共用圆角与内外留白,背景不覆盖行外留白,状态和时间保持右对齐。核对有数据和空状态、查询草稿切换/关闭、结果网格、数据编辑、结构动作、历史恢复以及明暗主题。 数据库树采用共享左右 12pt、上下 4pt 外留白;连接与表的选中行接入共享活动区域观察器,仅树区域获得焦点且应用窗口为 key 时显示主题蓝色,点击编辑区、搜索框或切换窗口后显示非活动灰色。核对重新点击树恢复蓝色,选中对象保持不变。 连接树移除行尾 Provider 类型及状态符号,成功/失败改为品牌图标右下角绿色/红色圆点,未连接不显示点,连接中保留进度提示;MySQL 原有海豚图形使用 IDEA 深色图标蓝色 #548AF7 提升暗背景对比,工具栏与树节点复用同一资源。核对不同连接状态、只读提示及明暗背景可辨性。 Provider 图标替换为 Community c7f91397 的原始 16×16 providers SVG 及可用 dark 变体(Nacos 保持已有资源),避免 DBX 1024px 画布内留白导致图形过小;主题切换使用独立缓存。状态点依据 modifiers/dot.svg 的 5pt 直径,在 Lithe 主题语义色上按用户要求轻微提高饱和度,不改变全局颜色。验证原生资源尺寸、实际像素占用、主题切换及红绿点位置。 Redis Key Browser 标题/DB 标识复用共享工具窗口标题栏,Estimate size 使用原生常规复选框;刷新与 SCAN 搜索使用 IDEA 16pt SVG 和共享 22pt 工具栏按钮及悬停说明。删除局部红色图标底板、DB 胶囊、迷你开关和内联错误条,错误沿用协调器全局通知,空状态复用 LitheUnavailableView 并填充浏览区,确保搜索固定顶部。保留 SCAN 分页、大小估计绑定、重试、详情及写保护逻辑。核对错误/空状态、搜索焦点、键盘提交与切换连接;原生生命周期检查覆盖 SQLite/Redis 工作区关闭项目后的保留视图。 Data 表格按 Community grid/impl 的 TableResultView、CellRenderingUtils、GridColorSchemeUtil 和 SelectionColorLayer 组织:数据工具栏置于 WHERE/ORDER BY 分栏上方,删除无动作 All rows 胶囊;表头 25pt 下限、4pt 左内边距、原始列 SVG、自适应数字宽度行号列,点击行号仍选择行、点击 # 仍全选,保留排序/过滤菜单。数字单元格右对齐、文本左对齐;单元格选择接入原生焦点和共享活动区域,有焦编辑器选择蓝、失焦编辑器选择灰,切换表清理焦点;表头使用背景 RGB +16/-16 的 softHighlightOf、网格线取 Islands 编辑器 INDENT_GUIDE,单元格左右 3pt,行高按字体行高乘编辑器行距 +5pt,顶部工具栏使用共享 30pt 紧凑高度。数据库树 schema/Tables 分组/表使用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 schema/objectGroup/table 16pt 明暗 SVG,Nacos/Redis/MongoDB 节点保持既有语义。验证有数据时焦点/失焦、过滤/排序、行选择/全选、分页、只读及窄宽度。 筛选/排序弹出区域复用共享弹窗按钮、输入框和分段控件,删除原生按钮背景。数据列宽参考 DefaultGridColumnLayout,按表头与前 100 行内容测量,40pt 下限和可用宽度黄金分割上限,按内容比例分配面板剩余空间;不再等分视口。可写连接单击只选择、双击进入单元格编辑,只读只选择不进入编辑;保留上下文菜单和行操作。 数据库树首层文件夹和连接不叠加局部 7pt/4pt 左内边距,只使用共享容器留白和 19pt 层级缩进;核对首层起点、文件夹子连接以及未分组连接。 数据表头依据 primaryKeyColumns 显示原始 16pt columnGoldKey 明暗资源;核对单字段及复合主键、普通列,字段名 id 不作为主键判据。 共享 sidecar 行解码读取实际值类型,SQLite PRAGMA 的 pk/cid/notnull 保持整数而非 unsupported;回归覆盖单字段和复合主键及普通列。 数据网格列宽在内容测量基础上按比例分配面板剩余宽度、移除 full×1.2 上限;拉宽/收窄面板核对铺满与横向滚动。双击可写单元格进入编辑,普通选中保留整格选中色;复用共享 litheFocusEffectHidden 隐藏 macOS 14+ 系统文字焦点框,核对键盘及双击编辑,Git Log 既有行为不变。 数据表删除独立路径/属性工具栏,保留单行共享 30pt 工具栏:左侧原始 16pt/22pt 刷新及编辑动作,右侧 TSV/分页固定,低频原操作合并到 Data Tools,窄宽度左侧横向滚动;连接标签显示数据库/连接名,移除内层表标签。原普通 Text 的单/双击 SwiftUI 手势改为原生 mouseDown 点击计数,第一下同步选择,第二下在可写连接进入编辑,不等待双击判定;已编辑字段不覆盖,右键/Control-click 和滚轮仍交由原控件,核对默认光标、只读、焦点失焦和键盘导航。 每个数据库连接对应一个顶层标签,重复打开去重;切换表仍复用所属连接标签,内层表标签与文字 Data/SQL/Structure/History 栏删除。四个页面使用原始 16pt table/shell/structure/history SVG、共享 22pt 图标按钮及悬停/选中/辅助功能,与表格操作合并为一行;分组分隔项 7pt 槽位,ToolBar.separatorColor 深浅主题色,非 Data 或无表禁用表格操作。验证 Data/Structure/History 页面及独立 SQL 控制台切换、数据库名稳定、多连接标签去重/关闭/恢复、各连接 SQL 草稿和导航位置隔离、表选择及即时单击、全部关闭/休眠清理和窄宽度布局;明暗资源和原动作保留。 工具栏新增/提交/撤销/更多、TSV 和分页复用同一共享按钮状态;核对可用时原色及 hover,禁用时灰色且无 hover,父组禁用与显式参数禁用只降低一次亮度;TSV 保留文字固有宽度。组件检查覆盖深浅主题,实际预览检查只读/可写草稿及撤销后的状态恢复。 行号列使用与数据相同的 13pt 等宽 Regular 字体,普通与当前行文字使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;背景透明,当前行按 SelectionColorLayer 高亮;核对单元格选择、行选择、# 全选、失焦、右键、深浅主题,以及右侧 1pt 网格线。 macOS Data 新增草稿行须立即可见、延续行号并滚动选中,双击编辑后可提交;连续新增与撤销通过原生预览检查,只读连接保持禁用,失败保留草稿。 macOS 连接树只显示保存的连接名和品牌/连接状态图标,移除只读锁;演示名称不含人为追加的类型后缀。 macOS Data 全选后点击单元格清除整行选择,行号仍可逐个取消;可写演示连接单击只选择、双击可编辑,Tables 分类使用普通树文字色,数量维持次要色。 macOS 可写单元格单击立即整格选择、双击进入编辑,编辑保留原背景及内侧 2pt Islands #3871E1 焦点边框;已有行与新增行一致,失焦移除边框,只读不编辑,行号与全选保留草稿。 macOS 异步标签导航:点击连接先同步选中标签,数据刷新完成不重新激活页面;侧栏、Overview、历史恢复和表动作复用同一入口。运行 DatabaseWorkspaceLifecycleTests.delayedConnectionLoadPreservesLaterNavigation,以 TestGate 控制成功/失败加载,核对切到文件或 Overview、关闭单个/全部标签和请求开始前关闭后,标签集合和选择不被迟到结果修改,loading 状态正常结束。 macOS Data 变更背景按 Community MutationsColorLayer 解析:修改蓝色、新增绿色、删除使用 DIFF_DELETED,提交失败覆盖为 Islands Notification.errorBackground 红色;当前行深/浅 RGB 各加/减 0x10,取消整行选择后保留当前行高亮,实际选中单元格覆盖选中色。核对编辑、连续新增、删除标记、失败保留草稿、撤销及深浅主题;每条网格线使用 INDENT_GUIDE_COLOR 深/浅 #323438/#E6E6E6 与 1pt 单边绘制,行列无额外空隙,修改/失败/删除不改变边线或文字透明度。DatabaseGridAppearanceTests 保护动态主题解析与透明度。 macOS Data 透明行号栏使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;普通行号无表头底板;GridRowHeader 横线与 TableResultRowHeader 右侧竖线保留同一 1pt 网格边线,当前行号变亮,选中单元格所在行的其余普通单元格及当前列标题取 GRID_CURRENT_ROW。核对深浅主题、跨行/列点击、键盘移动、全选取消、失焦、双击编辑和新增草稿,变更色及蓝/灰单元格选择保持原优先级。 macOS SQL 控制台:New Query 为每个查询打开独立顶层 console 标签;Data/SQL 切换复用已打开控制台,历史恢复与表 SQL 动作走相同导航入口。无查询结果时编辑器铺满面板,结果出现后使用共享分隔容器,原生行号栏复用普通编辑器;保留补全、格式化、执行选区、风险确认与历史。MySQL/MariaDB 数据库菜单位于控制台右侧,侧栏不显示额外品牌按钮,切库保留查询 ID 和草稿并清理旧结果。DatabaseWorkspaceLifecycleTests.connectionAndConsoleTabsRestoreDraftsAndCloseIndependently 覆盖多查询、跨连接、后台关闭、切库、关闭当前控制台与全部关闭;延迟加载测试增加切到查询后不得抢回标签的场景。 macOS Console 默认 JetBrains Mono 13pt Regular 与 1.2 行距,字号随编辑器设置;浅色注释真实 Italic,其余 token 保持常规,引号内注释标记不误着色。原生测试核对明暗主题、空白编辑视口扩大后仍覆盖、I-beam、当前行号、草稿回传及字体/段落属性。SQL 只读结果复用 Data 自适应列宽、透明行号、1pt 网格和立即选择/键盘/复制;筛选排序、结构编辑及批量/替换/行详情复用共享按钮、输入、选择器和居中弹窗,核对 Esc/取消/标题关闭不提交。Redis/Nacos 输入与工具栏保留原动作;系统确认及文件选择保留原生。远端 Provider 的实际读写仍须有服务验证,不以组件测试代替。 Console 及 Structure 工具栏统一复用 Database 的 30pt 行高;Console 与共享行号使用 TextKit 1 初始化,保护执行结果分栏及标签重建后无需点击即可首次显示草稿。补全候选的原生 delegate 回归确认 SELECT 前缀,不把系统抢占的 Control-Space 快捷键认定为已完成实机验收。 Mac 本地回归增加重名查询列的布局、原生 Data/Console 重建后的表格草稿及后台刷新快照、行号区与编辑区背景连续性、数据库菜单初始选择状态;数据库切换保留现有 MySQL/MariaDB 后端边界。 Mac Console:无焦点时进入/移动空白编辑区显示 I-beam,离开恢复箭头;首次普通点击末行下方任意位置直接聚焦 EOF,清除旧文字选择,不插入虚拟空行,立即输入追加到最后一行;窗口缩放与查询结果分栏后仍保持整块视口命中。数据库下拉的辅助功能报告当前库名,切库保留查询 ID 和草稿。 Mac Console 文字从内容区原点开始,不叠加容器与行片段的水平留白;明暗主题原生图像回归核对空查询和末尾空行输入前后行号基线不变,当前行背景持续覆盖行号栏;Console 行号按 New UI 的 4pt 空注释区、4pt 前置间距及 14pt 最小占位右对齐,核对单个数字的原生绘制位置,测量使用实际 Console 字体。 Mac Console 点击切行时,原生插入光标与当前行背景同步;stillSelecting 为 true 的单个/多个选区入口回归在选择结束通知之前检查行号及背景,保留原生拖选、键盘、输入法和执行选区行为,只重绘旧、新行及行号区域。 Mac Console 按 Community New UI 将分隔线置于行号栏右边缘前 3pt,1pt 线后保留 2pt 编辑背景,文字容器不重复加 padding;明暗主题原生像素检查验证边线及两点背景。 macOS 审查回归:运行 DatabaseWorkspaceLifecycleTests 的 delayedApplyLocksDraftsAndPreservesNavigation、backgroundConnectionManagementPreservesEditorSelection、removingConnectionClosesOwnedTabsAndFallsBack;以有界延迟控制成功/失败提交,核对在途编辑及重复提交被阻止、A→B 和 A→B→A 不被旧提交刷新、后台保存/刷新不改变编辑器选择,删除连接显式移除数据/查询标签并合法回退。原生交互和实际服务验证单独记录。 Mac Data、只读结果、Console 和行号栏结构背景复用全局 editor 调色板,与项目区保持一致;测试遍历全部配色及明暗外观,Console 与普通编辑器一样关闭文本、滚动视口、容器及行号栏的独立底色;在同一原生窗口逐像素比较最终合成背景与侧栏一致,覆盖深浅外观及编辑、缩放、重绘后的当前行状态。WHERE/ORDER BY 空条件沿 INLINE_PARAMETER_HINT 取深/浅 #858A94/#7A7A7A,有条件使用 SQL keyword 色;实际点击筛选/排序与切换面板后颜色不漂移。 Mac Console 数据库选择器在 SQLite 等单库连接下也可展开,使用共享菜单/hover/22pt 按钮和 16pt schema/箭头;当前库初始选中,MySQL/MariaDB 列出当前连接的其他数据库并复用切库,保留查询 ID 和草稿。SQLite 只显示当前文件库,未开放跨连接切换;核对关闭/Esc/禁用、真实名称和 2pt 内容间隔及左右 4pt 留白。 Mac 异步草稿隔离:运行 delayedPageOnlyClearsItsOwnCurrentDrafts,核对成功/失败分页在切表、切连接及后续分页替代后不清空新页面草稿;运行 delayedApplyLocksDraftsAndPreservesNavigation 的 otherConnection 场景,核对后台提交不阻止新连接加载和编辑,旧提交结束不清理新连接草稿,模块仍追踪后台在途写入。 Mac 查询呈现:运行 queryOutputTracksStatementsSelectionAndFailures 与 WorkbenchModuleUIRegistryTests,核对底部 Services/Output、逐语句真实时间/行数/错误、Unicode 与重复语句选区、错误历史、清空日志保留结果,以及执行前按 SQL 子句格式化(保留词大小写,SELECT 内容另起一行并缩进 4 格,FROM 跟随投影)、有原生范围时只改选区并保留重复 SQL、引用及运算符;深浅主题实际打开连接标题的历史窗口,检查 Close/Paste/Esc、只读预览、错误条关闭和 Services 页签切换。SQL 编辑区、历史预览和 Output 共用语法高亮器;querySyntaxColorsProtectQuotedIdentifiersAndLiterals 在明暗主题核对关键词与运算符、指数数字、转义字符串、美元引用、引用标识符和注释,SQL 本文保持不变。原生像素回归核对首列语句左侧 1pt 绿框可见,绘制遵循 BOXED 零外扩并避开 AppKit 裁剪边缘;同窗原生色块校准 HDR 映射,保留确切色值及严格色差断言。格式化后的末尾光标仍位于 EOF。executionMarkersFollowUnchangedStatementsAndExposeStripePositions 核对成功对号、失败图标、右侧 14×2pt 状态条、短文本不拉伸和长文本压缩、Unicode 前置编辑移动范围与语句编辑清除状态;queryCompletionKeepsOnlyUnchangedExecutionMarkers 用 TestGate 控制查询完成,核对在途追加保留状态及修改已执行语句不显示旧标记。右侧标记点击定位,普通滚动和滑块拖动保留共享 CompactScroller;原生明暗主题另验收。共享输出 Copy 复用统一按钮,常态无背景和边框、hover 使用共享圆角底色,禁用只衰减一次,复制内容仍为无 ANSI 的原始输出。Copy 作为原生滚动视图内的真实 NSHostingView 子视图优先命中,仍用共享 SwiftUI 按钮样式;正文排除按钮的 cursor rect,按钮及下层移动事件均保持箭头;copyExcludesNativeTextHitsAndCursorRectsAcrossResize 在深浅主题检查图标、文字、内边距、缩放后命中、文本矩形完整覆盖正文,以及下层 cursorUpdate 事件恢复箭头。回归检查整棵 NSHostingView 视图树命中到真实按钮宿主,覆盖窗口缩放和图标/文字/留白;完整应用 hover 单独检查。multilineStatementOutlineEndsAtFinalLineInsteadOfWidestLine 核对四行 SQL 的绿框在最后一行收窄到实际终点,原生像素验证收窄边线和原最大 X 处背景;沿 Community EditorPainter.paintBorderEffect,保留绿色 token 和 1pt 描边。 审查修复回归:databaseSQLFormatterLeavesSessionDependentEscapesVerbatim 核对 SQLite 普通字符串、E 转义字符串及写入语句含反斜杠时完全保留原文;formattedMiddleSelectionKeepsTheWholeStatement 核对 Unicode 前缀后的中间选区、全选和 EOF 格式化映射;delayedApplyLocksDraftsAndPreservesNavigation 增加 returnToConnection 的成功/失败场景,核对提交结束后补刷并结束 loading、不影响其他连接。真实窗口核对从旧 Console 切到新 Console 后 History Paste 不沿用旧范围。 SQL History 列表与预览之间的分隔条使用现有 highlightsOnHover=false,深浅主题核对 hover、按下及拖动颜色保持常态,同时保留调整光标和宽度拖动。 databaseSQLScannersPreserveCombiningMarksInsideLiterals 覆盖 U+0301/U+FE0F 开头、双引号转义及字面量内分号/危险关键词;格式化保持原值、拆分仍为一条查询。formattedMiddleSelectionKeepsTheWholeStatement 同时覆盖单条替换和整段多语句格式化,选区不包含其他语句,EOF/全选仍正确。databaseTextAndCopyIgnoreLateEventsOverDivider 验证正文、可编辑 Console、只读 History 及 Copy 的延迟 tracking 事件不覆盖真实分隔条光标,移出窗口后不修改光标;正常正文和按钮仍提供各自光标。Copy 的深浅主题实际宿主测试覆盖翻转坐标与缩放。 后台连接保存回归:backgroundSavePreservesCurrentLoading 使用 TestGate 覆盖当前连接空闲、刷新表、打开表及分页,与后台保存成功/失败的两种完成顺序;后台保存不能设置或清除当前页面 Loading,连接弹窗独立显示连接中并防止重复提交。 P1 并发回归:sidebarTableSelectionDuringApplyLoadsLastSelection 经真实 AppModel 入口覆盖提交期间 A→B、A→B→A 与成功/失败,断言旧页清空、最终表数据正确及 Loading 结束;sqlExecutionLockSurvivesConnectionNavigation 覆盖 INSERT/查询、成功/失败及完成前/后返回原连接,验证跨连接 SQL 执行锁、模块忙闲、禁止重复 Run 与完成后释放。 草稿快照与持久日志回归:reselectingFilteredTableDiscardsDraftsBeforeReplacingKeys 覆盖过滤/排序后编辑、重选同表成功/失败,保证旧行索引草稿不再向新快照主键提交;sqlExecutionLockSurvivesConnectionNavigation 重读原连接的持久 History、Execution Events、Audit,覆盖后台成功/失败及部分批次中断,保留实际影响行数且不回填其他连接 UI。 连接目录的多库独立展开与树单选,见 database-multi-schema-tree 能力记录。 macOS 连接和文件夹右键 Move/To Folder:无分组直接输入,已有分组选择目的地、新建后自动移入或移回根层;QA/Quick 创建嵌套路径,验证循环/重名/过期目标不会产生部分变更。名称弹窗与项目新建文件共用 LitheNamePopup,检查 Enter、Esc、错误提示和深浅主题。 macOS 密码保存:新建用户名为空,仅提供直到重启和永久两种范围;验证临时密码不落盘、跨模块实例可读、范围转换先成功更新存储再发布值、写入/删除失败保留旧密码、旧配置缺少范围字段默认永久。测试结果 Copy 点击后关闭、悬停手形。Windows 此保存范围选项未同步,保持 pending。 新连接 Name 默认采用数据库@主机、未填数据库时为@主机,SQLite 使用文件名;手动改名和已有连接名称不被联动覆盖,URI 密码和查询参数不进入名称。 Windows:数据库后端从未接入(backend-capabilities 的 database 为 false,前端调用的数据库命令没有原生实现),工具菜单入口禁用;.db、.sqlite、.duckdb 文件按普通二进制或文本打开,不进入数据库查看器。" } diff --git a/shared/platform-feature-matrix/features/database-crud-export.json b/shared/platform-feature-matrix/features/database-crud-export.json index 7eebc6e0a..1393d6fba 100644 --- a/shared/platform-feature-matrix/features/database-crud-export.json +++ b/shared/platform-feature-matrix/features/database-crud-export.json @@ -15,10 +15,10 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "windows/tauri/src/config/backend-capabilities.ts" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, "owner": "Database", "verification": "验证新增、编辑、删除、事务失败、复制和结果导出。 macOS 中文界面使用同时包含 Name、名称、Type 列的表,检查筛选、排序、批量更新和替换的列名保持原样;替换的 All columns 固定选项保留本地化,选择列后只修改对应列。 macOS 未选表/未选连接时,数据区使用共享 StatusText 操作提示,无大图标或重复标题;结构页同样使用共享提示,检查深浅主题。" diff --git a/shared/platform-feature-matrix/features/database-query-history.json b/shared/platform-feature-matrix/features/database-query-history.json index f029df9d8..892850cd7 100644 --- a/shared/platform-feature-matrix/features/database-query-history.json +++ b/shared/platform-feature-matrix/features/database-query-history.json @@ -12,10 +12,10 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "windows/tauri/src/config/backend-capabilities.ts" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, "owner": "Database", "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。" diff --git a/shared/platform-feature-matrix/features/database-schema.json b/shared/platform-feature-matrix/features/database-schema.json index 9c60a5daa..0f09c024e 100644 --- a/shared/platform-feature-matrix/features/database-schema.json +++ b/shared/platform-feature-matrix/features/database-schema.json @@ -12,10 +12,10 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "windows/tauri/src/config/backend-capabilities.ts" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, "owner": "Database", "verification": "展开数据库、Schema、表和列,验证刷新、筛选和空结果展示。" diff --git a/windows/tauri/src/features/file-system/controllers/file-utils.ts b/windows/tauri/src/features/file-system/controllers/file-utils.ts index 9213ff54b..c15bc28ad 100644 --- a/windows/tauri/src/features/file-system/controllers/file-utils.ts +++ b/windows/tauri/src/features/file-system/controllers/file-utils.ts @@ -1,3 +1,4 @@ +import { isBackendCapabilityAvailable } from "@/config/backend-capabilities"; import type { DatabaseType } from "@/features/database/types/provider.types"; import { getLanguageIdFromPath } from "@/features/editor/utils/language-id"; import { getBaseName } from "@/utils/path-helpers"; @@ -21,9 +22,13 @@ const isDuckDBFile = (path: string): boolean => { }; /** - * Get the database type from a file path, or null if not a database file + * Get the database viewer type for a file path, or null when the file should + * open through the generic text/binary path. The viewer needs the database + * backend; while that capability is unavailable, `.db` and similar files are + * not routed into a viewer whose every request is rejected. */ export const getDatabaseTypeFromPath = (path: string): DatabaseType | null => { + if (!isBackendCapabilityAvailable("database")) return null; if (isSQLiteFile(path)) return "sqlite"; if (isDuckDBFile(path)) return "duckdb"; return null; diff --git a/windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts b/windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts index c38f6db72..3c721ddaa 100644 --- a/windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts +++ b/windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts @@ -425,6 +425,18 @@ describe("shared open and restore content routing", () => { expect(await loadFileContent("app.java")).toEqual({ kind: "binary" }); }); + test("database files use the generic path while the database backend is unavailable", async () => { + classifyText.mockResolvedValue({ isPlainText: false }); + readFileContent.mockResolvedValue("SQLite format 3\0"); + for (const path of ["cache.db", "app.sqlite", "app.sqlite3", "warehouse.duckdb"]) { + expect(await loadFileContent(path)).toEqual({ kind: "binary" }); + } + + classifyText.mockResolvedValue({ isPlainText: true }); + readFileContent.mockResolvedValue("key=value\n"); + expect(await loadFileContent("settings.db")).toMatchObject({ kind: "text", content: "key=value\n" }); + }); + test("read errors are not disguised as binary files", async () => { readFileContent.mockRejectedValue(new Error("permission denied")); await expect(loadFileContent("app.properties")).rejects.toThrow("permission denied"); From 4a339fab509689897b6f46d43f042e2fdc36604b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 05:17:34 -0700 Subject: [PATCH 03/16] =?UTF-8?q?fix(database):=20=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E6=8C=89=E8=AF=8D=E6=B3=95=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E5=8D=B1=E9=99=A9=20SQL=EF=BC=8C=E6=89=A7=E8=A1=8C=E5=89=8D?= =?UTF-8?q?=E5=85=88=E7=94=B1=E5=90=8E=E7=AB=AF=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar 的危险 SQL 判定从字符串前缀/" WHERE " 匹配改为词法分析:去掉注释、 字符串、引号标识符和 $$ 块;按语句逐条检查;WHERE 必须直接限定该 UPDATE/DELETE(子查询里的 WHERE 不算);识别 WITH ... DELETE 与数据修改型 CTE;DROP/TRUNCATE(含 ALTER ... DROP)需要确认;排除 FOR UPDATE、 ON DELETE CASCADE、GRANT、MySQL TRUNCATE() 等非写入用法;MySQL/MariaDB 与 PostgreSQL E'' 字符串按反斜杠转义处理,SQL Server 识别 [标识符]。 - 修复根因:执行前格式化会把 WHERE 换到新行,旧判定只认 " WHERE ",导致 生产保护连接上所有带 WHERE 的 UPDATE/DELETE 都被拒绝。 - sidecar 新增不连库的 checkWriteProtection 方法;macOS 在生产保护连接执行写入 前先问后端,需要确认时弹出原有确认框,避免批量执行到一半被拒。 - 补充 Rust 金样例测试与 Swift 预检测试。 Co-Authored-By: Claude Opus 5.5 --- .../Database/DatabaseSQLWorkspaceView.swift | 31 +- .../Application/DatabaseFeatureModel.swift | 19 ++ .../Services/DatabaseSidecarService.swift | 8 + .../LitheTests/LitheCoreLogicTests.swift | 55 ++++ rust/lithe-db-sidecar/src/main.rs | 309 +++++++++++++++++- .../features/database-query-history.json | 7 +- 6 files changed, 400 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift index 8fd22507f..e87535a91 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift @@ -182,6 +182,7 @@ struct DatabaseSQLWorkspaceView: View { @State private var pendingScope: DatabaseSQLExecutionScope = .all @State private var pendingTabID: UUID? @State private var showsRiskConfirmation = false + @State private var isCheckingRisk = false @State private var sqlSelection = "" @State private var sqlSelectionRange = NSRange(location: 0, length: 0) @State private var showsQueryHistory = false @@ -240,7 +241,7 @@ struct DatabaseSQLWorkspaceView: View { } .litheToolbarIconButton() .accessibilityLabel("Run") - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil) + .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) .help(hasSQLSelection ? "Run selected SQL (Command-Return)" : "Run all SQL (Command-Return)") LitheMenu { @@ -251,7 +252,7 @@ struct DatabaseSQLWorkspaceView: View { LitheIDEAIcon(resourcePath: "expui/general/chevronDown.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) } .litheToolbarIconButton() - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil) + .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) .workbenchHoverHelp(Text("Choose SQL execution scope")) .accessibilityLabel("Choose SQL execution scope") @@ -390,17 +391,21 @@ struct DatabaseSQLWorkspaceView: View { } private func run(scope: DatabaseSQLExecutionScope) { - guard let tabID = feature.selectedSQLTabID else { return } - let analysis = feature.analysis(forSQLTab: tabID, scope: scope) - if analysis.requiresConfirmation { - pendingRisk = analysis - pendingScope = scope - pendingTabID = tabID - showsRiskConfirmation = true - } else { - model.showToolWindow(.databaseServices) - let selection = sqlSelectionRange - Task { await feature.runSQL(in: tabID, scope: scope, selectionRange: selection) } + guard let tabID = feature.selectedSQLTabID, !isCheckingRisk else { return } + let selection = sqlSelectionRange + isCheckingRisk = true + Task { + let risk = await feature.sqlConfirmation(forSQLTab: tabID, scope: scope) + isCheckingRisk = false + if let risk { + pendingRisk = risk + pendingScope = scope + pendingTabID = tabID + showsRiskConfirmation = true + } else { + model.showToolWindow(.databaseServices) + await feature.runSQL(in: tabID, scope: scope, selectionRange: selection) + } } } diff --git a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift b/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift index a5e4b73f2..e6dbd61de 100644 --- a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift +++ b/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift @@ -1640,6 +1640,25 @@ package final class DatabaseFeatureModel: ObservableObject { DatabaseSQLAnalyzer.analyze(sql(for: tabID, scope: scope)) } + /// Returns the analysis the user must confirm before running, or nil when the SQL may run directly. + /// The database helper owns production protection, so it is asked before the first statement runs; + /// otherwise a statement it rejects would stop the batch after earlier statements were applied. + package func sqlConfirmation(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) async -> DatabaseSQLAnalysis? { + let sql = sql(for: tabID, scope: scope) + let analysis = DatabaseSQLAnalyzer.analyze(sql) + if analysis.requiresConfirmation { return analysis } + guard analysis.canExecute, let profile = selectedProfile, profile.productionProtection, + analysis.statements.contains(where: { !DatabaseSQLAnalyzer.analyze($0).kind.usesQueryEndpoint }) else { return nil } + let connection = connection(profile) + // A helper failure falls through: execute reports it, and the helper still refuses unconfirmed risky SQL. + let requiresConfirmation = (try? await Task.detached { [operations] in + try operations.requiresWriteConfirmation(connection: connection, sql: sql) + }.value) ?? false + guard requiresConfirmation else { return nil } + return DatabaseSQLAnalysis(kind: analysis.kind, statementCount: analysis.statementCount, requiresConfirmation: true, + warning: "Production protection requires confirmation before this SQL runs.", statements: analysis.statements) + } + private func sql(for tabID: UUID, scope: DatabaseSQLExecutionScope) -> String { switch scope { case .all: diff --git a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift b/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift index d44cbc0f0..eadf7f97d 100644 --- a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift +++ b/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift @@ -392,6 +392,8 @@ package protocol DatabaseOperations: Sendable { func pageTable(connection: DatabaseConnection, schema: String, table: String, limit: Int, offset: Int, filters: [DatabaseFilter], sort: [DatabaseSort]) throws -> DatabaseQueryResult func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> DatabaseQueryResult func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult + /// Whether `execute` would reject `sql` under production protection until confirmed; does not connect. + func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool func applyChanges(connection: DatabaseConnection, schema: String, mutations: [DatabaseMutation], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult func applySchemaChange(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult func explain(connection: DatabaseConnection, sql: String, format: String) throws -> DatabaseQueryResult @@ -531,6 +533,11 @@ package final class DatabaseSidecarService: DatabaseOperations, @unchecked Senda try request(method: "execute", params: QueryParams(connection: connection, sql: sql, values: values, confirmed: confirmed, allowWrite: allowWrite)) } + package func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool { + let result: WriteProtectionResult = try request(method: "checkWriteProtection", params: QueryParams(connection: connection, sql: sql)) + return result.requiresConfirmation + } + package func applyChanges(connection: DatabaseConnection, schema: String = "", mutations: [DatabaseMutation], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "applyChanges", params: MutationParams(connection: connection, schema: schema, mutations: mutations, confirmed: confirmed, allowWrite: allowWrite)) } @@ -701,6 +708,7 @@ private struct EmptyParams: Codable {} private struct ConnectionParams: Codable { let connection: DatabaseConnection } private struct ConnectedResult: Codable { let connected: Bool } private struct EmptyResult: Codable {} +private struct WriteProtectionResult: Codable { let requiresConfirmation: Bool } private struct DatabaseRowsResult: Decodable { package let rows: [DatabaseRow] diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index c401d74e7..6ae9b7a29 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1982,6 +1982,61 @@ struct LitheCoreLogicTests { #expect(feature.sqlHistory.first?.sql.contains("SELECT\n 1") == true) } + @Test + @MainActor + func databaseSQLConfirmationAsksTheHelperBeforeProtectedWrites() async throws { + let preferences = DatabaseTestKeyValueStore() + let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) + let protected = DatabaseProfile(name: "Production", kind: .sqlite, path: "/tmp/lithe-protected.sqlite", productionProtection: true) + let local = DatabaseProfile(name: "Local", kind: .sqlite, path: "/tmp/lithe-local.sqlite") + try store.save([protected, local]) + let runner = RecordingProcessRunner { request in + let object = try! JSONSerialization.jsonObject(with: try! #require(request.standardInput)) as! [String: Any] + let id = object["id"] as! String + let sql = (object["params"] as? [String: Any])?["sql"] as? String ?? "" + guard object["method"] as? String == "checkWriteProtection" else { + return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) + } + // Stand-in for the helper's lexer: the subquery WHERE does not bound the outer UPDATE. + let requires = sql.contains("SELECT") + return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"requiresConfirmation":\#(requires)}}"#, exitCode: 0) + } + let feature = DatabaseFeatureModel( + operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), + connectionStore: store + ) + func checkedSQL() -> [String] { + runner.requests.compactMap { request in + guard let input = request.standardInput, + let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], + object["method"] as? String == "checkWriteProtection" else { return nil } + return (object["params"] as? [String: Any])?["sql"] as? String + } + } + + await feature.select(protected) + feature.addSQLTab(sql: "UPDATE items SET value = (SELECT max(value) FROM items WHERE id = 1)") + let tabID = try #require(feature.selectedSQLTabID) + // The local analyzer sees a WHERE token, so only the helper can ask for confirmation here. + #expect(!feature.analysis(forSQLTab: tabID).requiresConfirmation) + let risk = try #require(await feature.sqlConfirmation(forSQLTab: tabID)) + #expect(risk.requiresConfirmation) + #expect(risk.warning == "Production protection requires confirmation before this SQL runs.") + + // A bounded write and a read-only query run without a prompt; queries never reach the helper check. + feature.updateSQL("UPDATE items SET value = 2\nWHERE id = 1", in: tabID) + #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) + feature.updateSQL("SELECT 1", in: tabID) + #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) + #expect(checkedSQL().count == 2) + + // Without production protection the helper is not consulted, even for SQL it would flag. + await feature.select(local) + feature.addSQLTab(sql: "UPDATE items SET value = 2 WHERE id = (SELECT 1)") + #expect(await feature.sqlConfirmation(forSQLTab: try #require(feature.selectedSQLTabID)) == nil) + #expect(checkedSQL().count == 2) + } + @Test @MainActor func databaseSQLBatchStopsAfterTheFirstFailedStatement() async throws { diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs index cb697072d..91df8a0a3 100644 --- a/rust/lithe-db-sidecar/src/main.rs +++ b/rust/lithe-db-sidecar/src/main.rs @@ -275,6 +275,8 @@ async fn dispatch(request: Request) -> Response { "databaseTypes": ["mysql", "mariadb", "postgresql", "sqlite", "sqlserver", "mongodb", "redis", "nacos"], "features": ["testConnection", "schema", "indexes", "foreignKeys", "objects", "schemaChanges", "pagedFilterSort", "transactionalCrud", "query", "explain", "diagnostics", "csvImportExport", "jsonImportExport", "sqlBackupRestore", "writeProtection", "sqlServerDataGrid", "mongoCollections", "mongoDocumentEditor", "redisScan", "redisStringHashEditor", "redisTTL", "redisFlushDatabase", "nacosConfigManagement", "nacosServiceDiscovery"] })) + } else if request.method == "checkWriteProtection" { + check_write_protection(request.params) } else { database_method(&request.method, request.params).await }; @@ -284,6 +286,17 @@ async fn dispatch(request: Request) -> Response { } } +/// Reports whether `execute` would reject `sql` until the user confirms, without +/// connecting. Clients ask before running a batch so a statement rejected by +/// production protection cannot stop it after earlier statements were applied. +fn check_write_protection(value: Value) -> DbResult { + let params: Params = + serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; + let requires_confirmation = params.connection.production_protection + && is_dangerous_sql(¶ms.connection.kind, ¶ms.sql); + Ok(json!({"requiresConfirmation": requires_confirmation})) +} + async fn database_method(method: &str, value: Value) -> DbResult { let params: Params = serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; @@ -2810,7 +2823,7 @@ fn ensure_write_allowed( "This connection is configured as read-only".into(), )); } - if connection.production_protection && is_dangerous_sql(sql) && !confirmed { + if connection.production_protection && is_dangerous_sql(&connection.kind, sql) && !confirmed { return Err(( "confirmation_required".into(), "Production protection requires explicit confirmation".into(), @@ -2858,7 +2871,7 @@ fn ensure_transaction_allowed( if connection.production_protection && statements .iter() - .any(|statement| is_dangerous_sql(&statement.sql)) + .any(|statement| is_dangerous_sql(&connection.kind, &statement.sql)) && !confirmed { return Err(( @@ -2869,13 +2882,199 @@ fn ensure_transaction_allowed( Ok(()) } -fn is_dangerous_sql(sql: &str) -> bool { - let upper = sql.trim_start().to_ascii_uppercase(); - upper == "RESTORE" - || upper.starts_with("DROP ") - || upper.starts_with("TRUNCATE ") - || (upper.starts_with("UPDATE ") && !upper.contains(" WHERE ")) - || (upper.starts_with("DELETE ") && !upper.contains(" WHERE ")) +/// Decides whether production protection must ask before running `sql`. +/// +/// The script is lexed rather than pattern-matched so that line breaks, comments, +/// string literals and quoted identifiers cannot hide or fake a keyword. A +/// statement is dangerous when it restores a backup, drops or truncates +/// anything, or contains an `UPDATE`/`DELETE` whose own clause has no `WHERE` +/// (a `WHERE` inside a subquery does not bound the outer write). Every +/// statement of a multi-statement script is checked. +fn is_dangerous_sql(kind: &str, sql: &str) -> bool { + sql_statements(kind, sql) + .iter() + .any(|statement| is_dangerous_statement(statement)) +} + +/// Structural SQL token. Literals, quoted identifiers and comments produce no +/// token, so their contents never read as keywords. +#[derive(Debug, PartialEq)] +enum SqlToken { + /// Unquoted word, uppercased. + Word(String), + Open, + Close, +} + +/// Splits `sql` into top-level statements of structural tokens. +fn sql_statements(kind: &str, sql: &str) -> Vec> { + // MySQL and MariaDB treat backslash as an escape inside quotes by default; + // the other dialects only escape by doubling the quote character. + let backslash_escapes = matches!(kind, "mysql" | "mariadb"); + let chars: Vec = sql.chars().collect(); + let mut statements = vec![Vec::new()]; + let mut index = 0; + while index < chars.len() { + let character = chars[index]; + let next = chars.get(index + 1).copied(); + let tokens = statements + .last_mut() + .expect("statement list is never empty"); + match character { + '-' if next == Some('-') => { + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + } + '/' if next == Some('*') => { + // PostgreSQL nests block comments; a nested opener in other + // dialects is rare enough that over-skipping is acceptable. + let mut depth = 0; + while index < chars.len() { + if chars[index] == '/' && chars.get(index + 1) == Some(&'*') { + depth += 1; + index += 2; + } else if chars[index] == '*' && chars.get(index + 1) == Some(&'/') { + depth -= 1; + index += 2; + if depth == 0 { + break; + } + } else { + index += 1; + } + } + } + '\'' => { + // PostgreSQL E'...' strings accept backslash escapes. + let escape_string = tokens.last() == Some(&SqlToken::Word("E".into())) + && index > 0 + && chars[index - 1].eq_ignore_ascii_case(&'e'); + if escape_string { + tokens.pop(); + } + index = skip_quoted(&chars, index, '\'', backslash_escapes || escape_string); + } + '"' => index = skip_quoted(&chars, index, '"', backslash_escapes), + '`' => index = skip_quoted(&chars, index, '`', false), + '[' if kind == "sqlserver" => index = skip_quoted(&chars, index, ']', false), + '$' => match dollar_quote_tag(&chars, index) { + Some(tag) => { + let start = index + tag.len(); + index = (start..=chars.len().saturating_sub(tag.len())) + .find(|&end| chars[end..end + tag.len()] == tag[..]) + .map_or(chars.len(), |end| end + tag.len()); + } + None => index += 1, + }, + '(' => { + tokens.push(SqlToken::Open); + index += 1; + } + ')' => { + tokens.push(SqlToken::Close); + index += 1; + } + ';' => { + statements.push(Vec::new()); + index += 1; + } + _ if character.is_alphanumeric() || character == '_' => { + let start = index; + while index < chars.len() + && (chars[index].is_alphanumeric() || matches!(chars[index], '_' | '$')) + { + index += 1; + } + let word: String = chars[start..index].iter().collect(); + tokens.push(SqlToken::Word(word.to_uppercase())); + } + _ => index += 1, + } + } + statements.retain(|tokens| !tokens.is_empty()); + statements +} + +/// Returns the index just past the closing `close` of a quoted run that opens +/// at `start`. A doubled closing character is an escaped literal character. +fn skip_quoted(chars: &[char], start: usize, close: char, backslash_escapes: bool) -> usize { + let mut index = start + 1; + while index < chars.len() { + if backslash_escapes && chars[index] == '\\' { + index += 2; + } else if chars[index] == close { + if chars.get(index + 1) == Some(&close) { + index += 2; + } else { + return index + 1; + } + } else { + index += 1; + } + } + chars.len() +} + +/// Returns the `$tag$` opener at `start`, if any. `$1` style placeholders are +/// not dollar quotes because a tag cannot start with a digit. +fn dollar_quote_tag(chars: &[char], start: usize) -> Option> { + let mut index = start + 1; + if chars + .get(index) + .is_some_and(|character| character.is_ascii_digit()) + { + return None; + } + while chars + .get(index) + .is_some_and(|character| character.is_alphanumeric() || *character == '_') + { + index += 1; + } + (chars.get(index) == Some(&'$')).then(|| chars[start..=index].to_vec()) +} + +fn is_dangerous_statement(tokens: &[SqlToken]) -> bool { + let word = |index: usize| match tokens.get(index) { + Some(SqlToken::Word(word)) => Some(word.as_str()), + _ => None, + }; + if word(0) == Some("RESTORE") { + return true; + } + let mut depths = Vec::with_capacity(tokens.len()); + let mut depth = 0usize; + for token in tokens { + if *token == SqlToken::Close { + depth = depth.saturating_sub(1); + } + depths.push(depth); + if *token == SqlToken::Open { + depth += 1; + } + } + (0..tokens.len()).any(|index| match word(index) { + // TRUNCATE(x, d) is MySQL's numeric function, not the statement. + Some("DROP" | "TRUNCATE") => tokens.get(index + 1) != Some(&SqlToken::Open), + // Only a verb opens a write: at statement start, as a subquery or CTE + // body after `(`, or as the main statement after a `WITH ... )` list. + // This excludes FOR UPDATE, ON DELETE CASCADE, GRANT UPDATE, trigger + // events and MERGE/upsert actions, which are bounded elsewhere. + Some("UPDATE" | "DELETE") + if index == 0 || matches!(tokens[index - 1], SqlToken::Open | SqlToken::Close) => + { + let clause_depth = depths[index]; + !tokens[index + 1..] + .iter() + .zip(&depths[index + 1..]) + .take_while(|(_, depth)| **depth >= clause_depth) + .any(|(token, depth)| { + *depth == clause_depth && *token == SqlToken::Word("WHERE".into()) + }) + } + _ => false, + }) } async fn apply_changes( @@ -4198,14 +4397,96 @@ mod tests { #[test] fn write_protection_classifies_dangerous_sql() { - assert!(is_dangerous_sql("RESTORE")); - assert!(is_dangerous_sql("DROP TABLE users")); - assert!(is_dangerous_sql("UPDATE users SET active = 0")); + for sql in [ + "RESTORE", + "DROP TABLE users", + "drop table users", + "TRUNCATE users", + "ALTER TABLE users DROP COLUMN email", + "UPDATE users SET active = 0", + "DELETE FROM users", + "DELETE\nFROM users", + // Comments must not hide the verb from the check. + "-- cleanup\nDELETE FROM users", + "/* cleanup */ DELETE FROM users", + // A WHERE in a comment, literal or subquery does not bound the write. + "DELETE FROM users -- WHERE id = 1", + "UPDATE users SET note = 'WHERE id = 1'", + "UPDATE users SET rank = (SELECT max(rank) FROM ranks WHERE ranks.id = 1)", + "DELETE FROM \"where\"", + "WITH stale AS (SELECT id FROM users WHERE active = 0) DELETE FROM users", + "WITH gone AS (DELETE FROM users RETURNING id) SELECT count(*) FROM gone", + "SELECT 1; DELETE FROM users", + "UPDATE users SET active = 0 WHERE id = 1; DELETE FROM users", + ] { + assert!(is_dangerous_sql("postgresql", sql), "{sql}"); + } + for sql in [ + "SELECT * FROM users", + "INSERT INTO users (name) VALUES ('drop table')", + // Regression: a WHERE on its own line or after a tab used to be missed. + "UPDATE users SET active = 0 WHERE id = 1", + "UPDATE users\nSET active = 0\nWHERE id = 1", + "DELETE FROM users\tWHERE id = 1", + "DELETE FROM users\nwhere id = 1;", + "WITH stale AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM stale)", + "SELECT * FROM users FOR UPDATE", + "CREATE TABLE posts (author_id INT REFERENCES users(id) ON DELETE CASCADE)", + "INSERT INTO users (id) VALUES (1) ON DUPLICATE KEY UPDATE id = id", + "GRANT UPDATE, DELETE ON users TO app", + "SELECT TRUNCATE(1.25, 1)", + "SELECT $$DELETE FROM users$$", + "SELECT $body$ DROP TABLE users $body$", + "SELECT * FROM users WHERE id = $1", + "/* outer /* nested */ DROP TABLE users */ SELECT 1", + "", + "-- only a comment", + ] { + assert!(!is_dangerous_sql("postgresql", sql), "{sql}"); + } + } + + #[test] + fn write_protection_check_matches_execute_guard_without_connecting() { + let request = |production_protection: bool, sql: &str| { + json!({ + "connection": {"kind": "postgresql", "productionProtection": production_protection}, + "sql": sql, + }) + }; + let requires = |value: Value| { + check_write_protection(value).unwrap()["requiresConfirmation"] + .as_bool() + .unwrap() + }; + assert!(requires(request(true, "UPDATE users SET active = 0"))); + assert!(!requires(request( + true, + "UPDATE users SET active = 0\nWHERE id = 1" + ))); + assert!(!requires(request(false, "DROP TABLE users"))); + } + + #[test] + fn write_protection_lexes_quotes_per_dialect() { + // MySQL backslash-escapes quotes, so the WHERE stays inside the literal. + let sql = r"UPDATE users SET note = 'it\' WHERE 1 = 1 --'"; + assert!(is_dangerous_sql("mysql", sql)); + assert!(is_dangerous_sql("mariadb", sql)); + // Standard strings end at the first quote; E'' strings escape like MySQL. assert!(!is_dangerous_sql( - "UPDATE users SET active = 0 WHERE id = 1" + "postgresql", + r"UPDATE users SET path = 'C:\' WHERE id = 1" + )); + assert!(is_dangerous_sql( + "postgresql", + r"UPDATE users SET note = E'it\' WHERE 1 = 1 --'" )); + // SQL Server bracket identifiers can contain keywords. + assert!(is_dangerous_sql("sqlserver", "DELETE FROM [where]")); assert!(!is_dangerous_sql( - "INSERT INTO users (name) VALUES ('drop table')" + "sqlserver", + "DELETE FROM [users] WHERE [id] = 1" )); } diff --git a/shared/platform-feature-matrix/features/database-query-history.json b/shared/platform-feature-matrix/features/database-query-history.json index 892850cd7..b47f9693c 100644 --- a/shared/platform-feature-matrix/features/database-query-history.json +++ b/shared/platform-feature-matrix/features/database-query-history.json @@ -5,7 +5,10 @@ "capability": "SQL 查询、历史与分页", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database" + "macos/Sources/Lithe/Views/Database", + "macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift", + "rust/lithe-db-sidecar/src/main.rs", + "macos/Tests/LitheTests/LitheCoreLogicTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -18,5 +21,5 @@ "verificationStatus": "not-applicable" }, "owner": "Database", - "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。" + "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。 macOS 生产保护连接:带 WHERE 的 UPDATE/DELETE(WHERE 换行或缩进)直接执行不弹窗;无 WHERE、WHERE 只在子查询/注释/字符串里、WITH … DELETE、DROP/TRUNCATE(含 ALTER … DROP)在第一条语句执行前弹出确认框,确认后整批执行一次;SELECT … FOR UPDATE 不弹窗。危险判定以数据库 helper(lithe-db-sidecar)为准,执行 cargo test -p lithe-db-sidecar write_protection 与 LitheCoreLogicTests/databaseSQLConfirmation。" } From 46fcda5c72cb4969030cbe036d5bd8575c022c78 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 05:22:06 -0700 Subject: [PATCH 04/16] =?UTF-8?q?docs(notes):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8F=92=E4=BB=B6=E5=8C=96=E7=9A=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E3=80=81=E8=BF=9C=E7=A8=8B=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=20Windows=20=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-24-database-provider-plugin.md | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md index aa12ed262..0c92a0835 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +++ b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md @@ -82,7 +82,8 @@ ### 三、插件身份与分发 -- 使用官方 ID(`dev.lithe.plugin.database`),带 Lithe 发布者签名。原因是插件会经手用户的数据库密码,不能使用不需要 Lithe 签名的社区插件身份。这要求组织仓库的发布 CI 能使用发布者私钥,或者把签名步骤交给主仓库的发布流程完成。 +- 使用官方 ID(`dev.lithe.plugin.database`),带 Lithe 发布者签名。原因是插件会经手用户的数据库密码,不能使用不需要 Lithe 签名的社区插件身份。 +- 签名在组织仓库的发布 CI 中完成:发布者私钥只作为插件仓库的 CI secret 存在,插件自己出包、签名、登记索引;主仓库的发布流程不参与插件出包。 - 每个版本按平台发布下载包(`darwin-arm64`、`darwin-x64`,Windows 就绪后再加 Windows 的包),在插件索引里登记。网页界面和清单在各平台通用,只有 Rust 可执行文件按平台编译。 - 构建阶段固定依赖并签名,规则沿用[LSP 插件构建与资源归属](../../implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。安装目录只读,运行状态写进平台存储。 @@ -111,7 +112,7 @@ ### 七、Windows - **现在就做:** 功能矩阵把 Windows 的数据库能力改为 `missing`;停止把 `.db`、`.sqlite`、`.duckdb` 文件交给数据库查看器;README 注明数据库目前只支持 macOS。 -- **#1424 对齐:** Windows 声明式插件宿主按第四节的同一份契约实现网页桥接、插件后端、凭据和权限。之后由插件仓库补 Windows 构建、WebView2 回归测试和发布。 +- **暂不适配:** 这次改造不为 Windows 做任何插件侧适配,也不要求 #1424 现在对齐。等 Windows 插件宿主和插件市场改造完成后,再按当时的宿主能力小幅调整插件逻辑,补 Windows 构建、WebView2 回归测试和发布。 - 现有 9400 行 Windows 数据库前端不迁移,跟随主仓库的数据库代码一起删除。暂不支持 DuckDB。 ### 八、分期 @@ -123,9 +124,13 @@ | 3 | macOS 宿主实现第 2 期能力;`lithe-plugin-docs` 新增 Rust SDK | 第 2 期 | | 4 | 新建 `lithe-plugin-database`:Rust 后端、MCP、网页界面、样例;先在 macOS 上以本地开发包验证 | 第 3 期 | | 5 | macOS 发布插件,加入迁移提示,从主仓库删除数据库代码 | 第 4 期 | -| 6 | Windows 宿主按同一契约实现(#1424),Windows 插件市场;插件补 Windows 构建后发布 | 第 2 期、#1424 | +| 6 | Windows 插件宿主和插件市场就绪后,按当时的宿主能力小幅调整插件,补 Windows 构建后发布 | Windows 插件市场 | -第 1 到第 5 期不需要等待 Windows。第 6 期只补 Windows 构建与测试,插件本体不重写。 +第 1 到第 5 期不需要等待 Windows,全部在同一个 PR 中分步提交。第 6 期只做小幅调整和 Windows 构建,插件本体不重写。 + +### 九、远程开发 + +第一版数据库后端只在本机运行,连接从本机发起;需要访问远端网络中的数据库时,使用连接自带的 SSH 隧道。是否让后端随远程开发会话运行在远端,等远程开发的插件运行模型确定后再评估。 ### 正确做法 @@ -169,10 +174,10 @@ ## 风险 -- **Windows 进度:** 第 6 期依赖 #1424,而 #1424 当前仍有安全阻塞。缓解方式是尽早让它按第四节的契约实现,避免两边各做一套再返工。 +- **Windows 进度:** 第 6 期依赖 Windows 插件宿主和插件市场。插件只依赖公开契约,届时按 Windows 宿主的实际能力调整,不预先适配。 - **网页界面体验:** macOS 上不再是原生 SwiftUI。要使用宿主注入的主题和语言上下文,尽量贴近宿主风格;数据表格和 SQL 编辑器要在插件里自带组件,包体积和性能需要实测。 - **凭据能力的安全边界:** 宿主凭据是第一项宿主能力,权限、隔离和审查必须先写进契约和样例,不能先实现后补。 -- **签名私钥:** 官方签名需要在组织仓库的 CI 中使用发布者私钥,或者由主仓库代签。要先确定私钥的保管方式。 +- **签名私钥:** 私钥放在组织仓库 CI secret 中,泄露面扩大到插件仓库的 CI。只允许发布工作流在受保护分支和标签上读取它,PR 构建不能接触私钥。 - **升级断档:** 第 5 期必须同时具备插件、迁移和提示;任何一项没有完成,都不能从主包删除数据库。 ## 已定决策 @@ -186,13 +191,15 @@ - macOS 先上线,Windows 等宿主和插件市场就绪后跟上; - 老用户提示一键安装,不自动下载; - 现有 Windows 数据库前端不迁移; -- 暂不支持 DuckDB。 +- 暂不支持 DuckDB; +- 签名在组织仓库 CI 中完成,私钥是插件仓库的受保护 secret; +- 远程开发第一版只在本机运行数据库后端,远端数据库通过 SSH 隧道访问; +- Windows 这次不做插件侧适配,等 Windows 插件市场就绪后再小幅调整; +- 第 1 到第 5 期在同一个 PR 中分步提交,不拆成多个小 PR。 ## 待确认 -- 发布者私钥放在组织仓库的 CI 中,还是由主仓库代签? -- `packaged` 按平台定位可执行文件的具体方式。 -- 远程开发时,数据库后端是否需要在远端运行。 +- `packaged` 按平台定位可执行文件的具体方式,在契约提交中确定。 ## 适用范围 From 8471b99835e52929623f5c2553ca8982ebcdb655 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 05:49:29 -0700 Subject: [PATCH 05/16] =?UTF-8?q?feat(plugins):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=83=BD=E5=8A=9B=E4=B8=8E=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=20v2=20=E5=AE=BF=E4=B8=BB=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 level 3 能力 `backend` 1:插件自有的长驻进程,复用 serverProcess 启动字段与安装审查(packagedBackend 等),需 `backend.launch` 权限和 web 工具窗,不绑定 workspace(拒绝 ${workspaceStateDir}) - 新增权限 `credentials.store`,无 backend 时声明即无效 - launch plan 支持 `backend: true` 选择器,返回 `backend.hostServices`; `workspaceStateDirectory` 改为可选,仅在进程用到时必填 - 插件函数协议 v2:`lithe/host/credentials/{get,set,delete}`,Core 校验 版本、授权与参数(key 字母表/长度、secret ≤16 KiB、拒绝未知字段), 通过 `hostRequest` 事件交给宿主,宿主用新命令 `lsp.hostResponse` 应答; 每会话最多 16 个未应答请求 - `lsp.startServer.hostServices` 传入授予的服务 - 契约 README、rust-core-api、schema、fixture 与黄金用例同步更新 Co-Authored-By: Claude Opus 5.5 --- rust/lithe-core/src/lsp/interface/client.rs | 41 ++- rust/lithe-core/src/lsp/interface/engine.rs | 293 +++++++++++++++++- .../lsp/interface/engine_real_jdt_tests.rs | 1 + .../src/lsp/interface/plugin_function.rs | 185 ++++++++++- rust/lithe-core/src/lsp/interface/types.rs | 10 + rust/lithe-core/src/plugins/backend.rs | 88 ++++++ rust/lithe-core/src/plugins/capabilities.rs | 5 + rust/lithe-core/src/plugins/declarative.rs | 143 ++++++++- rust/lithe-core/src/plugins/mod.rs | 1 + rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 12 + .../src/tests/declarative_plugins.rs | 93 ++++++ scripts/verify-shared-contracts.sh | 3 +- shared/contracts/plugins/v1/README.md | 133 ++++++-- .../plugins/v1/fixtures/backend.plugin.json | 49 +++ .../v1/fixtures/function-protocol.json | 91 +++++- .../v1/fixtures/invalid-manifests.json | 54 ++++ .../plugins/v1/plugin-manifest.schema.json | 15 +- shared/contracts/rust-core-api.md | 23 +- 19 files changed, 1188 insertions(+), 56 deletions(-) create mode 100644 rust/lithe-core/src/plugins/backend.rs create mode 100644 shared/contracts/plugins/v1/fixtures/backend.plugin.json diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index eff141681..b56d5918d 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -532,6 +532,38 @@ pub(super) fn client_apply_server_message_for_plugin( responses.push(json_rpc_result(id, Value::Null)?); } } + _ if plugin_owned && request_id.is_some() && method.starts_with("lithe/host/") => { + let id = request_id.expect("guarded above"); + match plugin_function::host_request( + method, + message.get("params"), + state.plugin_function_version.unwrap_or(0), + &state.host_services, + ) { + // The engine answers once the host has; the request + // carries the backend's own ID back to it. + Ok(service) => events.push(LspClientEvent { + kind: "hostRequest".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "id": id, + "service": service, + "params": message.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + }), + Err(refusal) => responses.push(json_rpc_error_with_code( + id, + refusal.rpc_code, + refusal.message, + refusal.code, + )?), + } + } _ => { if let Some(id) = request_id { responses.push(match plugin_function::refused_backend_request(method) { @@ -575,8 +607,9 @@ pub(super) fn client_apply_server_message_for_plugin( ) }) .flatten(); - if let Some(functions) = functions { + if let Some((version, functions)) = functions { state.plugin_functions = functions; + state.plugin_function_version = Some(version); events.push(LspClientEvent { kind: "functionsChanged".to_string(), request_id: None, @@ -589,6 +622,7 @@ pub(super) fn client_apply_server_message_for_plugin( }); } else { state.plugin_functions.clear(); + state.plugin_function_version = None; state .server_capabilities .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); @@ -643,6 +677,7 @@ pub(super) fn client_apply_server_message_for_plugin( state.diagnostics.clear(); state.diagnostic_versions.clear(); state.plugin_functions.clear(); + state.plugin_function_version = None; state.vcs_provider = None; responses.push(json_rpc_message_without_params(None, "exit")?); } @@ -734,7 +769,7 @@ fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result Result { +pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result { encode_json_rpc(json!({ "jsonrpc": "2.0", "id": id, @@ -756,7 +791,7 @@ fn experimental_capabilities(plugin_owned: bool) -> Value { } /// A JSON-RPC error whose `data.code` carries a stable plugin-contract code. -fn json_rpc_error_with_code( +pub(super) fn json_rpc_error_with_code( id: &Value, code: i64, message: &str, diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 0daeae5a9..c53eb2f90 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -1,6 +1,9 @@ //! Stateful language-server sessions coordinating client state and child processes. -use super::client::{client_apply_server_message_for_plugin, client_initialize_for_plugin}; +use super::client::{ + client_apply_server_message_for_plugin, client_initialize_for_plugin, json_rpc_error_with_code, + json_rpc_result, +}; use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use super::{ client_change_document, client_close_document, client_feature_request_canonical, @@ -99,6 +102,12 @@ pub struct StartServerRequest { /// host-owned servers leave it out. #[serde(default)] pub plugin_id: Option, + /// Host services the plugin's permissions grant the session, as the + /// launch plan lists them (`backend.hostServices`). Only a plugin-owned + /// session may name any; Core forwards only these `lithe/host/*` + /// requests to the host. + #[serde(default)] + pub host_services: Vec, pub executable_path: String, #[serde(default)] pub arguments: Vec, @@ -425,6 +434,30 @@ pub struct VcsProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// The host's answer to one `hostRequest` event: the outcome of a host +/// service a plugin backend asked for. Exactly one of `result` and `error`. +pub struct PluginHostResponse { + pub session_id: String, + /// The `hostRequestID` of the `hostRequest` event being answered. + pub host_request_id: String, + /// The service's result; `null` for a write or delete. + #[serde(default)] + pub result: Option, + /// The host could not perform the service. Its message reaches the + /// backend, bounded; the code is always `plugin.host.failed`. + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Why the host could not perform a forwarded host-service request. +pub struct PluginHostFailure { + pub message: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-function invocation. The result arrives as a `requestCompleted` @@ -692,6 +725,10 @@ struct SessionState { deadline_cancellations: Vec, /// Bounds an outbound maintenance write independently of caller deadlines. maintenance_write_deadline: Option, + /// Backend requests for a host service awaiting `lsp.hostResponse`, by + /// the opaque ID the host sees, with the backend's own JSON-RPC ID. + host_requests: BTreeMap, + next_host_request_id: u64, } struct RuntimeSession { @@ -1273,6 +1310,14 @@ pub fn vcs_provider_request(request: VcsProviderRequest) -> Result Result<(), CoreError> { + engine() + .session(&request.session_id)? + .host_response(request) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -1612,7 +1657,10 @@ impl LspEngine { outbound_order: Mutex::new(()), state: Mutex::new(SessionState { lifecycle: LspLifecycleState::Created, - client: initialize.state, + client: LspClientState { + host_services: request.host_services.into_iter().collect(), + ..initialize.state + }, pending: BTreeMap::from([( request_id, PendingRequest { @@ -1656,6 +1704,8 @@ impl LspEngine { java_build_timeout, deadline_cancellations: Vec::new(), maintenance_write_deadline: None, + host_requests: BTreeMap::new(), + next_host_request_id: 1, }), event_signal: Condvar::new(), process: process.handle, @@ -2243,6 +2293,36 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + /// Sends the backend the host's answer to one forwarded host-service + /// request. An unknown ID, including one already answered or of a session + /// that restarted, is refused so a late answer never reaches another + /// request. + fn host_response(&self, response: PluginHostResponse) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let message = { + let mut state = self.lock_state()?; + let backend_id = state + .host_requests + .remove(&response.host_request_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "No host-service request with this ID is waiting for an answer.", + ) + })?; + match (response.result, response.error) { + (_, Some(failure)) => json_rpc_error_with_code( + &backend_id, + -32603, + &super::plugin_function::bounded_host_message(&failure.message), + super::plugin_function::codes::HOST_FAILED, + )?, + (result, None) => json_rpc_result(&backend_id, result.unwrap_or(Value::Null))?, + } + }; + self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") + } + fn request_with_kind( &self, request: SemanticRequest, @@ -3697,6 +3777,13 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == "hostRequest" { + outbound.extend(forward_host_request_locked( + self, + &mut state, + event.method.unwrap_or_default(), + event.result.unwrap_or_default(), + )?); } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); } else if event.kind == "diagnostics" { @@ -4775,6 +4862,17 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> if request.provider_id.trim().is_empty() { return Err(invalid_field("providerId")); } + // A host-owned server is no plugin's backend, and a service Core does not + // know could never be forwarded. + if !request.host_services.is_empty() + && (request.plugin_id.is_none() + || request + .host_services + .iter() + .any(|service| service != super::plugin_function::HOST_SERVICE_CREDENTIALS)) + { + return Err(invalid_field("hostServices")); + } if request.executable_path.trim().is_empty() || request.executable_path.contains('\0') || request.working_directory.trim().is_empty() @@ -5481,6 +5579,62 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r } /// Publishes the session's registered functions after a registry change. +/// Publishes a backend's well-formed host-service request as `hostRequest`, +/// or answers it at once when the session already has the most requests the +/// host may owe it. Returns the messages to send to the backend. +fn forward_host_request_locked( + session: &RuntimeSession, + state: &mut SessionState, + method: String, + request: Value, +) -> Result, CoreError> { + let backend_id = request.get("id").cloned().unwrap_or(Value::Null); + if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { + return Ok(vec![json_rpc_error_with_code( + &backend_id, + -32603, + "Too many host-service requests are waiting for the host.", + super::plugin_function::codes::HOST_FAILED, + )?]); + } + let host_request_id = format!("host-{}", state.next_host_request_id); + state.next_host_request_id += 1; + state + .host_requests + .insert(host_request_id.clone(), backend_id); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "hostRequest".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: Some(method), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "hostRequestID": host_request_id, + "service": request.get("service").cloned().unwrap_or(Value::Null), + "params": request.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); + Ok(Vec::new()) +} + fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { let sequence = take_sequence(state); enqueue_runtime_event( @@ -6166,6 +6320,7 @@ mod tests { // `adapt_start` is covered by its own tests. provider_id: "gopls".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path: "/usr/bin/scripted-server".to_string(), arguments: vec!["--stdio".to_string()], environment: BTreeMap::new(), @@ -9739,6 +9894,7 @@ public class Main { .start_server(StartServerRequest { provider_id: "java".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path, arguments: Vec::new(), environment: BTreeMap::from([( @@ -10474,6 +10630,7 @@ public class Main { let request = StartServerRequest { provider_id: String::new(), plugin_id: None, + host_services: Vec::new(), executable_path: "/bin/server".to_string(), arguments: Vec::new(), environment: BTreeMap::new(), @@ -11500,7 +11657,7 @@ public class Main { initialize["params"]["capabilities"]["experimental"], json!({ "litheBuild": { "versions": [1] }, - "litheFunctions": { "versions": [1] }, + "litheFunctions": { "versions": [1, 2] }, "litheVcs": { "versions": [1] } }) ); @@ -12252,4 +12409,134 @@ public class Main { assert_eq!(reply["error"]["data"]["code"], code, "{method}"); } } + + fn host_service_backend(version: u64, host_services: &[&str]) -> Harness { + let host_services: Vec = host_services.iter().map(ToString::to_string).collect(); + let mut harness = Harness::start(move |request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = host_services.clone(); + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheFunctions": { "version": version } } + })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn host_response( + harness: &Harness, + host_request_id: &str, + result: Option, + error: Option<&str>, + ) -> Result<(), CoreError> { + harness.session().host_response(PluginHostResponse { + session_id: harness.session_id.clone(), + host_request_id: host_request_id.to_string(), + result, + error: error.map(|message| PluginHostFailure { + message: message.to_string(), + }), + }) + } + + /// A granted credential request reaches the host as `hostRequest`. The + /// backend receives exactly the host's answer under its own ID, and a + /// second answer to the same request is refused instead of reaching a + /// later request. + #[test] + fn a_granted_host_service_request_round_trips_through_the_host() { + let mut harness = host_service_backend(2, &["credentials"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let event = harness + .await_event(|event| event.kind == "hostRequest") + .clone(); + assert_eq!(event.method.as_deref(), Some("lithe/host/credentials/get")); + let request = event.result.expect("the request is described"); + assert_eq!(request["service"], "credentials"); + assert_eq!(request["params"], json!({ "key": "connection.prod" })); + let read_id = request["hostRequestID"].as_str().unwrap().to_string(); + host_response( + &harness, + &read_id, + Some(json!({ "secret": "s3cret" })), + None, + ) + .expect("the host answers once"); + assert_eq!( + await_reply(&harness, "read")["result"], + json!({ "secret": "s3cret" }) + ); + assert!(host_response(&harness, &read_id, None, None).is_err()); + + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "write", + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": "s3cret" } + })); + let event = harness + .await_event(|event| { + event.kind == "hostRequest" + && event.method.as_deref() == Some("lithe/host/credentials/set") + }) + .clone(); + let write_id = event.result.unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(write_id, read_id); + host_response(&harness, &write_id, None, Some("The keychain is locked.")) + .expect("the host reports its failure"); + let reply = await_reply(&harness, "write"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!(reply["error"]["message"], "The keychain is locked."); + } + + /// Host services start with protocol version 2 and only for the services + /// the plugin's permissions grant; refusals never reach the host. + #[test] + fn host_services_need_version_2_and_a_grant() { + for (version, services, code) in [ + (1, &["credentials"][..], "plugin.host.unavailable"), + (2, &[][..], "plugin.host.permissionDenied"), + ] { + let mut harness = host_service_backend(version, services); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!(reply["error"]["data"]["code"], code, "version {version}"); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + } + } + + /// A host that stops answering cannot make a backend grow the session's + /// state: requests beyond the bound fail at once. + #[test] + fn unanswered_host_requests_are_bounded() { + let harness = host_service_backend(2, &["credentials"]); + let limit = super::super::plugin_function::MAX_PENDING_HOST_REQUESTS; + for index in 0..=limit { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": format!("read-{index}"), + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + } + let reply = await_reply(&harness, &format!("read-{limit}")); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!( + harness.session().lock_state().unwrap().host_requests.len(), + limit + ); + } } diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index 167da2f1a..238407903 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -335,6 +335,7 @@ fn real_start_request( StartServerRequest { provider_id: "java".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path: toolchain .root .join("bin") diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 0ad3c1b11..e10f0f896 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -20,8 +20,11 @@ use std::collections::BTreeSet; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheFunctions"; -/// Plugin-function protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +/// Plugin-function protocol versions this client speaks. Version 2 keeps +/// every rule of version 1 and adds the host services of `host_request`. +const SUPPORTED_VERSIONS: [u64; 2] = [1, 2]; +/// First protocol version that offers host services. +const HOST_SERVICES_VERSION: u64 = 2; /// Feature name recorded in the session's server capabilities once a backend /// answered a supported version with a valid function list. @@ -49,6 +52,18 @@ const MAX_RESULT_BYTES: usize = 1 << 20; /// Upper bound of one invocation's deadline, matching build-provider requests. pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; +/// Host service that keeps secrets for the plugin, private to its ID. +pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; +const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; +const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; +const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; +/// Bound of one stored secret, in UTF-8 bytes; enough for a password, a +/// token, or a private key, not for documents. +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; +/// Host requests one session may have awaiting the host's answer. +pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; + /// Stable codes of the plugin-function contract. Synchronous refusals carry /// them in `CoreError::details`; asynchronous outcomes in the runtime error's /// `code`; refused backend requests in the JSON-RPC error's `data.code`. @@ -77,6 +92,14 @@ pub(crate) mod codes { pub const RECURSIVE_CALL: &str = "plugin.function.recursiveCall"; /// The backend asked for a host capability this host does not offer. pub const HOST_UNAVAILABLE: &str = "plugin.host.unavailable"; + /// The host offers the service, but the plugin's permissions do not + /// grant it to this session. + pub const HOST_PERMISSION_DENIED: &str = "plugin.host.permissionDenied"; + /// A host-service request has malformed or unknown params. + pub const HOST_INVALID_PARAMS: &str = "plugin.host.invalidParams"; + /// The host could not perform the service, or too many requests of + /// the session were already waiting for it. + pub const HOST_FAILED: &str = "plugin.host.failed"; } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -125,10 +148,11 @@ pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) } -/// Functions of a backend that answered a version this client offered, or -/// `None` when the protocol was not negotiated. A malformed function list -/// rejects the whole answer, so a session never starts with half a registry. -pub(crate) fn negotiate(capabilities: &Value) -> Option> { +/// The version and functions of a backend that answered a version this +/// client offered, or `None` when the protocol was not negotiated. A +/// malformed function list rejects the whole answer, so a session never +/// starts with half a registry. +pub(crate) fn negotiate(capabilities: &Value) -> Option<(u64, BTreeSet)> { let answer = capabilities .get("experimental") .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; @@ -136,10 +160,11 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option> { if !SUPPORTED_VERSIONS.contains(&version) { return None; } - match answer.get("functions") { - None => Some(BTreeSet::new()), - Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok(), - } + let functions = match answer.get("functions") { + None => BTreeSet::new(), + Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok()?, + }; + Some((version, functions)) } /// The registry after a `lithe/function/register` request. The request is @@ -311,6 +336,98 @@ pub(crate) fn refused_backend_request(method: &str) -> Option<&'static str> { } } +/// Why a backend's `lithe/host/*` request is not forwarded to the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostRequestRefusal { + /// JSON-RPC error code of the answer. + pub rpc_code: i64, + /// Stable contract code, sent as `error.data.code`. + pub code: &'static str, + pub message: &'static str, +} + +/// The host service a backend request asks for, or why it is refused. +/// `version` is the negotiated protocol version (0 when not negotiated) and +/// `granted` the services the plugin's permissions grant the session. The +/// params are checked here, so the host only ever sees a well-formed +/// request, and unknown params are refused so a later version can add them. +pub(crate) fn host_request( + method: &str, + params: Option<&Value>, + version: u64, + granted: &BTreeSet, +) -> Result<&'static str, HostRequestRefusal> { + let unavailable = HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_UNAVAILABLE, + message: "The host offers no such service to this plugin process.", + }; + if version < HOST_SERVICES_VERSION { + return Err(unavailable); + } + let (service, with_secret) = match method { + CREDENTIALS_GET | CREDENTIALS_DELETE => (HOST_SERVICE_CREDENTIALS, false), + CREDENTIALS_SET => (HOST_SERVICE_CREDENTIALS, true), + _ => return Err(unavailable), + }; + if !granted.contains(service) { + return Err(HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_PERMISSION_DENIED, + message: "The plugin does not declare the permission for this host service.", + }); + } + let invalid = |message| HostRequestRefusal { + rpc_code: -32602, + code: codes::HOST_INVALID_PARAMS, + message, + }; + let params = params + .and_then(Value::as_object) + .ok_or(invalid("Params must be an object."))?; + let allowed: &[&str] = if with_secret { + &["key", "secret"] + } else { + &["key"] + }; + if params.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(invalid("Params name a field this service does not define.")); + } + if !params + .get("key") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + if with_secret + && !params + .get("secret") + .and_then(Value::as_str) + .is_some_and(|secret| secret.len() <= MAX_CREDENTIAL_SECRET_BYTES) + { + return Err(invalid("A secret is a string of at most 16 KiB.")); + } + Ok(service) +} + +/// Key of a stored secret. The alphabet excludes path separators so a host +/// may use the key in a store item name without escaping. +fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) +} + +/// Truncates a host's failure message like a backend's. +pub(crate) fn bounded_host_message(value: &str) -> String { + bounded_message(value) +} + /// IDs of a descriptor array, refusing malformed descriptors, duplicates, /// IDs already in `existing`, and a registry beyond the session limit. fn descriptor_ids( @@ -453,7 +570,10 @@ mod tests { match expected["functions"].as_array() { Some(_) => assert_eq!( negotiated, - Some(id_set(&expected["functions"])), + Some(( + expected["version"].as_u64().unwrap_or(1), + id_set(&expected["functions"]) + )), "{}", name(&case) ), @@ -579,6 +699,49 @@ mod tests { } } + #[test] + fn host_requests_follow_the_fixture() { + for case in cases("hostRequests") { + let granted: BTreeSet = id_set(&case["hostServices"]); + let outcome = host_request( + case["method"].as_str().unwrap(), + case.get("params"), + case["version"].as_u64().unwrap(), + &granted, + ); + let expected = &case["expected"]; + match expected["forward"].as_str() { + Some(service) => assert_eq!(outcome, Ok(service), "{}", name(&case)), + None => { + let refusal = outcome.expect_err(name(&case)); + assert_eq!(refusal.code, expected["code"], "{}", name(&case)); + assert_eq!(refusal.rpc_code, expected["rpcCode"], "{}", name(&case)); + } + } + } + } + + /// The secret bound is inclusive and measured in UTF-8 bytes. + #[test] + fn credential_secrets_are_bounded() { + let granted = BTreeSet::from([HOST_SERVICE_CREDENTIALS.to_string()]); + let set = |secret: String| { + host_request( + CREDENTIALS_SET, + Some(&json!({ "key": "k", "secret": secret })), + 2, + &granted, + ) + }; + assert!(set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES)).is_ok()); + assert_eq!( + set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES + 1)) + .unwrap_err() + .code, + codes::HOST_INVALID_PARAMS + ); + } + /// A message over the bound is cut at a character boundary, never inside /// a multi-byte character. #[test] diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 5f1cd4e3d..fa4009175 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -139,6 +139,14 @@ pub struct LspClientState { /// negotiated; empty otherwise. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, + /// The `litheFunctions` version the backend chose; `None` when the + /// protocol was not negotiated. Version 2 adds host services. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_function_version: Option, + /// Host services the plugin's permissions grant this session, from + /// `lsp.startServer.hostServices`. Empty for host-owned sessions. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub host_services: BTreeSet, /// The VCS and capabilities a plugin process answered under /// `litheVcs`; `None` when the protocol was not negotiated. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -160,6 +168,8 @@ impl Default for LspClientState { diagnostics: BTreeMap::new(), diagnostic_versions: BTreeMap::new(), plugin_functions: BTreeSet::new(), + plugin_function_version: None, + host_services: BTreeSet::new(), vcs_provider: None, } } diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs new file mode 100644 index 000000000..e0a7f26c1 --- /dev/null +++ b/rust/lithe-core/src/plugins/backend.rs @@ -0,0 +1,88 @@ +//! `backend` of an API level 3 plugin manifest: one long-lived process a +//! plugin starts for its own pages, independent of any build system, +//! language, or workspace. +//! +//! A build provider or VCS provider exists for one domain contract and one +//! workspace. A backend serves only the plugin itself: its web pages call the +//! backend's [plugin functions](crate::lsp::interface::plugin_function), and +//! the backend may ask the host for the primitive services its permissions +//! grant, such as private credential storage. The host starts it on the first +//! call from one of the plugin's pages, keeps one process per plugin, and ends +//! it when the plugin is disabled, updated, or uninstalled. +//! +//! The process shares the launch fields, templates, and install review of +//! every plugin server process. It is not bound to a workspace, so +//! `${workspaceStateDir}` is refused. +//! +//! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +use super::declarative::{invalid, ServerProcess, ServerRole, Validation}; +use super::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// The plugin's own backend process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct BackendDeclaration { + #[serde(flatten)] + pub(crate) process: ServerProcess, +} + +/// Primitive host services a backend may request, each granted by one +/// manifest permission. +pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; + +/// What backend validation needs from the rest of the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, + /// Whether a tool window of the plugin shows web content, the only + /// caller of a backend in `backend` 1. + pub(crate) has_web_window: bool, +} + +impl BackendDeclaration { + pub(crate) fn validate(&self, context: &DeclarationContext<'_>) -> Validation<()> { + if context.plugin_api_version < 3 { + return Err(invalid( + "backend", + "A plugin backend requires plugin API level 3.", + )); + } + // Only the plugin's own pages call a backend in version 1, so one + // without a web window could never start. + if !context.has_web_window { + return Err(invalid( + "backend", + "A plugin backend serves the plugin's own web pages; declare a web tool window.", + )); + } + self.process.validate( + "backend", + context.plugin_api_version, + context.declared_settings, + ServerRole::Backend, + )?; + if self.process.uses_workspace_state_directory() { + return Err(invalid( + "backend", + "A plugin backend is not bound to a workspace and cannot use ${workspaceStateDir}.", + )); + } + Ok(()) + } +} + +/// Install-review entries: the backend process with the runtimes it needs. +pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { + let mut programs = vec![backend.process.program(ServerRole::Backend)]; + programs.extend(backend.process.runtime_programs()); + programs +} + +/// The `backend` member of a launch plan: the host services the session may +/// request, which the host passes to `lsp.startServer` unchanged. +pub(crate) fn plan_descriptor(host_services: &[&str]) -> Value { + json!({ "hostServices": host_services }) +} diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 67838335e..33e7a4aad 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -45,6 +45,10 @@ pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; /// process; see `crate::vcs`. pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; +/// `backend`: the plugin's own long-lived process serving its pages; see +/// `super::backend`. +pub(crate) const BACKEND: &str = "backend"; + /// `projectGenerators[]`: entries of the New Project window. pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; @@ -54,6 +58,7 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ + (BACKEND, &[1]), (BUILD_PROVIDERS, &[1]), (BUILD_SYSTEMS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 93585c0fe..1a4bd9518 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -50,8 +50,15 @@ const PERMISSION_VCS: &str = "process.vcs"; /// Creating projects: writing template files, running scaffolding tools, /// and downloading project archives. API level 3. const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; +/// Starting the plugin's own backend process. `backend` 1. +const PERMISSION_BACKEND: &str = "backend.launch"; +/// Letting the backend keep secrets in the host's credential store, private +/// to the plugin. `backend` 1. +const PERMISSION_CREDENTIALS: &str = "credentials.store"; const KNOWN_PERMISSIONS: &[&str] = &[ + PERMISSION_BACKEND, PERMISSION_BUILD, + PERMISSION_CREDENTIALS, PERMISSION_LANGUAGE_SERVER, PERMISSION_PROJECT_GENERATE, PERMISSION_RUN, @@ -172,6 +179,10 @@ pub struct DeclarativePluginManifest { /// validation live in `crate::project_generators`. #[serde(default, skip_serializing_if = "Vec::is_empty")] project_generators: Vec, + /// The plugin's own process serving its pages. `backend` 1; see + /// `super::backend`. + #[serde(default, skip_serializing_if = "Option::is_none")] + backend: Option, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -442,6 +453,7 @@ pub(crate) enum ServerRole { LanguageServer, BuildProvider, VcsProvider, + Backend, } impl ServerRole { @@ -450,6 +462,7 @@ impl ServerRole { Self::LanguageServer => "language server", Self::BuildProvider => "build provider", Self::VcsProvider => "VCS provider", + Self::Backend => "plugin backend", } } } @@ -950,6 +963,16 @@ impl DeclarativePluginManifest { declared_settings: &declared_settings, }, )?; + if let Some(backend) = &self.backend { + backend.validate(&super::backend::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + has_web_window: self + .ui + .as_ref() + .is_some_and(|ui| ui.tool_windows.iter().any(|window| window.web.is_some())), + })?; + } self.validate_permissions()?; self.validate_activation_events()?; Ok(self) @@ -985,6 +1008,7 @@ impl DeclarativePluginManifest { capabilities::SERVER_PROCESS, !self.language_servers.is_empty() || !self.vcs_providers.is_empty() + || self.backend.is_some() || self .build_systems .iter() @@ -1011,6 +1035,7 @@ impl DeclarativePluginManifest { .any(|system| system.provider.is_some()), ), (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), + (capabilities::BACKEND, self.backend.is_some()), ]; sections .into_iter() @@ -1176,6 +1201,16 @@ impl DeclarativePluginManifest { if !self.project_generators.is_empty() { required.insert(PERMISSION_PROJECT_GENERATE); } + if self.backend.is_some() { + required.insert(PERMISSION_BACKEND); + } else if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + // Only a backend can use the store; listing the permission + // without one would show the user a grant nothing exercises. + return Err(invalid( + "permissions", + "credentials.store is used by a plugin backend; declare one or remove the permission.", + )); + } for permission in required { if !self .permissions @@ -1295,6 +1330,16 @@ impl DeclarativePluginManifest { &self.vcs_providers } + /// Host services the plugin's backend may request, sorted; empty without + /// a backend. Each is granted by one permission. + pub(crate) fn backend_host_services(&self) -> Vec<&'static str> { + let mut services = Vec::new(); + if self.backend.is_some() && self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + services.push(super::backend::HOST_SERVICE_CREDENTIALS); + } + services + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -1450,6 +1495,9 @@ impl DeclarativePluginManifest { for generator in &self.project_generators { programs.extend(crate::project_generators::programs(generator)); } + if let Some(backend) = &self.backend { + programs.extend(super::backend::programs(backend)); + } // Order by kind, then language or tool, then the full serialized // value as a total tie-breaker; duplicates (two servers needing the // same runtime) collapse to one entry. @@ -1876,6 +1924,7 @@ impl ServerProcess { ServerRole::LanguageServer => "LanguageServer", ServerRole::BuildProvider => "BuildProvider", ServerRole::VcsProvider => "VcsProvider", + ServerRole::Backend => "Backend", }; let mut program = match &self.launch { LanguageServerLaunch::Packaged { path } => json!({ @@ -1914,6 +1963,46 @@ impl ServerProcess { program } + /// Whether any start template names `${workspaceStateDir}`, which only + /// a process bound to one workspace can be given. + pub(crate) fn uses_workspace_state_directory(&self) -> bool { + let runtime_arguments = match &self.launch { + LanguageServerLaunch::Runtime { + runtime_arguments, .. + } => runtime_arguments.as_slice(), + _ => &[], + }; + let mentions = |text: &str| { + parse_template_with(text, LaunchVariable::parse) + .unwrap_or_default() + .iter() + .any(|part| { + matches!( + part, + TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) + ) + }) + }; + fn strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { + match value { + Value::String(text) => out.push(text), + Value::Array(items) => items.iter().for_each(|item| strings(item, out)), + Value::Object(members) => members.values().for_each(|item| strings(item, out)), + _ => {} + } + } + let mut option_strings = Vec::new(); + if let Some(options) = &self.initialization_options { + strings(options, &mut option_strings); + } + self.arguments + .iter() + .chain(runtime_arguments) + .map(String::as_str) + .chain(option_strings) + .any(mentions) + } + /// Install-review entries of the runtimes the host discovers for it. pub(crate) fn runtime_programs(&self) -> Vec { self.runtime_requirements() @@ -2591,7 +2680,10 @@ pub struct LanguageServerLaunchPlanRequest { /// Absolute writable directory the host keeps for this plugin's server. state_directory: String, /// Absolute writable directory for the server's state of this workspace. - workspace_state_directory: String, + /// A host without a workspace, such as one starting a plugin backend, + /// omits it; a launch that names it then has no usable plan. + #[serde(default)] + workspace_state_directory: Option, /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. architecture: String, /// Values the user stored for the plugin's settings, by setting ID. A @@ -2780,6 +2872,24 @@ fn vcs_provider_launch_plan( Ok(plan) } +/// The launch plan of the plugin's backend. `backend.hostServices` lists the +/// host services its permissions grant, which the host passes to +/// `lsp.startServer` so Core admits only those requests. +fn backend_launch_plan( + manifest: &DeclarativePluginManifest, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let backend = manifest.backend.as_ref().ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + "The plugin declares no backend.", + ) + })?; + let mut plan = server_launch_plan(manifest, &backend.process, None, request)?; + plan["backend"] = super::backend::plan_descriptor(&manifest.backend_host_services()); + Ok(plan) +} + fn server_launch_plan( manifest: &DeclarativePluginManifest, process: &ServerProcess, @@ -2790,10 +2900,16 @@ fn server_launch_plan( let values = LaunchValues { plugin_directory: launch_directory(&request.plugin_directory, "pluginDirectory")?, state_directory: launch_directory(&request.state_directory, "stateDirectory")?, - workspace_state_directory: launch_directory( - &request.workspace_state_directory, - "workspaceStateDirectory", - )?, + workspace_state_directory: match &request.workspace_state_directory { + Some(directory) => launch_directory(directory, "workspaceStateDirectory")?, + None if process.uses_workspace_state_directory() => { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + "The launch request has no usable workspaceStateDirectory.", + )) + } + None => "", + }, architecture: &request.architecture, settings: &effective_settings, }; @@ -3326,6 +3442,9 @@ struct LaunchPlanPayload { build_system_id: Option, #[serde(default, rename = "vcsProviderID")] vcs_provider_id: Option, + /// Selects the plugin's backend; `backend` 1. + #[serde(default)] + backend: bool, request: LanguageServerLaunchPlanRequest, } @@ -3333,6 +3452,7 @@ enum LaunchSelection<'a> { Language(&'a str), BuildSystem(&'a str), VcsProvider(&'a str), + Backend, } pub(crate) fn decode_payload Deserialize<'de>>( @@ -3419,16 +3539,20 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result LaunchSelection::Language(language_id), - (None, Some(build_system_id), None) => LaunchSelection::BuildSystem(build_system_id), - (None, None, Some(vcs_id)) => LaunchSelection::VcsProvider(vcs_id), + (Some(language_id), None, None, false) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None, false) => LaunchSelection::BuildSystem(build_system_id), + (None, None, Some(vcs_id), false) => LaunchSelection::VcsProvider(vcs_id), + (None, None, None, true) => LaunchSelection::Backend, _ => { return Err(CoreError::new( ErrorCode::InvalidRequest, format!("Invalid {COMMAND} payload"), ) - .with_details("Name exactly one of languageID, buildSystemID, and vcsProviderID.")) + .with_details( + "Name exactly one of languageID, buildSystemID, vcsProviderID, and backend.", + )) } }; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -3443,6 +3567,7 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result { vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) } + LaunchSelection::Backend => backend_launch_plan(&manifest, &payload.request), } .map(|plan| ("plan", plan)) }, diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index 129d67295..a7ea21d89 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -1,5 +1,6 @@ //! Plugin manifest parsing, compatibility checks, and deterministic catalog merging. +pub(crate) mod backend; pub mod capabilities; pub mod declarative; pub(crate) mod executables; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index e2d5e08f0..7d87d668b 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -300,6 +300,8 @@ pub enum CoreCommand { LspVcsRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, + /// Answers a plugin backend's host-service request (`lsp.hostResponse`). + LspHostResponse, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). JavaNavigationMarkers, /// Queues click-time Java navigation resolution (`java.resolveNavigation`). @@ -611,6 +613,7 @@ impl CoreCommand { "lsp.buildRequest" => Some(Self::LspBuildRequest), "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), + "lsp.hostResponse" => Some(Self::LspHostResponse), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), "lsp.pollEvents" => Some(Self::LspPollEvents), @@ -717,6 +720,7 @@ mod tests { "lsp.buildRequest", "lsp.vcsRequest", "lsp.functionRequest", + "lsp.hostResponse", "lsp.cancelOperation", "lsp.pollEvents", "lsp.waitEvents", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index a4afb0159..c33c2d39d 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1719,6 +1719,18 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspHostResponse => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host-service response") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::plugin_host_response) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspCancelOperation => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 16952c195..c8629895f 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -29,6 +29,7 @@ const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.pl const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); +const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/runtime-versions.json"); @@ -52,6 +53,7 @@ fn fixture(name: &str) -> Value { "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, + "backend.plugin.json" => BACKEND_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -70,6 +72,7 @@ fn contract_fixtures_and_official_plugins_validate() { WEB_PANEL_FIXTURE, JAVA_FIXTURE, NODE_RUNTIME_FIXTURE, + BACKEND_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); @@ -605,6 +608,96 @@ fn web_tool_windows_need_ui_2_their_permission_and_a_package_page() { } } +/// A plugin backend is a reviewed server process of the plugin itself. It is +/// planned without a workspace, its plan names exactly the host services its +/// permissions grant, and a host that does not run `backend` never gets it. +#[test] +fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { + let outcome = validate(fixture("backend.plugin.json")); + assert_eq!(outcome["status"], "ok", "{outcome}"); + assert_eq!( + outcome["plugin"]["backend"]["launch"], + json!({ "kind": "packaged", "path": "bin/backend" }) + ); + + let plan_for = |manifest: Value, request: Value| { + language_server_launch_plan_command(json!({ + "manifest": manifest, + "backend": true, + "request": request, + })) + .expect("payload is well formed") + }; + let request = json!({ + "pluginDirectory": "/plugins/backend", + "stateDirectory": "/state/backend", + "architecture": "arm64" + }); + let launch = plan_for(fixture("backend.plugin.json"), request.clone()); + assert_eq!(launch["status"], "ok", "{launch}"); + assert_eq!( + launch["plan"]["launch"], + json!({ "kind": "packaged", "path": "bin/backend" }) + ); + assert_eq!( + launch["plan"]["arguments"], + json!(["--state", "/state/backend"]) + ); + assert_eq!(launch["plan"]["requiredDirectories"], json!(["state"])); + assert_eq!( + launch["plan"]["backend"], + json!({ "hostServices": ["credentials"] }) + ); + + // Without credentials.store the session may request no host service. + let mut manifest = fixture("backend.plugin.json"); + manifest["permissions"] = json!(["backend.launch", "ui.contribute", "ui.web"]); + let launch = plan_for(manifest, request.clone()); + assert_eq!(launch["plan"]["backend"], json!({ "hostServices": [] })); + + // A plugin without a backend has nothing to start. + let launch = plan_for(fixture("web-panel.plugin.json"), request.clone()); + assert_eq!(launch["error"]["code"], "plugin.launch.unavailable"); + + // A selection names one process only. + assert!(language_server_launch_plan_command(json!({ + "manifest": fixture("backend.plugin.json"), + "backend": true, + "languageID": "php", + "request": request, + })) + .is_err()); + + let review = crate::plugins::marketplace::install_review_command(json!({ + "manifest": fixture("backend.plugin.json"), + })) + .expect("payload is well formed"); + assert!(review["review"]["programs"] + .as_array() + .unwrap() + .contains(&json!({ + "kind": "packagedBackend", + "path": "bin/backend", + "arguments": ["--state", "${stateDir}"], + "validationArguments": [], + }))); + + let hosted = |host: Value| { + validate_command(json!({ + "manifest": fixture("backend.plugin.json"), + "hostCapabilities": host, + })) + .expect("payload is well formed") + }; + let without_backend = hosted(json!({ "ui": [1, 2], "serverProcess": [1] })); + assert_eq!(without_backend["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_backend["error"]["field"], "requires.backend"); + assert_eq!( + hosted(json!({ "backend": [1], "ui": [1, 2], "serverProcess": [1] }))["status"], + "ok" + ); +} + /// Icon trust boundaries are shared; native decoding remains with the macOS host. #[test] fn ui_icon_declarations_validate_and_preserve_theme_resources() { diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 721107910..df5675051 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -218,7 +218,7 @@ done functions = JSON.parse(File.read(File.join(root, "fixtures", "function-protocol.json"))) abort "function protocol fixture schema must be 1" unless functions.fetch("schemaVersion") == 1 abort "function protocol fixture must describe litheFunctions" unless functions.fetch("protocol") == "litheFunctions" - function_sections = %w[negotiation registration invocations outcomes progress backendRequests] + function_sections = %w[negotiation registration invocations outcomes progress backendRequests hostRequests] function_names = function_sections.flat_map { |section| functions.fetch(section).map { |item| item.fetch("name") } } abort "function protocol case names must be unique" unless function_names.uniq.length == function_names.length function_codes = [] @@ -229,6 +229,7 @@ done functions.fetch("invocations").each { |item| function_codes << item.fetch("expected")["code"] } functions.fetch("outcomes").each { |item| function_codes << item.fetch("expected").fetch("error", {})["code"] } functions.fetch("backendRequests").each { |item| function_codes << item.fetch("expected")["code"] } + functions.fetch("hostRequests").each { |item| function_codes << item.fetch("expected")["code"] } function_codes.compact.uniq.each do |code| abort "function protocol fixture uses an undocumented code #{code}" unless documented.include?(code) end diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 4cb483051..2b17044c2 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -194,15 +194,18 @@ contains a machine path, and the host decides where state lives. | --- | --- | | `${pluginDir}` | Root of the installed, read-only package. It must be used at most once per value and be followed by a `/`-rooted path inside the package; that path may vary only by `${arch}`. | | `${stateDir}` | Writable directory the host keeps for this plugin's server and removes when the plugin is uninstalled. Not available in `validationArguments`. | -| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`. | +| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`, nor in a [plugin backend](#plugin-backend), which belongs to no workspace. | | `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The host selects the process with exactly one of `languageID` (a language server), -`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, -stateDirectory, workspaceStateDirectory, architecture, settings?}`, and -receives: +`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), and +`backend: true` (the [plugin backend](#plugin-backend)), passes `{pluginDirectory, +stateDirectory, workspaceStateDirectory?, architecture, settings?}`, and +receives. `workspaceStateDirectory` may be omitted only when the selected +process does not use `${workspaceStateDir}`; otherwise the request is +`plugin.launch.invalidRequest`. | Field | Meaning | | --- | --- | @@ -216,10 +219,11 @@ receives: | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | | `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | +| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant. The host passes them to `lsp.startServer.hostServices` unchanged. | A language without a server, an undeclared build system, a build system -without a provider, and an undeclared VCS provider are -`plugin.launch.unavailable`. Core never creates or +without a provider, an undeclared VCS provider, and a plugin without a +backend are `plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -396,7 +400,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | @@ -404,6 +408,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -425,7 +430,8 @@ moving a level 2 manifest to level 3 only adds `requires`. own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider negotiation, requests, and lifecycle. Every VCS provider starts its own - process, so `vcsProviders` always comes with `serverProcess`. + process, so `vcsProviders` always comes with `serverProcess`, and so does + `backend`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -452,7 +458,8 @@ providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install review; template-only build plugins remain available. It does not declare `vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. Core supports `buildProviders: 1` and +the macOS host starts providers. It does not declare `backend` until it +starts plugin backends. Core supports `backend: 1`, `buildProviders: 1`, and `vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -490,6 +497,8 @@ Rules for publishing across levels and capability versions: | `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | +| `backend.launch` | `backend` is declared | +| `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | Activation event | Meaning | | --- | --- | @@ -998,6 +1007,44 @@ its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through `vcs.decodeReply`. The fields, operations, reply shapes, and error codes are defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). +## Plugin backend + +A plugin with its own pages may need code that a page cannot run: database +drivers, network clients, file formats. `backend` declares one long-lived +process that serves only the plugin itself, independent of any build system, +language, or workspace: + +```json +"pluginAPIVersion": 3, +"requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, +"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], +"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } +``` + +- `backend` holds exactly the [`serverProcess`](#capability-versions-level-3) + launch fields, with the same templates and install review; the review lists + it as `packagedBackend`, `systemCommandBackend`, or `runtimeBackend`. It + needs `pluginAPIVersion` 3, `requires.backend: 1`, `requires.serverProcess: + 1`, and the `backend.launch` permission. `fixtures/backend.plugin.json` is + the example; the backend cases of `fixtures/invalid-manifests.json` are the + rejections. +- In `backend` 1 only the plugin's own [web pages](#web-content-ui-2) call it, + so a backend without a web tool window is `plugin.manifest.invalid`. +- It belongs to no workspace: `${workspaceStateDir}` is refused, and its + writable state is `${stateDir}`. The host starts it with `${stateDir}` as + root and working directory. +- The host keeps at most one backend process per plugin, starts it on the + first `functions.*` call from one of the plugin's pages, and ends it when + the plugin is disabled, updated, or uninstalled, and when the host quits. + Its consent is the install review: it needs no per-workspace prompt. +- The backend speaks the [plugin-function protocol](#plugin-functions) and, + from version 2, may ask the host for the + [host services](#host-services-protocol-version-2) its permissions grant. + It owns no domain contract: it never answers `lithe/build/*` or VCS + requests. +- A plugin publishes one archive per platform, so its packaged path does not + vary by `${arch}`; a macOS archive may carry a universal binary. + ## Project generators `projectGenerators` (requires `projectGenerators` 1 and the `project.generate` @@ -1131,11 +1178,12 @@ cancellation removes the staging directory and leaves nothing behind ## Plugin functions -A plugin backend (a language server or build provider process the plugin -starts) can offer **plugin functions**: plugin-private actions with +A plugin backend (a language server, build provider, or +[plugin backend](#plugin-backend) process the plugin starts) can offer +**plugin functions**: plugin-private actions with serializable params and results, which the host invokes on behalf of the same plugin, for example from that plugin's own window. Functions complement the -typed domain protocols; they never replace them. Version 1 of the protocol is +typed domain protocols; they never replace them. Versions 1 and 2 of the protocol are implemented in Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`). macOS starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the @@ -1166,6 +1214,7 @@ Two rules keep these directions from forming a loop: domain registry. Each is added to a protocol version together with its params, permission, and the operation it must belong to. Version 1 offers none: every `lithe/host/*` request is refused with `plugin.host.unavailable`. + Version 2 adds the [credentials](#host-services-protocol-version-2) service. ### Declarative or backend implementation @@ -1187,7 +1236,7 @@ Two rules keep these directions from forming a loop: ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1] }` in +`capabilities.experimental.litheFunctions: { "versions": [1, 2] }` in `initialize`, next to and independent of `litheBuild`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers @@ -1288,6 +1337,42 @@ A backend reports progress of a running call with the notification normalized report. A malformed report, or one for an operation that is not running on that session, is dropped. +### Host services (protocol version 2) + +A backend that negotiated version 2 may send these requests to the host. +Each service is granted by one manifest permission; Core receives the +granted services as `lsp.startServer.hostServices` (from the launch plan's +`backend.hostServices`) and checks every request before the host sees it. + +| Request | Params | Result | Permission | +| --- | --- | --- | --- | +| `lithe/host/credentials/get` | `{ key }` | `{ secret }`, `null` when nothing is stored | `credentials.store` | +| `lithe/host/credentials/set` | `{ key, secret }` | `null` | `credentials.store` | +| `lithe/host/credentials/delete` | `{ key }` | `null`, also when nothing was stored | `credentials.store` | + +- A `key` is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a `secret` + is a string of at most 16 KiB. Unknown params are refused so a later + version can add them. +- Credentials are private to the plugin ID: the host stores them in the + platform's secret store (the Keychain on macOS) in a namespace of that ID, + never in `${stateDir}` or the package. They survive disabling and updating + the plugin and are deleted when it is uninstalled. +- Core refuses, without involving the host, in this order: a session that + negotiated version 1 or a method version 2 does not define (`-32601`, + `plugin.host.unavailable`), a service the plugin is not granted (`-32601`, + `plugin.host.permissionDenied`), and malformed params (`-32602`, + `plugin.host.invalidParams`). The code is in `error.data.code`. +- An accepted request becomes the runtime event `hostRequest` with `method` + and `result: { hostRequestID, service, params }`. The host answers it with + `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; + Core sends `result` (or `null`) to the backend, or `-32603` with + `plugin.host.failed` and the host's message cut to 1024 bytes. An unknown + `hostRequestID`, including one already answered, is `invalid_request`. +- A session holds at most 16 unanswered host requests; a further one is + refused with `plugin.host.failed`. Unanswered requests end with the session. +- `hostServices` needs `pluginId` and names only services this Core defines; + otherwise `lsp.startServer` is `invalid_request`. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -1296,8 +1381,10 @@ running on that session, is dropped. function calls, and its function requests are answered with `-32601`. - A new host capability or wire field the other side must not ignore needs a new protocol version, offered next to the old one. -- `fixtures/function-protocol.json` holds the golden cases. Rust Core tests run - them, and a scripted backend covers routing, result, failure, cancellation, +- A backend that answers `version: 1` keeps working unchanged: version 2 + only adds host services. +- `fixtures/function-protocol.json` holds the golden cases, including the + host requests (`hostRequests`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. @@ -1387,6 +1474,9 @@ command payload is a Core `invalid_request` failure instead. | `plugin.project.generationFailed` | Host | A command step failed or timed out, a download failed, or the project could not be moved into place | | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | +| `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | +| `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | +| `plugin.host.failed` | Core | The host could not complete a host-service request, or too many were unanswered | Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. @@ -1500,15 +1590,15 @@ their output appears in the host's Build output window. | `build.setOptions` | `{ instance, values }` | `{ options }`, checked by `build.resolveOptions` and stored like values set in the host's option list | | `workspace.openFile` | `{ path, line?, column? }`, a path inside the workspace | `{}` | | `ui.getState`, `ui.setState` | none, `{ state }` | `{ state }`, `{}`; JSON of at most 64 KiB per window and workspace | -| `functions.list` | `{ instance }` | `{ functions }`: sorted IDs of the functions the instance's provider currently offers; starts the provider | -| `functions.invoke` | `{ instance, function, params?, call? }`; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | +| `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | +| `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | | Event | Payload | | --- | --- | | `build.instancesChanged` | `{ instances }`, as `build.instances` returns them | | `build.runChanged` | `{ run, instance, state, exitCode? }`, `state` one of `running`, `succeeded`, `failed`, `cancelled` | -| `functions.progress` | `{ call, instance, function, message?, percentage? }`, only for a call made with `call` | +| `functions.progress` | `{ call, instance?, function, message?, percentage? }`, only for a call made with `call`; `instance` absent for a backend call | | `ui.themeChanged` | `{ theme }` | Bridge errors are `plugin.web.unknownMethod`, `plugin.web.invalidRequest`, @@ -1524,9 +1614,10 @@ cancelled or the provider stopped. A page has at most 16 function calls accepted at once, counting calls still awaiting consent, and closing it cancels them. Reusing a `call` ID is `plugin.web.invalidRequest`, also after that call ended. -`functions.list` and `functions.invoke` ask for the same consent as the -host's provider requests: once given for a plugin and workspace, it holds -until the project is closed. +`functions.list` and `functions.invoke` with an `instance` ask for the same +consent as the host's provider requests: once given for a plugin and +workspace, it holds until the project is closed. Calls to the plugin's +backend ask for none; the install review covered it. macOS implements web content with WKWebView and advertises `ui: [1, 2, 3]` (including the native controls described below). Windows has no WebView2 host yet and still advertises `ui: [1]`, so Core refuses diff --git a/shared/contracts/plugins/v1/fixtures/backend.plugin.json b/shared/contracts/plugins/v1/fixtures/backend.plugin.json new file mode 100644 index 000000000..b5bd06b17 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/backend.plugin.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.backend", + "displayName": "Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 1, + "serverProcess": 1, + "ui": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "credentials.store", + "ui.contribute", + "ui.web" + ], + "activationEvents": [ + "onView:dev.lithe.fixture.backend.window" + ], + "languages": [], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.backend.window", + "title": "Backend", + "placement": "bottom", + "web": { + "entry": "ui/index.html" + } + } + ] + }, + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 9cbf35e51..98d132632 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1] }, + "offer": { "versions": [1, 2] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -36,9 +36,14 @@ }, "expected": { "functions": ["install.finish", "install.prepare"] } }, + { + "name": "version 2 negotiates the same registry and adds host services", + "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 2 } + }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 2 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 3 } } }, "expected": { "functions": null } }, { @@ -266,5 +271,87 @@ "method": "workspace/applyEdit", "expected": { "code": null } } + ], + "hostRequests": [ + { + "name": "version 1 sessions get no host service even when granted", + "version": 1, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a granted credential read is forwarded to the host", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential write carries the secret", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod:password", "secret": "s3cret" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential delete names only the key", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a service the plugin's permissions do not grant is refused", + "version": 2, + "hostServices": [], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } + }, + { + "name": "a host method version 2 does not define is unavailable", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/process.run", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a key outside the alphabet is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "../other-plugin" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "an empty key is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a write without a string secret is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": 42 }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "unknown params are invalid so a later version can add them", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod", "scope": "global" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + } ] } diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 16d371acc..79b27a9fc 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -959,6 +959,60 @@ "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["Tasks", " "] }], "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs its capability in requires", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend capability version this Core does not know", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/requires/backend", "value": 2 }], + "expectedCode": "plugin.api.unsupported" + }, + { + "name": "backend needs serverProcess", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs the backend.launch permission", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.permission.undeclared" + }, + { + "name": "backend serves only web pages", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "No page" }, { "op": "set", "path": "/requires/ui", "value": 1 }, { "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend is not bound to a workspace", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend launch stays inside the package", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/launch/path", "value": "../backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend rejects unknown fields", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/workspace", "value": true }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "credentials.store without a backend", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.manifest.invalid" } ] } diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 5665dcc24..e210b478d 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -21,6 +21,7 @@ "minProperties": 1, "additionalProperties": false, "properties": { + "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, @@ -28,7 +29,7 @@ "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, and of the plugin backend.", "enum": [1] }, "settings": { "enum": [1] }, "ui": { "description": "Version 2 adds web content; version 3 adds native controls.", "enum": [1, 2, 3] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } @@ -46,7 +47,7 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } + "items": { "enum": ["backend.launch", "credentials.store", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", @@ -71,6 +72,10 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "backend": { + "description": "pluginAPIVersion 3, requires.backend 1: the plugin's own LSP base-protocol process. The host starts it on the first plugin-function call from one of the plugin's web pages, keeps one process per plugin independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Requires backend.launch and a web tool window; credentials.store lets it use the host's private credential store.", + "$ref": "#/$defs/backend" + }, "vcsProviders": { "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", "type": "array", @@ -636,6 +641,12 @@ } ] }, + "backend": { + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because the backend is not bound to a workspace.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false + }, "vcsProvider": { "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", "type": "object", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 013ba0d65..e8f068259 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1003,6 +1003,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.hostResponse` | Answer one host-service request a plugin backend sent | | `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | @@ -2269,7 +2270,9 @@ provider ID, selected executable/arguments/environment, root URI, working directory, initialization options, optional runtime executable, `jdtlsLaunchResources`, cache directory, `workspaceFingerprint`, and the optional `pluginId` of the plugin that owns the process, its optional -`workspaceConfiguration` (an object, plugin-owned sessions only), plus +`workspaceConfiguration` (an object, plugin-owned sessions only), its +optional `hostServices` (the launch plan's `backend.hostServices`; +plugin-owned sessions only, and only services Core defines), plus initialize, post-initialize readiness, request, Java project build (`javaBuildTimeoutMilliseconds`), and shutdown deadlines. Java callers may also provide the versioned `mavenContext` accepted by @@ -2563,8 +2566,8 @@ sessions refuse it with `invalid_request`. functionID }, params?, timeoutMilliseconds? }` and returns `{ operationId }`. It sends `lithe/function/invoke` to a session started with `lsp.startServer.pluginId` equal to `function.pluginID` that negotiated -`capabilities.experimental.litheFunctions` version 1 and registered the -function. `params` must be a JSON object and defaults to `{}`. Refusals are +`capabilities.experimental.litheFunctions` version 1 or 2 and registered +the function. `params` must be a JSON object and defaults to `{}`. Refusals are `invalid_request`, `not_supported`, or `permission_denied` with a `plugin.function.*` code in `details`, in the order the [plugin contract](plugins/v1/README.md#plugin-functions) defines. The @@ -2592,6 +2595,18 @@ is `pluginFunctions` in the session's client state. Sessions without `lsp.startServer.pluginId` neither offer nor accept `litheFunctions`, retain no registry, and publish no function-discovery events. +A session that negotiated `litheFunctions` version 2 may send the +[host-service requests](plugins/v1/README.md#host-services-protocol-version-2) +its `hostServices` grant. Core checks method, grant, and params, then +publishes the runtime event `hostRequest` with `method` and `result: { +hostRequestID, service, params }`; refusals go back to the backend without an +event. `lsp.hostResponse` accepts `{ sessionId, hostRequestID, result? }` or +`{ sessionId, hostRequestID, error: { message } }`, returns `{}`, and sends +`result` (default `null`) or `-32603` with `data.code: "plugin.host.failed"` +to the backend. An unknown or already answered `hostRequestID` is +`invalid_request`. At most 16 host requests are unanswered per session; more +are refused with `plugin.host.failed`. + `lsp.request` accepts a semantic `operation` plus the operation-specific URI, position, range, diagnostics, item, action, or command fields, and returns `{ operationId }`. Supported operations include @@ -2686,7 +2701,7 @@ channel until events are queued or the timeout elapses, then drains the same typed events. Hosts should use `waitEvents` so idle sessions do not poll. Event types include `stateChanged`, `featuresChanged`, `diagnostics`, -`requestCompleted`, `requestProgress`, `functionsChanged`, +`requestCompleted`, `requestProgress`, `functionsChanged`, `hostRequest`, `serverInfoChanged`, and `log`. Every request completes at most once with either `result` or a structured runtime error containing provider/session, stage, optional method/document/request, stable code, and From 749cf9d7fa0828b57045fe2cd6e2c35a92ab8b5a Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 05:50:51 -0700 Subject: [PATCH 06/16] =?UTF-8?q?docs(notes):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=90=8E=E7=AB=AF=E3=80=81=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=20v2=20=E5=87=AD=E6=8D=AE=E4=B8=8E=20Agent=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B5=B0=20lithe=20MCP=20=E7=9A=84=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正“Agent 到 app 里查找 lithe-db-mcp”的描述:它只被打包,从未被启动 - 记录后端每插件一个进程、宿主全局、授权由安装审查覆盖等取舍 - 凭据生命周期:停用/更新保留,卸载删除 - 按平台可执行文件不改契约;Agent 工具后续经现有 lithe MCP 暴露 - 待确认清空 Co-Authored-By: Claude Opus 5.5 --- .../2026-10-24-database-provider-plugin.md | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md index 0c92a0835..401fa80cd 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +++ b/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md @@ -6,7 +6,7 @@ 数据库连接功能将从 Lithe 主仓库和主安装包中移出,做成一个独立仓库的官方插件。用户只装 IDE 时,看不到数据库入口;从插件市场安装后才能使用。插件的界面是包内网页,后端用 Rust 编写,macOS 和 Windows 共用同一份代码。 -主仓库不再保留任何数据库专属代码,只提供通用的插件能力:不依附构建系统的插件后端、宿主凭据存储、插件提供的 MCP 服务,以及按平台区分的可执行文件。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 +主仓库不再保留任何数据库专属代码,只提供通用的插件能力:不依附构建系统的插件后端、宿主凭据存储,以及之后经由 Lithe 自带 MCP 暴露的插件 Agent 工具。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 这次评估还发现,数据库模块已经偏离“两套界面、一个核心”:同一条 SQL 安全规则写了三份并且不一致,Windows 界面从导入起就没有后端。插件化之后,这些规则只在插件后端保留一份。 @@ -57,8 +57,7 @@ - **网页界面只能操作构建系统。** 网页桥接对象(`window.lithe`)只能操作本插件声明的构建系统;插件函数在 macOS 上也只由构建 provider 的会话提供。数据库插件只能伪装成构建系统,违反[公共扩展契约](2026-10-13-public-extension-contract-and-provider-api.md)的“不要伪造声明”。 - **插件拿不到宿主的凭据存储。** 插件调用宿主服务(`lithe/host/*`)在协议 v1 中全部被拒绝,没有地方安全地保存密码。 -- **Agent 找不到插件提供的 MCP 服务。** Agent 现在直接到 app 里查找 `lithe-db-mcp`,不会去已安装的插件里找。 -- **跨平台可执行文件路径未定义。** `packaged` 启动方式只有一个固定路径,没有规定 Windows 可执行文件(`.exe`)怎么声明。 +- **Agent 用不上数据库能力。** `lithe-db-mcp` 只被打包进 `Contents/Helpers`,没有任何代码启动或登记它,Agent 实际调用不到。Lithe 已有自己的 `lithe` MCP(`lithe-mcp` 加 IDE 宿主的能力目录和项目授权),插件能力应当从这里进入,而不是再起一个独立 MCP 进程。 - **Windows 还没有声明式插件宿主和插件市场。** 声明式插件宿主正在 #1424 中开发;插件市场目前只有 macOS 版。 ## 提案 @@ -74,7 +73,7 @@ | 位置 | 内容 | | --- | --- | -| 组织仓库 `Lithe-IDEA/lithe-plugin-database`(新建) | Rust 后端(从 `lithe-db-sidecar` 演进)、MCP 服务(从 `lithe-db-mcp` 演进)、网页界面、`plugin.json`、数据库专属的测试样例和兼容性测试 | +| 组织仓库 `Lithe-IDEA/lithe-plugin-database`(新建) | Rust 后端(从 `lithe-db-sidecar` 演进,吸收 `lithe-db-mcp` 的查询能力)、网页界面、`plugin.json`、数据库专属的测试样例和兼容性测试 | | 主仓库 | 只保留通用能力:契约、Rust Core 校验、两端宿主实现、Rust 后端 SDK 的契约测试 | | 主仓库最终删除 | `macos/Sources/LitheDatabaseModule/`、`macos/Sources/Lithe/Views/Database/`、`rust/lithe-db-sidecar/`、`rust/lithe-db-mcp/`、`windows/tauri/src/features/database/` 及打包脚本中的数据库部分 | @@ -91,17 +90,22 @@ 每一项都先写进契约、Rust Core 和测试样例,再由 macOS 宿主实现;Windows 宿主(#1424)按同一份契约实现。 -1. **插件后端(不依附构建系统)。** 新增一项能力:插件声明一个归属于自己的后端进程,宿主在插件的界面打开时按需启动它,会话按插件和工作区划分。网页桥接新增调用本插件后端函数的方法,复用现有[插件函数协议](../../implemented/architecture/2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。网页原生面板的数据源也可以改为指向这个后端,不再强制要求 `buildSystemID`。 -2. **宿主凭据存储。** 在插件函数协议的新版本中加入第一个宿主能力:插件私有的凭据读写。macOS 使用 Keychain,Windows 使用系统凭据管理器。插件需要声明专门的权限,安装审查会列出这项权限。凭据只按插件 ID 隔离,插件读不到别的插件或宿主自身的凭据。 -3. **插件提供的 MCP 服务。** 插件声明 MCP 服务的入口;Agent 从已启用的插件中发现它。插件停用后,这些工具随之消失。 -4. **按平台的可执行文件。** 契约规定 `packaged` 在不同平台上怎么定位可执行文件,例如允许按平台给出路径,或规定 Windows 自动补 `.exe` 后缀。具体方式在契约 PR 中确定。 +1. **插件后端(`backend` 1,已在契约中实现)。** 清单的 `backend` 字段只含 `serverProcess` 的启动字段,需要 `backend.launch` 权限和一个网页工具窗。设计取舍: + - **每个插件一个进程,宿主全局,不按工作区划分。** 数据库连接属于用户而不是项目,按工作区起多份进程只会重复连接池;因此后端不能使用 `${workspaceStateDir}`,状态写在 `${stateDir}`,宿主以 `${stateDir}` 作为根目录和工作目录启动它。 + - **按需启动、随插件结束。** 本插件网页第一次调用 `functions.*` 时启动;插件停用、更新、卸载或宿主退出时结束。 + - **授权就是安装审查。** 后端在安装审查中以 `packagedBackend` 等条目列出,调用时不再按工作区弹同意框;带 `instance` 的构建 provider 调用仍沿用原来的同意规则。 + - **不承担领域契约。** 后端只回答插件函数,不回答 `lithe/build/*` 或 VCS 请求,避免再次“伪装成构建系统”。 + - 网页桥接 `functions.list` / `functions.invoke` 省略 `instance` 即调用本插件后端,复用现有[插件函数协议](../../implemented/architecture/2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。 +2. **宿主凭据存储(插件函数协议 v2,已在契约中实现)。** v2 是第一个提供宿主服务的协议版本:`lithe/host/credentials/{get,set,delete}`,需要 `credentials.store` 权限(没有后端时声明即无效)。Core 在宿主看到请求前校验协议版本、授权和参数(key 只允许 `[A-Za-z0-9._:-]` 且不超过 128 字节,secret 不超过 16 KiB,拒绝未知字段以便后续版本扩展),通过 `hostRequest` 事件交给宿主,宿主用 `lsp.hostResponse` 应答;每个会话最多 16 个未应答请求。macOS 使用 Keychain,按插件 ID 划分命名空间;插件读不到别的插件或宿主自身的凭据。生命周期:停用和更新时保留,卸载时与 `${stateDir}` 一起删除。 +3. **Agent 工具经由 `lithe` MCP(后续)。** 不新增“插件声明 MCP 服务”的契约,也不保留独立的 `lithe-db-mcp` 进程。之后由插件以插件函数的形式提供 Agent 工具,IDE 宿主把它们登记进现有 `lithe` MCP 的能力目录,沿用其项目授权;插件停用时工具随之消失。这一项在数据库插件上线后单独设计,不阻塞第 1 到第 5 期。 +4. **按平台的可执行文件:不改契约。** 每个平台单独出下载包,可执行文件放在包内固定路径(例如 `bin/backend`),macOS 包可以是 universal 二进制。`${arch}` 本来就不能用于 `packaged` 路径,这样也不需要。Windows 的 `.exe` 等 Windows 宿主就绪时再定。 5. **Rust 后端 SDK。** 在 `lithe-plugin-docs` 中新增 `sdk/rust`,实现插件函数协议和宿主能力,并补端到端样例,和 Node、Java SDK 对齐。 ### 五、规则只保留一份 - SQL 分析、语句拆分、标识符引用、DDL 生成和危险语句判定,全部只在插件的 Rust 后端里实现。界面要不要弹出确认框、执行时要不要拦截,都使用同一个分析结果。 - 插件仓库维护数据库专属的黄金样例。必须覆盖:换行或制表符分隔的 `WHERE`、开头的行注释和块注释、`WITH … DELETE`、多语句、无法识别的语句。 -- 主仓库只维护通用能力的样例(插件后端、凭据、MCP 声明),不再维护数据库样例。 +- 主仓库只维护通用能力的样例(`fixtures/backend.plugin.json`、`function-protocol.json` 的 `hostRequests`),不再维护数据库样例。 ### 六、老用户迁移 @@ -120,9 +124,9 @@ | 期 | 内容 | 依赖 | | --- | --- | --- | | 1 | Windows 矩阵、`.db` 拦截、README 修正;修复 `WHERE` 误判,让 sidecar 与界面使用一致的词法判断,并让 macOS 处理 `confirmation_required` | 无 | -| 2 | 契约:插件后端、凭据宿主能力、MCP 声明、按平台可执行文件;Rust Core 校验与样例 | 无 | +| 2 | 契约:插件后端、函数协议 v2 凭据宿主服务;Rust Core 校验与样例 | 无 | | 3 | macOS 宿主实现第 2 期能力;`lithe-plugin-docs` 新增 Rust SDK | 第 2 期 | -| 4 | 新建 `lithe-plugin-database`:Rust 后端、MCP、网页界面、样例;先在 macOS 上以本地开发包验证 | 第 3 期 | +| 4 | 新建 `lithe-plugin-database`:Rust 后端、网页界面、样例;先在 macOS 上以本地开发包验证 | 第 3 期 | | 5 | macOS 发布插件,加入迁移提示,从主仓库删除数据库代码 | 第 4 期 | | 6 | Windows 插件宿主和插件市场就绪后,按当时的宿主能力小幅调整插件,补 Windows 构建后发布 | Windows 插件市场 | @@ -166,9 +170,9 @@ - 只安装 Lithe 时没有任何数据库入口;`Lithe.app` 中不含数据库程序和界面代码;主仓库中搜不到数据库专属模块。 - 从插件市场安装后,现有连接、查询、编辑、导入导出、结构对比、Redis 和 Nacos 功能可用。老用户的连接和密码完成迁移。 -- 停用或卸载后,入口消失,插件进程结束;重新安装后,凭据和连接仍在。 +- 停用或卸载后,入口消失,插件进程结束。停用后重新启用、或更新插件后,凭据和连接仍在;卸载后凭据和 `${stateDir}` 一并删除。 - 插件仓库中只有一份 SQL 危险判定。换行写的 `DELETE … WHERE` 可以直接执行,不带 `WHERE` 的删除会弹出确认框。 -- 插件后端、凭据和 MCP 声明的通用样例在 Rust Core 测试中通过;Windows 宿主实现后,同一批样例在 Windows 上也通过。 +- 插件后端和凭据宿主服务的通用样例在 Rust Core 测试中通过;Windows 宿主实现后,同一批样例在 Windows 上也通过。 - `scripts/verify-runtime-bundle-immutability.sh` 覆盖插件安装和典型数据库工作流,发行目录哈希不变。 - Windows 宿主就绪后,同一插件版本只增加 Windows 下载包即可安装使用。 @@ -197,9 +201,18 @@ - Windows 这次不做插件侧适配,等 Windows 插件市场就绪后再小幅调整; - 第 1 到第 5 期在同一个 PR 中分步提交,不拆成多个小 PR。 +按用户授权由实现方确定(2026-10-24): + +- 后端每个插件一个进程、宿主全局,不按工作区划分;授权由安装审查覆盖; +- 凭据停用和更新时保留,卸载时删除; +- 按平台可执行文件不改契约,各平台下载包使用固定路径; +- Agent 工具之后经由现有 `lithe` MCP 暴露,不单独起 MCP 进程; +- 网页界面全新编写(TypeScript,符合 CSP 的打包产物),参照 macOS 现有功能; +- 老用户迁移由宿主侧的过渡垫片完成,第 5 期设计。 + ## 待确认 -- `packaged` 按平台定位可执行文件的具体方式,在契约提交中确定。 +- 无。 ## 适用范围 @@ -214,6 +227,8 @@ - `windows/tauri/src/extensions/tooling/package-database-sidecars.ts` - `windows/tauri/src/features/file-system/controllers/file-utils.ts` - `shared/contracts/plugins/v1/` +- `rust/lithe-core/src/plugins/backend.rs` +- `rust/lithe-core/src/lsp/interface/plugin_function.rs` - `shared/platform-feature-matrix/features/` - `scripts/package-app.sh` From 173c791c30699d6492bff959db724cf6c3b1364e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:07:01 -0700 Subject: [PATCH 07/16] =?UTF-8?q?feat(macos):=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=8F=92=E4=BB=B6=E5=90=8E=E7=AB=AF=E5=B9=B6?= =?UTF-8?q?=E9=80=9A=E8=BF=87=20Keychain=20=E6=8F=90=E4=BE=9B=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MacPluginBackendSessions:每个插件一个后端进程、应用全局共享, 由插件网页首次调用 functions.* 时按需启动,以 ${stateDir} 为根目录和 工作目录;插件停用时停止、重新启用后可再启动,包替换/卸载前停止且本次 运行不再启动;设置变化时重启 - 后端会话以 hostServices 启动;LanguageServerRuntimeSession 处理 hostRequest 事件并经新命令 lsp.hostResponse 应答(离开主线程发送) - 新增 MacPluginCredentialStore:每个插件独立的 Keychain service, 读取不弹密码框;卸载时随插件状态目录一起删除,停用/更新保留 - 网页桥接:functions.list/invoke 省略 instance 时调用本插件后端, 不弹同意框(安装审查已覆盖);无后端的插件返回 plugin.web.forbidden - 安装时校验后端打包可执行文件;hostCapabilities 声明 backend: [1] - Swift 契约:launch request 的 workspaceStateDirectory 可为空、 backendLaunchPlan、descriptor.declaresBackend、事件携带 method - Core 修正:lsp.hostResponse 的字段按契约拼写为 hostRequestID, 测试改为从线上 JSON 解码以覆盖字段名 - README 更新 macOS 宿主能力说明 Co-Authored-By: Claude Opus 5.5 --- .../Features/PluginHostServices.swift | 78 ++++ .../Features/PluginWebBridge.swift | 92 ++++- .../Application/Features/PluginWebPages.swift | 4 + .../Lithe/Core/Rust/RustCoreBridge.swift | 33 ++ .../RustDeclarativePluginOperations.swift | 26 +- .../RustLanguageServerRuntimeAdapter.swift | 50 ++- .../Models/AppModel/AppModel+PluginWeb.swift | 2 + .../Platform/MacOS/MacServiceContainer.swift | 33 +- .../Plugins/MacPluginBackendSessions.swift | 359 ++++++++++++++++++ .../Plugins/MacPluginCredentialStore.swift | 97 +++++ ...acPluginLanguageServerLaunchResolver.swift | 86 ++++- .../MacOS/Plugins/MacPluginPackageStore.swift | 63 ++- .../LanguageServerRuntimeContracts.swift | 96 ++++- .../Plugins/DeclarativePluginContracts.swift | 41 +- .../Runtime/LanguageServerSession.swift | 52 ++- .../LitheModuleAPI/Plugins/PluginTypes.swift | 1 + .../MacPluginBackendSessionsTests.swift | 324 ++++++++++++++++ .../LitheTests/PluginPackageStoreTests.swift | 49 ++- .../LitheTests/PluginWebBridgeTests.swift | 52 +++ rust/lithe-core/src/lsp/interface/engine.rs | 27 +- shared/contracts/plugins/v1/README.md | 4 +- 21 files changed, 1510 insertions(+), 59 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostServices.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift create mode 100644 macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServices.swift b/macos/Sources/Lithe/Application/Features/PluginHostServices.swift new file mode 100644 index 000000000..6f65d77b4 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostServices.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheCoreContracts + +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +/// Secrets plugin backends store through the `credentials` host service. +/// Every entry belongs to one plugin ID: a plugin can never name another +/// plugin's entries, and the host's own secrets live in other stores. +protocol PluginCredentialStoring: Sendable { + func secret(pluginID: String, key: String) throws -> String? + func setSecret(_ secret: String, pluginID: String, key: String) throws + /// Succeeds when nothing is stored under the key. + func deleteSecret(pluginID: String, key: String) throws + /// Removes everything the plugin stored, when it is uninstalled. + func deleteAll(pluginID: String) throws +} + +/// Performs the host services of plugin function protocol 2 for one +/// plugin. Core has already checked the method, the grant, and the params; +/// this maps them onto the host's stores and never puts a secret in a +/// failure message, which reaches the backend. +enum PluginHostServices { + static func answer( + _ request: PluginHostRequest, + pluginID: String, + credentials: any PluginCredentialStoring + ) -> Result { + guard request.service == "credentials", + case .object(let params) = request.params, + case .string(let key)? = params["key"] else { + return .failure(PluginHostRequestFailure(message: "The host does not serve \(request.method).")) + } + do { + switch request.method { + case "lithe/host/credentials/get": + let secret = try credentials.secret(pluginID: pluginID, key: key) + return .success(.object(["secret": secret.map(ToolingJSONValue.string) ?? .null])) + case "lithe/host/credentials/set": + guard case .string(let secret)? = params["secret"] else { + return .failure(PluginHostRequestFailure(message: "The request has no secret.")) + } + try credentials.setSecret(secret, pluginID: pluginID, key: key) + return .success(.null) + case "lithe/host/credentials/delete": + try credentials.deleteSecret(pluginID: pluginID, key: key) + return .success(.null) + default: + return .failure(PluginHostRequestFailure(message: "The host does not serve \(request.method).")) + } + } catch { + return .failure(PluginHostRequestFailure( + message: "The credential store failed: \(error.localizedDescription)" + )) + } + } +} + +/// Process-lifetime store for tests and hosts without a secret store. +final class MemoryPluginCredentialStore: PluginCredentialStoring, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: [String: String]] = [:] + + func secret(pluginID: String, key: String) throws -> String? { + lock.withLock { values[pluginID]?[key] } + } + + func setSecret(_ secret: String, pluginID: String, key: String) throws { + lock.withLock { values[pluginID, default: [:]][key] = secret } + } + + func deleteSecret(pluginID: String, key: String) throws { + lock.withLock { _ = values[pluginID]?.removeValue(forKey: key) } + } + + func deleteAll(pluginID: String) throws { + lock.withLock { _ = values.removeValue(forKey: pluginID) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift index a4150c573..73dbded40 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift @@ -80,6 +80,20 @@ extension BuildSystemsFeatureModel: PluginWebBuildAccess { var changes: AnyPublisher { objectWillChange.map { _ in () }.eraseToAnyPublisher() } } +/// Plugin backends (`backend` 1) as pages reach them: one process per +/// plugin, shared by every project window. Core checks the owner again on +/// every call. +@MainActor +protocol PluginBackendRequesting: AnyObject { + func declaresBackend(_ pluginID: String) -> Bool + /// Functions the plugin's backend registers; starts it. + func pluginFunctions(ofPlugin pluginID: String) async throws -> [String] + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue +} + /// What a page may cause in its host window. @MainActor protocol PluginWebBridgeHost: AnyObject { @@ -90,6 +104,12 @@ protocol PluginWebBridgeHost: AnyObject { /// Opens a workspace file at a 1-based line and column; false when it does not exist. func pluginWebOpenFile(_ url: URL, line: Int?, column: Int?) -> Bool func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool + /// The application's plugin backends; nil on hosts that run none. + var pluginWebBackends: (any PluginBackendRequesting)? { get } +} + +extension PluginWebBridgeHost { + var pluginWebBackends: (any PluginBackendRequesting)? { nil } } /// Page state kept by the host, per window and workspace. @@ -483,10 +503,16 @@ final class PluginWebBridge { // MARK: Plugin functions - /// Functions are reached through one of this plugin's own instances, so - /// a page can call only its plugin's provider process; Core checks the - /// process owner again. + /// Functions are reached through one of this plugin's own instances, or + /// without `instance` through the plugin's own backend, so a page can + /// call only its plugin's processes; Core checks the process owner again. private func listFunctions(_ params: Parameters, workspaceURL: URL) async throws -> ToolingJSONValue { + guard params.values["instance"] != nil else { + let backends = try backendAccess() + let functions = try await functionOutcome { try await backends.pluginFunctions(ofPlugin: window.pluginID) } + _ = try currentWorkspace() + return .object(["functions": .array(functions.map(ToolingJSONValue.string))]) + } let (build, entry) = try await entry(params) try await consent(String(localized: "Start the \(entry.instance.displayName) provider"), workspaceURL: workspaceURL) let functions = try await functionOutcome { try await build.pluginFunctions(entry.id) } @@ -495,7 +521,13 @@ final class PluginWebBridge { } private func invokeFunction(_ params: Parameters, workspaceURL: URL) async throws -> ToolingJSONValue { - let (build, entry) = try await entry(params) + let target: FunctionTarget + if params.values["instance"] == nil { + target = .backend(try backendAccess()) + } else { + let (build, entry) = try await entry(params) + target = .instance(build, entry) + } let functionID = try params.string("function", limit: 128) let input = params.values["params"] ?? .object([:]) guard case .object = input else { @@ -515,23 +547,35 @@ final class PluginWebBridge { functionCalls[call] = reservation if let pageCall { usedCallIDs.insert(pageCall) } defer { if functionCalls[call] === reservation { functionCalls[call] = nil } } - try await consent(String(localized: "Run \(functionID) of \(entry.instance.displayName)"), workspaceURL: workspaceURL) + // The install review covered the backend; a build provider asks + // the same consent as the host's own provider requests. + if case .instance(_, let entry) = target { + try await consent(String(localized: "Run \(functionID) of \(entry.instance.displayName)"), workspaceURL: workspaceURL) + } guard !reservation.isCancelled else { throw PluginWebBridgeFailure(code: "requestCancelled", message: "The function call was cancelled.") } - let entryID = entry.id let timeout = requestTimeout - let task = Task { @MainActor [weak self, weak reservation] in - try await build.invokePluginFunction( - entryID, functionID: functionID, params: input, timeout: timeout - ) { progress in - guard let self, let pageCall, let reservation, self.functionCalls[pageCall] === reservation else { return } - var payload: [String: ToolingJSONValue] = [ - "call": .string(pageCall), "instance": .string(entryID), "function": .string(functionID) - ] - if let message = progress.message { payload["message"] = .string(message) } - if let percentage = progress.percentage { payload["percentage"] = .integer(percentage) } - self.emit?("functions.progress", .object(payload)) + let pluginID = window.pluginID + let instanceID: String? = if case .instance(_, let entry) = target { entry.id } else { nil } + let progress: @MainActor @Sendable (PluginFunctionProgress) -> Void = { [weak self, weak reservation] progress in + guard let self, let pageCall, let reservation, self.functionCalls[pageCall] === reservation else { return } + var payload: [String: ToolingJSONValue] = ["call": .string(pageCall), "function": .string(functionID)] + if let instanceID { payload["instance"] = .string(instanceID) } + if let message = progress.message { payload["message"] = .string(message) } + if let percentage = progress.percentage { payload["percentage"] = .integer(percentage) } + self.emit?("functions.progress", .object(payload)) + } + let task = Task { @MainActor in + switch target { + case .instance(let build, let entry): + try await build.invokePluginFunction( + entry.id, functionID: functionID, params: input, timeout: timeout, progress: progress + ) + case .backend(let backends): + try await backends.invokePluginFunction( + functionID, params: input, ofPlugin: pluginID, timeout: timeout, progress: progress + ) } } reservation.task = task @@ -544,6 +588,20 @@ final class PluginWebBridge { return .object(["value": value]) } + private enum FunctionTarget { + case instance(any PluginWebBuildAccess, BuildSystemEntry) + case backend(any PluginBackendRequesting) + } + + /// The plugin's backend, when it declares one. + private func backendAccess() throws -> any PluginBackendRequesting { + guard let host else { throw PluginWebBridgeFailure.forbidden("The host window was closed.") } + guard let backends = host.pluginWebBackends, backends.declaresBackend(window.pluginID) else { + throw PluginWebBridgeFailure.forbidden("\(window.pluginName) declares no backend.") + } + return backends + } + /// Passes Core's outcome codes through unchanged, so a page sees the same /// `plugin.function.*`, `requestCancelled`, and `requestTimeout` codes on /// every host. diff --git a/macos/Sources/Lithe/Application/Features/PluginWebPages.swift b/macos/Sources/Lithe/Application/Features/PluginWebPages.swift index ba7920b6a..663cfc6bb 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWebPages.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWebPages.swift @@ -16,6 +16,8 @@ final class PluginWebPages { /// By tool-window contribution ID. let windows: [String: PluginWebWindow] let stateStore: any PluginWebStateStoring + /// The application's plugin backends, shared with every project window. + let backends: (any PluginBackendRequesting)? private let prompt: @MainActor (PluginWebProcessConsent) async -> Bool private var pages: [String: any PluginWebPageHandle] = [:] private var nativeOwners: [String: String] = [:] @@ -27,10 +29,12 @@ final class PluginWebPages { init( windows: [PluginWebWindow] = [], stateStore: (any PluginWebStateStoring)? = nil, + backends: (any PluginBackendRequesting)? = nil, prompt: @escaping @MainActor (PluginWebProcessConsent) async -> Bool = { _ in false } ) { self.windows = Dictionary(windows.map { ($0.windowID, $0) }, uniquingKeysWith: { first, _ in first }) self.stateStore = stateStore ?? MemoryPluginWebStateStore() + self.backends = backends self.prompt = prompt } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index cc1a306c8..4f837e5b4 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1891,6 +1891,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let workingDirectory: String let initializationOptions: ToolingJSONValue? let workspaceConfiguration: ToolingJSONValue? + /// Host services a plugin backend may request; omitted when none. + let hostServices: [String]? let runtimeExecutablePath: String? let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? @@ -2056,6 +2058,17 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspHostResponseRequest: Encodable { + struct Failure: Encodable { + let message: String + } + + let sessionId: String + let hostRequestID: String + let result: ToolingJSONValue? + let error: Failure? + } + private struct LspCancelOperationRequest: Encodable { let sessionId: String let operationId: String @@ -3656,6 +3669,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { workingDirectoryURL: URL, initializationOptions: ToolingJSONValue? = nil, workspaceConfiguration: ToolingJSONValue? = nil, + hostServices: [String] = [], runtimeExecutableURL: URL? = nil, jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, @@ -3678,6 +3692,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { workingDirectory: workingDirectoryURL.standardizedFileURL.path, initializationOptions: initializationOptions, workspaceConfiguration: workspaceConfiguration, + hostServices: hostServices.isEmpty ? nil : hostServices, runtimeExecutablePath: runtimeExecutableURL?.standardizedFileURL.path, jdtlsLaunchResources: jdtlsLaunchResources.map { LspJdtlsLaunchResourcesRequest( @@ -3979,6 +3994,24 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Answers one `hostRequest` event of a plugin backend. + func lspHostResponse( + sessionID: String, + hostRequestID: String, + result: ToolingJSONValue?, + failureMessage: String? + ) -> Result { + executeVoid( + command: "lsp.hostResponse", + payload: LspHostResponseRequest( + sessionId: sessionID, + hostRequestID: hostRequestID, + result: failureMessage == nil ? (result ?? .null) : nil, + error: failureMessage.map { .init(message: $0) } + ) + ) + } + /// Cancels a pending operation. Its completion event still arrives, carrying /// a cancellation error, so callers never leak a continuation. func lspCancelOperation(sessionID: String, operationID: String) { diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index b94168513..fb9f55ec6 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -143,6 +143,19 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec )) } + func backendLaunchPlan( + manifest: Data, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + try launchPlan(LaunchPlanPayload( + manifest: try Self.json(manifest), + languageID: nil, + buildSystemID: nil, + backend: true, + request: LaunchRequest(request) + )) + } + private func launchPlan(_ payload: LaunchPlanPayload) throws -> DeclarativeLanguageServerLaunchPlan { let outcome: Outcome = try execute( "plugins.declarative.languageServerLaunchPlan", @@ -191,6 +204,7 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec mayUseNetwork: $0.mayUseNetwork ?? false ) } + result.backend = plan.backend.map { .init(hostServices: $0.hostServices) } return result } @@ -320,7 +334,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec private struct LaunchRequest: Encodable { let pluginDirectory: String let stateDirectory: String - let workspaceStateDirectory: String + /// Omitted for a process bound to no workspace. + let workspaceStateDirectory: String? let architecture: String let settings: [String: ToolingJSONValue] let projectSettings: [String: ToolingJSONValue] @@ -328,7 +343,7 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec init(_ request: DeclarativeLanguageServerLaunchRequest) { pluginDirectory = request.pluginDirectory.path stateDirectory = request.stateDirectory.path - workspaceStateDirectory = request.workspaceStateDirectory.path + workspaceStateDirectory = request.workspaceStateDirectory?.path architecture = request.architecture settings = request.settings projectSettings = request.projectSettings @@ -400,6 +415,11 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let requiredPackagePaths: [String] let buildProvider: BuildProvider? let vcsProvider: VcsProvider? + let backend: Backend? + + struct Backend: Decodable { + let hostServices: [String] + } struct VcsProvider: Decodable { struct Tool: Decodable { @@ -447,6 +467,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let languageID: String? let buildSystemID: String? var vcsProviderID: String? = nil + /// Selects the plugin backend; omitted for every other process. + var backend: Bool? = nil let request: LaunchRequest } diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index c03d81498..5047c42e6 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -286,6 +286,53 @@ extension RustCoreBridge: LanguageServerRuntimeCore { }.mapError(Self.runtimeFailure) } + func startPluginLanguageServer( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + lspStartServer( + providerID: providerID, + pluginID: pluginID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: rootURL, + initializationOptions: initializationOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ).map { + LanguageServerRuntimeStart(sessionID: $0.sessionId, state: $0.state, processID: $0.processId) + }.mapError(Self.runtimeFailure) + } + + func respondToPluginHostRequest( + sessionID: String, + hostRequestID: String, + outcome: Result + ) -> Result { + let result: ToolingJSONValue? + let failureMessage: String? + switch outcome { + case .success(let value): result = value; failureMessage = nil + case .failure(let failure): result = nil; failureMessage = failure.message + } + return lspHostResponse( + sessionID: sessionID, hostRequestID: hostRequestID, result: result, failureMessage: failureMessage + ).mapError(Self.runtimeFailure) + } + func updateWorkspaceConfiguration( sessionID: String, configuration: ToolingJSONValue @@ -341,7 +388,8 @@ extension RustCoreBridge: LanguageServerRuntimeCore { mavenProfileTask: event.mavenProfileTask, mavenProfileProject: event.mavenProfileProject.map { MavenProfileProjectResult(projectURI: $0.projectUri, status: $0.status, errorDetails: $0.errorDetails) - } + }, + method: event.method ) } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift index 8e5fcb566..28d096217 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift @@ -30,6 +30,8 @@ extension AppModel: PluginWebBridgeHost { await services.pluginWebPages.confirmProcessStart(consent) } + var pluginWebBackends: (any PluginBackendRequesting)? { services.pluginWebPages.backends } + /// True while the window's plugin has a new version, a rollback, or an /// uninstall waiting for restart: its old page is not shown again. func pluginWebPageAwaitsRestart(_ windowID: String) -> Bool { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index f6b2212e7..e9cb5782c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -171,9 +171,13 @@ final class MacServiceContainer { ) moduleLifecycleCoordinator = ModuleLifecycleCoordinator(runtime: moduleRuntime) let declarativePluginOperations = RustDeclarativePluginOperations(core: rustCore) + let pluginCredentials = MacPluginCredentialStore( + servicePrefix: fileStorage.identity.keychainService("plugin-credentials") + ) let pluginPackageStore = MacPluginPackageStore( fileStorage: fileStorage, - validator: declarativePluginOperations + validator: declarativePluginOperations, + credentials: pluginCredentials ) let pluginSettings = MacPluginSettingsStore(store: store) let pluginLaunchResolver = MacPluginLanguageServerLaunchResolver( @@ -291,6 +295,21 @@ final class MacServiceContainer { core: rustCore, processRegistry: processRegistry ) + // Plugin backends, one per plugin for the whole application: the + // first project window creates them, and every window registers the + // same startup snapshot. Nothing starts until a page calls one. + let pluginBackends = MacPluginBackendSessions.shared { + MacPluginBackendSessions( + launchResolver: pluginLaunchResolver, + tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + versionChecker: declarativePluginOperations, + runtime: runtimeService, + core: rustCore, + processRegistry: processRegistry, + credentials: pluginCredentials + ) + } + pluginBackends.register(pluginStartup.registeredPlugins) let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) // Installation owns the process-backed language boundary even when a // package is disabled, quarantined, or failed to load. Only the active @@ -682,6 +701,7 @@ final class MacServiceContainer { directories: MacPluginStateDirectories(fileStorage: fileStorage), fileStorage: fileStorage ), + backends: pluginBackends, prompt: MacPluginWebConsentPrompt.confirm ) pluginWebPageLifecycle.register(pluginWebPages) @@ -694,10 +714,17 @@ final class MacServiceContainer { launchMode: moduleLaunchMode, startup: pluginStartup, managedBuiltInPlugins: bundledLanguageManifests, - invalidateWebPages: { id in pluginWebPageLifecycle.disable(id.rawValue) }, - enableWebPages: { id in pluginWebPageLifecycle.enable(id.rawValue) }, + invalidateWebPages: { id in + pluginWebPageLifecycle.disable(id.rawValue) + pluginBackends.disable(id.rawValue) + }, + enableWebPages: { id in + pluginBackends.enable(id.rawValue) + pluginWebPageLifecycle.enable(id.rawValue) + }, beforePackageReplacement: { id in pluginWebPageLifecycle.packageWillChange(id.rawValue) + pluginBackends.packageWillChange(id.rawValue) // The running provider still executes the old files; the // build systems' plugin list is a startup snapshot and does // not see the replacement. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift new file mode 100644 index 000000000..9acb25e19 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -0,0 +1,359 @@ +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI + +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +/// The backends of plugins (`backend` 1): one process per plugin for the +/// whole application, shared by every project window, because what a +/// backend holds, such as database connections, belongs to the user rather +/// than to a project. +/// +/// A process starts on the first `functions.*` call from one of the +/// plugin's pages and runs until the plugin is disabled, its package is +/// replaced or removed, or the application quits. Its root and working +/// directory is its `${stateDir}`, outside the app bundle and the package. +/// It may request only the host services its permissions grant; Core checks +/// each request, and this type performs it for that plugin alone. +@MainActor +final class MacPluginBackendSessions: PluginBackendRequesting { + /// Shared by every project window's container; the first container + /// creates it. + private static var sharedInstance: MacPluginBackendSessions? + + static func shared(make: () -> MacPluginBackendSessions) -> MacPluginBackendSessions { + if let sharedInstance { return sharedInstance } + let created = make() + sharedInstance = created + return created + } + + struct UnavailableError: LocalizedError, Equatable { + let message: String + var errorDescription: String? { message } + } + + /// The installed package a backend starts from. + struct Package: Equatable { + let manifest: Data + let packageURL: URL + } + + private let launchResolver: MacPluginLanguageServerLaunchResolver + private let tools: any BuildToolResolving + private let versionChecker: any LanguageServerRuntimeVersionChecking + private let runtime: any LanguageToolRuntimePort + private let core: any LanguageServerRuntimeCore + private weak var processRegistry: (any LanguageServerProcessRegistry)? + private let credentials: any PluginCredentialStoring + private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void + private let stuckSendGrace: Duration + private var packages: [String: Package] = [:] + /// Disabled plugins start again once enabled. + private var disabledPluginIDs: Set = [] + /// A replaced or removed package never starts again in this launch. + private var replacedPluginIDs: Set = [] + private var running: [String: Running] = [:] + + /// One process, from its start until it is dropped. `session` is set as + /// soon as the process is launched, so a stop during initialize ends it. + private final class Running { + let settings: [String: ToolingJSONValue] + var task: Task? + var session: LanguageServerRuntimeSession? + + init(settings: [String: ToolingJSONValue]) { + self.settings = settings + } + } + + init( + launchResolver: MacPluginLanguageServerLaunchResolver, + tools: any BuildToolResolving, + versionChecker: any LanguageServerRuntimeVersionChecking, + runtime: any LanguageToolRuntimePort, + core: any LanguageServerRuntimeCore, + processRegistry: (any LanguageServerProcessRegistry)?, + credentials: any PluginCredentialStoring, + stuckSendGrace: Duration = .seconds(2), + endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in + MacPluginBackendProcesses.end( + token: token, + gracePeriod: immediately ? 0 : TimeInterval(MacProcessSupervisor.storedSettings().gracefulStopSeconds) + ) + } + ) { + self.launchResolver = launchResolver + self.tools = tools + self.versionChecker = versionChecker + self.runtime = runtime + self.core = core + self.processRegistry = processRegistry + self.credentials = credentials + self.stuckSendGrace = stuckSendGrace + self.endBackendProcesses = endBackendProcesses + } + + /// Records the backends of the plugins loaded at launch. Every project + /// window registers the same startup snapshot; a package installed later + /// waits for the next launch like its pages. + func register(_ plugins: [InstalledPluginPackage]) { + for plugin in plugins where plugin.descriptor.declaresBackend { + let id = plugin.descriptor.id.rawValue + guard !replacedPluginIDs.contains(id), packages[id] == nil else { continue } + packages[id] = Package(manifest: plugin.descriptor.normalizedManifest, packageURL: plugin.packageURL) + } + } + + func declaresBackend(_ pluginID: String) -> Bool { + packages[pluginID] != nil + } + + func pluginFunctions(ofPlugin pluginID: String) async throws -> [String] { + try await session(for: pluginID).functionIDs + } + + func invokePluginFunction( + _ functionID: String, + params: ToolingJSONValue, + ofPlugin pluginID: String, + timeout: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + let session = try await session(for: pluginID) + let components = timeout.components + let seconds = TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + return try await session.invokeFunction(functionID, params: params, timeout: seconds, progress: progress) + } + + /// Stops the backend until `enable` is called. + func disable(_ pluginID: String) { + disabledPluginIDs.insert(pluginID) + stop(pluginID) + } + + func enable(_ pluginID: String) { + disabledPluginIDs.remove(pluginID) + } + + /// Stops the backend before its package is replaced, rolled back, or + /// uninstalled; it does not start again from the old files. + func packageWillChange(_ pluginID: String) { + replacedPluginIDs.insert(pluginID) + packages[pluginID] = nil + stop(pluginID) + } + + private func stop(_ pluginID: String) { + guard let entry = running.removeValue(forKey: pluginID) else { return } + entry.task?.cancel() + entry.session?.stop() + } + + // MARK: Start + + /// The plugin's running backend, started on first use. A process that + /// exited is replaced, and so is one started with other setting values. + private func session(for pluginID: String) async throws -> LanguageServerRuntimeSession { + try Task.checkCancellation() + guard let package = packages[pluginID], !disabledPluginIDs.contains(pluginID) else { + throw PluginFunctionFailure( + code: "plugin.function.unavailable", + message: String(localized: "This plugin has no backend that can run now.") + ) + } + let settings = launchResolver.backendSettingValues(forPlugin: pluginID) + if let entry = running[pluginID], let task = entry.task { + if entry.settings == settings, entry.session?.isRunning != false { + let session = try await Self.waitForStart(task) + guard running[pluginID] === entry else { throw CancellationError() } + return session + } + stop(pluginID) + } + let backend = try launchResolver.resolveBackend( + pluginID: pluginID, manifest: package.manifest, packageURL: package.packageURL + ) + // Another call may have started the process meanwhile. + if let entry = running[pluginID], let task = entry.task { + return try await Self.waitForStart(task) + } + let entry = Running(settings: backend.process.settings) + running[pluginID] = entry + // The start is shared by every call that arrives meanwhile, so no + // single caller's cancellation cancels it; a failed start clears + // the entry itself. + let task = Task { @MainActor [weak self] () throws -> LanguageServerRuntimeSession in + guard let self else { throw CancellationError() } + do { + return try await self.launch(backend, pluginID: pluginID, entry: entry) + } catch { + if self.running[pluginID] === entry { self.running[pluginID] = nil } + throw error + } + } + entry.task = task + let session = try await Self.waitForStart(task) + guard running[pluginID] === entry else { throw CancellationError() } + return session + } + + private func launch( + _ backend: MacPluginLanguageServerLaunchResolver.BackendProcess, + pluginID: String, + entry: Running + ) async throws -> LanguageServerRuntimeSession { + var (executableURL, environment) = try await resolveExecutable(backend.process, directory: backend.stateDirectory) + try Task.checkCancellation() + guard running[pluginID] === entry else { throw CancellationError() } + // Every process the backend starts inherits this token, so what it + // leaves behind can be ended when the backend is gone. + let backendToken = UUID().uuidString + environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + let session = LanguageServerRuntimeSession( + providerID: "pluginBackend:\(pluginID)", + executableURL: executableURL, + arguments: backend.process.arguments, + environment: environment, + initializationOptions: backend.process.initializationOptions, + initializeTimeout: 60, + core: core, + processRegistry: processRegistry, + // Memory is attributed to the plugin's UI module, whose pages + // are the backend's only callers. + moduleID: ModuleID("\(pluginID).ui"), + pluginID: pluginID, + hostServices: backend.hostServices + ) + let endBackendProcesses = endBackendProcesses + let credentials = credentials + session.stuckSendGrace = stuckSendGrace + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + session.onHostRequest = { [weak session] request in + // Keychain calls block, so they run off the main actor. + Task.detached(priority: .userInitiated) { + let outcome = PluginHostServices.answer(request, pluginID: pluginID, credentials: credentials) + await session?.respond(to: request.id, with: outcome) + } + } + entry.session = session + try await waitUntilReady(session, rootURL: backend.stateDirectory) + try Task.checkCancellation() + guard running[pluginID] === entry else { throw CancellationError() } + return session + } + + /// Starts the session and returns once `initialize` succeeded. A process + /// that fails or exits first ends the wait with its own message. + private func waitUntilReady(_ session: LanguageServerRuntimeSession, rootURL: URL) async throws { + var finished = false + defer { session.onStateChange = nil } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + func finish(_ result: Result) { + guard !finished else { return } + finished = true + continuation.resume(with: result) + } + session.onStateChange = { state in + switch state { + case .ready: + finish(.success(())) + case .failed(let failure): + finish(.failure(UnavailableError( + message: failure.message ?? String(localized: "The plugin backend failed to start.") + ))) + case .stopped: + finish(.failure(UnavailableError( + message: String(localized: "The plugin backend stopped before it was ready.") + ))) + case .startingProcess, .initializing, .stopping: + break + } + } + do { + try session.start(rootURL: rootURL, workspaceFingerprint: nil) + } catch { + finish(.failure(error)) + } + } + } + + /// A packaged file starts as it is. Commands and runtimes resolve through + /// the build tool discovery, with the backend's state directory as the + /// place a project-local tool would be looked up. + private func resolveExecutable( + _ process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, + directory: URL + ) async throws -> (URL, [String: String]) { + let runtimes = process.runtimeTools.map { + BuildInvocation.RuntimeDependency(id: $0, hostManaged: true) + } + let invocation: BuildInvocation + var requirement: LanguageServerExecutableVersionRequirement? + switch process.executable { + case .file(let url): + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + case .commands(let commands): + invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) + case .runtime(let tool, let versionRequirement): + requirement = versionRequirement + invocation = BuildInvocation( + executables: [.runtime(tool)], + runtimeDependencies: [BuildInvocation.RuntimeDependency( + id: tool, + hostManaged: true, + minimumVersion: versionRequirement?.minimumVersion, + versionArguments: versionRequirement?.probeArguments + )] + runtimes + ) + } + let versionChecker = versionChecker + let resolved = try await tools.resolve( + invocation, + displayName: String(localized: "Plugin backend"), + workspaceURL: directory, + checkVersion: { [requirement] _, output in + guard let requirement else { return BuildVersionCheck(satisfied: true, version: nil) } + let check = try versionChecker.checkRuntimeVersion(requirement, output: output) + return BuildVersionCheck(satisfied: check.satisfied, version: check.version) + } + ) + return (resolved.executableURL, resolved.environment) + } + + /// Waits for a shared start. A cancelled caller stops waiting at once, + /// while the start goes on for the other callers. + private static func waitForStart( + _ task: Task + ) async throws -> LanguageServerRuntimeSession { + let waiter = StartWaiter() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiter.wait(with: continuation) + if Task.isCancelled { + waiter.finish(.failure(CancellationError())) + return + } + Task { @MainActor in waiter.finish(await task.result) } + } + } onCancel: { + Task { @MainActor in waiter.finish(.failure(CancellationError())) } + } + } + + @MainActor + private final class StartWaiter { + private var continuation: CheckedContinuation? + + func wait(with continuation: CheckedContinuation) { + self.continuation = continuation + } + + func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift new file mode 100644 index 000000000..2c78c5296 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift @@ -0,0 +1,97 @@ +import Foundation +import LocalAuthentication +import Security + +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +/// Plugin credentials in the user's login Keychain. Each plugin gets a +/// Keychain service of its own (`.plugin-credentials.`), +/// so one query removes everything a plugin stored and no key a plugin +/// chooses can reach another plugin's items. Nothing is written into the +/// plugin package, its state directory, or the app bundle. +final class MacPluginCredentialStore: PluginCredentialStoring, @unchecked Sendable { + enum StoreError: LocalizedError { + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case .unexpectedStatus(let status): + let message = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown Keychain error" + return "Keychain error \(status): \(message)" + } + } + } + + private let servicePrefix: String + + /// - Parameter servicePrefix: the app's Keychain service for plugin + /// credentials; the plugin ID is appended per plugin. + init(servicePrefix: String) { + self.servicePrefix = servicePrefix + } + + func secret(pluginID: String, key: String) throws -> String? { + // A backend asks without a user action in front of it, so a locked + // or re-signed Keychain fails instead of showing a password prompt. + let context = LAContext() + context.interactionNotAllowed = true + var result: CFTypeRef? + let status = SecItemCopyMatching(query(pluginID: pluginID, key: key).merging([ + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ]) { _, new in new } as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let data = result as? Data else { return nil } + return String(decoding: data, as: UTF8.self) + case errSecItemNotFound: + return nil + default: + throw StoreError.unexpectedStatus(status) + } + } + + func setSecret(_ secret: String, pluginID: String, key: String) throws { + let data = Data(secret.utf8) + let query = query(pluginID: pluginID, key: key) + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { throw StoreError.unexpectedStatus(updateStatus) } + let addStatus = SecItemAdd(query.merging([ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ]) { _, new in new } as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw StoreError.unexpectedStatus(addStatus) } + } + + func deleteSecret(pluginID: String, key: String) throws { + try delete(query(pluginID: pluginID, key: key)) + } + + func deleteAll(pluginID: String) throws { + try delete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service(pluginID: pluginID) + ]) + } + + private func delete(_ query: [String: Any]) throws { + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw StoreError.unexpectedStatus(status) + } + } + + private func service(pluginID: String) -> String { + "\(servicePrefix).\(pluginID)" + } + + private func query(pluginID: String, key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service(pluginID: pluginID), + kSecAttrAccount as String: key + ] + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index 8ed9c8537..c1f360f1c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -60,6 +60,12 @@ struct MacPluginStateDirectories: Sendable { .appendingPathComponent(vcs, isDirectory: true) } + /// `${stateDir}` of the plugin backend, also its root and working + /// directory. It goes with the plugin directory on uninstall. + func backendStateDirectory(pluginID: String) -> URL { + pluginDirectory(pluginID: pluginID).appendingPathComponent("backend", isDirectory: true) + } + /// `${workspaceStateDir}` of one VCS provider. func vcsProviderWorkspaceStateDirectory(pluginID: String, vcs: String, workspaceRootURL: URL) -> URL { vcsProviderStateDirectory(pluginID: pluginID, vcs: vcs) @@ -144,13 +150,7 @@ struct MacPluginLanguageServerLaunchResolver { request: request ) try verifyPackagePaths(plan.requiredPackagePaths, packageURL: source.packageURL) - for directory in plan.requiredDirectories { - let url = switch directory { - case .state: request.stateDirectory - case .workspaceState: request.workspaceStateDirectory - } - try fileManager.createDirectory(at: url, withIntermediateDirectories: true) - } + try createRequiredDirectories(of: plan, request: request) return ResolvedLanguageServerLaunch( arguments: plan.arguments, initializationOptions: plan.initializationOptions, @@ -282,6 +282,48 @@ struct MacPluginLanguageServerLaunchResolver { ) } + /// The plugin backend's process, with the host services the session may + /// request. It belongs to no workspace, so its launch has no + /// `${workspaceStateDir}` and no project settings. + struct BackendProcess: Equatable { + let process: BuildProviderProcess + let hostServices: [String] + /// `${stateDir}`, created; the session's root and working directory. + let stateDirectory: URL + } + + /// The setting values a backend launch expands with now: the global + /// layer only, since a backend belongs to no project. + func backendSettingValues(forPlugin pluginID: String) -> [String: ToolingJSONValue] { + settings.values(forPlugin: pluginID) + } + + func resolveBackend(pluginID: String, manifest: Data, packageURL: URL) throws -> BackendProcess { + let launchSettings = backendSettingValues(forPlugin: pluginID) + let stateDirectory = directories.backendStateDirectory(pluginID: pluginID) + let request = DeclarativeLanguageServerLaunchRequest( + pluginDirectory: packageURL.standardizedFileURL, + stateDirectory: stateDirectory, + workspaceStateDirectory: nil, + architecture: Self.hostArchitecture, + settings: launchSettings + ) + let plan = try planner.backendLaunchPlan(manifest: manifest, request: request) + guard let backend = plan.backend else { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "Rust Core returned no backend for \(pluginID)." + ) + } + let process = try providerProcess( + plan, request: request, packageURL: packageURL, + executesProjectCode: false, mayUseNetwork: true, settings: launchSettings + ) + // The session starts in this directory even when no argument names it. + try fileManager.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + return BackendProcess(process: process, hostServices: backend.hostServices, stateDirectory: stateDirectory) + } + /// The executable of a plan's own process, with its package paths /// verified and state directories created. private func providerProcess( @@ -312,13 +354,7 @@ struct MacPluginLanguageServerLaunchResolver { message: "Rust Core returned a provider launch without an executable." ) } - for directory in plan.requiredDirectories { - let url = switch directory { - case .state: request.stateDirectory - case .workspaceState: request.workspaceStateDirectory - } - try fileManager.createDirectory(at: url, withIntermediateDirectories: true) - } + try createRequiredDirectories(of: plan, request: request) return BuildProviderProcess( executable: executable, arguments: plan.arguments, @@ -407,6 +443,28 @@ struct MacPluginLanguageServerLaunchResolver { ) } + /// Creates the state directories the plan expands into. Core refuses a + /// workspace directory the request did not give, so a missing one here + /// means the plan and request disagree. + private func createRequiredDirectories( + of plan: DeclarativeLanguageServerLaunchPlan, + request: DeclarativeLanguageServerLaunchRequest + ) throws { + for directory in plan.requiredDirectories { + let url: URL? = switch directory { + case .state: request.stateDirectory + case .workspaceState: request.workspaceStateDirectory + } + guard let url else { + throw DeclarativePluginRejection( + code: "plugin.launch.invalidRequest", + message: "The launch needs a workspace state directory, but it belongs to no workspace." + ) + } + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + } + /// A packaged file the host starts: inside the package, reached without /// a symbolic link, a regular file, and executable. Installation checks /// the same; this guards a package changed on disk since. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 75ea5928f..51f844a56 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -35,6 +35,9 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { /// A file a build system's provider launch points at is missing, outside /// the package, reached through a symbolic link, or not executable. case buildProviderLaunchMissing(PluginID, buildSystemID: String, path: String) + /// A file the plugin backend's launch points at is missing, outside the + /// package, reached through a symbolic link, or not executable. + case backendLaunchMissing(PluginID, path: String) /// A `sameTeamAsHost` package outside the host's sealed installation. case hostSealRequired(PluginID) /// An unsigned package outside a confirmed community installation, or @@ -67,6 +70,8 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { "Plugin \(id) is missing its language server or it is not executable: \(path)." case .buildProviderLaunchMissing(let id, let buildSystemID, let path): "Plugin \(id) is missing the \(buildSystemID) build provider's file or it is not usable: \(path)." + case .backendLaunchMissing(let id, let path): + "Plugin \(id) is missing its backend's file or it is not usable: \(path)." case .hostSealRequired(let id): "Plugin \(id) can only be trusted when it ships inside Lithe." case .unsignedPackageNotAllowed(let id): @@ -115,6 +120,8 @@ final class MacPluginPackageStore { /// Writable language-server state owned by installed plugins; removed /// together with the plugin so an uninstall leaves nothing behind. private let stateDirectories: MacPluginStateDirectories? + /// Secrets plugin backends stored; removed with the plugin's state. + private let credentials: (any PluginCredentialStoring)? private let hostVersion: PluginVersion private let validator: any DeclarativePluginManifestValidating /// Expands build provider launches so installation can check the files @@ -129,6 +136,7 @@ final class MacPluginPackageStore { rootURL: URL, bundledRootURL: URL? = nil, stateDirectories: MacPluginStateDirectories? = nil, + credentials: (any PluginCredentialStoring)? = nil, hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, validator: any DeclarativePluginManifestValidating, launchPlanner: (any DeclarativeLanguageServerLaunchPlanning)? = nil, @@ -138,6 +146,7 @@ final class MacPluginPackageStore { self.rootURL = rootURL.standardizedFileURL self.bundledRootURL = bundledRootURL?.standardizedFileURL self.stateDirectories = stateDirectories + self.credentials = credentials self.hostVersion = hostVersion self.validator = validator self.launchPlanner = launchPlanner ?? validator as? any DeclarativeLanguageServerLaunchPlanning @@ -147,13 +156,18 @@ final class MacPluginPackageStore { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] } - convenience init(fileStorage: any FileStorage, validator: any DeclarativePluginManifestValidating) { + convenience init( + fileStorage: any FileStorage, + validator: any DeclarativePluginManifestValidating, + credentials: (any PluginCredentialStoring)? = nil + ) { self.init( rootURL: fileStorage.applicationSupportDirectory() .appendingPathComponent("Lithe/Plugins", isDirectory: true), bundledRootURL: Bundle.main.resourceURL? .appendingPathComponent("OfficialPlugins", isDirectory: true), stateDirectories: MacPluginStateDirectories(fileStorage: fileStorage), + credentials: credentials, validator: validator ) } @@ -408,7 +422,7 @@ final class MacPluginPackageStore { try validatePackage(descriptor, at: stagedURL, origin: origin, marketplace: marketplace, disk: disk) } catch let error as PluginPackageStoreError { switch error { - case .languageServerLaunchMissing, .buildProviderLaunchMissing: + case .languageServerLaunchMissing, .buildProviderLaunchMissing, .backendLaunchMissing: throw PluginPackageStoreError.invalidInstalledPlugin(descriptor.id, error.localizedDescription) default: throw error @@ -611,9 +625,12 @@ final class MacPluginPackageStore { } /// Best effort: the package is already gone, and leftover state of an - /// uninstalled plugin is never read again. + /// uninstalled plugin is never read again. Its backend's credentials go + /// with it; disabling or updating the plugin keeps both. private func removeState(of pluginID: PluginID) { - guard let stateDirectories, (try? validatePathComponent(pluginID.rawValue)) != nil else { return } + guard (try? validatePathComponent(pluginID.rawValue)) != nil else { return } + try? credentials?.deleteAll(pluginID: pluginID.rawValue) + guard let stateDirectories else { return } try? fileManager.removeItem(at: stateDirectories.pluginDirectory(pluginID: pluginID.rawValue)) } @@ -710,6 +727,34 @@ final class MacPluginPackageStore { for buildSystem in descriptor.buildSystems where buildSystem.declaresProvider { try validateBuildProviderLaunch(buildSystem.id, of: descriptor, at: root) } + if descriptor.declaresBackend { + try validateBackendLaunch(of: descriptor, at: root) + } + } + + /// Expands the backend launch with a throwaway state directory and + /// checks every package path it points at, like a provider's. + private func validateBackendLaunch(of descriptor: DeclarativePluginDescriptor, at root: URL) throws { + let missing = { (path: String) in PluginPackageStoreError.backendLaunchMissing(descriptor.id, path: path) } + guard let launchPlanner else { throw missing("backend") } + let unused = fileManager.temporaryDirectory + .appendingPathComponent("lithe-backend-validation", isDirectory: true) + let plan: DeclarativeLanguageServerLaunchPlan + do { + plan = try launchPlanner.backendLaunchPlan( + manifest: descriptor.normalizedManifest, + request: DeclarativeLanguageServerLaunchRequest( + pluginDirectory: root, + stateDirectory: unused.appendingPathComponent("state", isDirectory: true), + workspaceStateDirectory: nil, + architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + settings: [:] + ) + ) + } catch let rejection as DeclarativePluginRejection { + throw PluginPackageStoreError.declarationRejected(descriptor.id, rejection) + } + try validatePlannedFiles(plan, at: root, missing: missing) } /// Expands the provider launch with throwaway directories (nothing is @@ -744,6 +789,16 @@ final class MacPluginPackageStore { throw PluginPackageStoreError.declarationRejected(descriptor.id, rejection) } guard plan.buildProvider?.languageServer == nil else { return } + try validatePlannedFiles(plan, at: root, missing: missing) + } + + /// Every package path the plan points at exists inside the package, and + /// the files it starts are regular and executable. + private func validatePlannedFiles( + _ plan: DeclarativeLanguageServerLaunchPlan, + at root: URL, + missing: (String) -> PluginPackageStoreError + ) throws { for relativePath in plan.requiredPackagePaths where !isPackageEntry(relativePath, in: root) { throw missing(relativePath) } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index f61d8bca8..6b5633a84 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -295,6 +295,8 @@ package struct LanguageServerRuntimeEvent: Equatable, Sendable { package let level: String? package let message: String? package let detail: String? + /// The protocol method the event belongs to, when Core names one. + package var method: String? package init( type: String, @@ -310,8 +312,10 @@ package struct LanguageServerRuntimeEvent: Equatable, Sendable { message: String? = nil, detail: String? = nil, mavenProfileTask: String? = nil, - mavenProfileProject: MavenProfileProjectResult? = nil + mavenProfileProject: MavenProfileProjectResult? = nil, + method: String? = nil ) { + self.method = method self.mavenProfileTask = mavenProfileTask self.mavenProfileProject = mavenProfileProject self.type = type @@ -502,6 +506,29 @@ package protocol LanguageServerRuntimeCore: Sendable { requestTimeout: TimeInterval, shutdownTimeout: TimeInterval ) -> Result + /// Starts a plugin backend that may request the host services named in + /// `hostServices` (function protocol 2). Core checks every request + /// against them before publishing a `hostRequest` event. + func startPluginLanguageServer( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result + /// Answers one `hostRequest` event; Core sends the result, or the + /// failure as `plugin.host.failed`, to the backend. + func respondToPluginHostRequest( + sessionID: String, + hostRequestID: String, + outcome: Result + ) -> Result /// Replaces the configuration a running plugin server reads and tells it /// through `workspace/didChangeConfiguration`. func updateWorkspaceConfiguration( @@ -696,6 +723,45 @@ package extension LanguageServerRuntimeCore { ) } + /// Runtimes without host services start the backend only when it + /// needs none. + func startPluginLanguageServer( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + guard hostServices.isEmpty else { + return .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "Plugin host services are not supported by this runtime." + )) + } + return startPluginLanguageServer( + pluginID: pluginID, providerID: providerID, executableURL: executableURL, arguments: arguments, + environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout + ) + } + + func respondToPluginHostRequest( + sessionID _: String, + hostRequestID _: String, + outcome _: Result + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "Plugin host services are not supported by this runtime." + )) + } + func updateWorkspaceConfiguration( sessionID _: String, configuration _: ToolingJSONValue @@ -720,6 +786,34 @@ package extension LanguageServerRuntimeCore { } } +/// A host-service request a plugin backend sent, after Core checked its +/// method, grant, and params (function protocol 2). +package struct PluginHostRequest: Equatable, Sendable { + /// Core's ID for `lsp.hostResponse`. + package let id: String + package let method: String + /// The granted service the method belongs to, such as `credentials`. + package let service: String + package let params: ToolingJSONValue + + package init(id: String, method: String, service: String, params: ToolingJSONValue) { + self.id = id + self.method = method + self.service = service + self.params = params + } +} + +/// The host could not complete a host-service request. The message reaches +/// the backend; never put a secret in it. +package struct PluginHostRequestFailure: Error, Equatable, Sendable { + package let message: String + + package init(message: String) { + self.message = message + } +} + /// One progress report of a running plugin function, as Core normalized it. package struct PluginFunctionProgress: Equatable, Sendable { package let message: String? diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index fc8120504..ca4563160 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -139,7 +139,9 @@ public struct LanguageServerRuntimeVersionCheck: Equatable, Sendable { public struct DeclarativeLanguageServerLaunchRequest: Equatable, Sendable { public let pluginDirectory: URL public let stateDirectory: URL - public let workspaceStateDirectory: URL + /// Nil for a process bound to no workspace, such as a plugin backend; + /// Core then refuses a launch that names `${workspaceStateDir}`. + public let workspaceStateDirectory: URL? /// `arm64` or `x86_64`. public let architecture: String /// Values the user stored for the plugin's settings, by setting ID. Rust @@ -152,7 +154,7 @@ public struct DeclarativeLanguageServerLaunchRequest: Equatable, Sendable { public init( pluginDirectory: URL, stateDirectory: URL, - workspaceStateDirectory: URL, + workspaceStateDirectory: URL?, architecture: String, settings: [String: ToolingJSONValue] = [:], projectSettings: [String: ToolingJSONValue] = [:] @@ -445,6 +447,17 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { public let buildProvider: BuildProvider? /// Set when the plan was selected by VCS provider. public var vcsProvider: VcsProvider? = nil + /// Set when the plan is the plugin's backend: the host services its + /// permissions grant, passed to the session unchanged. + public var backend: Backend? = nil + + public struct Backend: Equatable, Sendable { + public let hostServices: [String] + + public init(hostServices: [String]) { + self.hostServices = hostServices + } + } public enum Executable: Equatable, Sendable { /// Package-relative file. @@ -589,6 +602,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let ui: DeclarativeUIContributions? /// Entries the plugin adds to the New Project window. public let projectGenerators: [DeclarativeProjectGenerator] + /// Whether the plugin declares its own backend process (`backend` 1). + public let declaresBackend: Bool public let normalizedManifest: Data public var id: PluginID { pluginManifest.id } @@ -631,6 +646,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { DeclarativeProjectGenerator(id: $0.id, displayName: $0.displayName, icon: $0.icon) } ui = decoded.ui + declaresBackend = decoded.backend != nil settings = try decoded.settings.map { try $0.declaration() } settingActions = decoded.settingActions.map { $0.declaration() } languages = decoded.languages.map { DeclarativePluginLanguage(id: $0.id, displayName: $0.displayName) } @@ -1098,6 +1114,9 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let settings: [Setting] let settingActions: [SettingAction] let projectGenerators: [ProjectGenerator] + /// Launch fields stay with Rust Core; the host only needs to know + /// a backend exists. + let backend: ToolingJSONValue? /// What plugin management lists. Fields and steps stay with Rust Core, /// which evaluates the New Project form and plans every step. @@ -1125,12 +1144,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] projectGenerators = try values.decodeIfPresent([ProjectGenerator].self, forKey: .projectGenerators) ?? [] + backend = try values.decodeIfPresent(ToolingJSONValue.self, forKey: .backend) } private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions - case projectGenerators + case projectGenerators, backend } } } @@ -1234,6 +1254,11 @@ public protocol DeclarativeLanguageServerLaunchPlanning: Sendable { vcs: String, request: DeclarativeLanguageServerLaunchRequest ) throws -> DeclarativeLanguageServerLaunchPlan + /// The launch plan of the plugin's backend, with `backend` set. + func backendLaunchPlan( + manifest: Data, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan } public extension DeclarativeLanguageServerLaunchPlanning { @@ -1258,6 +1283,16 @@ public extension DeclarativeLanguageServerLaunchPlanning { message: "This host cannot plan VCS provider launches." ) } + + func backendLaunchPlan( + manifest _: Data, + request _: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "This host cannot plan plugin backend launches." + ) + } } /// Reads a launch runtime's version from its probe output and compares it diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 71afb65c7..c4b470b70 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -45,6 +45,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// The plugin that owns the process; nil for host-owned servers, which /// serve no plugin functions. private let pluginID: String? + /// Host services Core lets this plugin backend request. + private let hostServices: [String] private var sessionID: String? private var pendingOperations: [String: PendingOperation] = [:] @@ -91,6 +93,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// `functionsChanged` event listed them. package private(set) var functionIDs: [String] = [] package var onFunctionsChange: (([String]) -> Void)? + /// Receives each host-service request Core accepted. The owner answers + /// it once through `respond(to:with:)`; without an owner the request is + /// refused so the backend never waits for an answer that cannot come. + package var onHostRequest: ((PluginHostRequest) -> Void)? /// The VCS provider the process negotiated in `initialize`, as Core's /// `vcsProvider` event reported it; nil for every other session. package private(set) var vcsNegotiation: VcsProviderNegotiation? @@ -114,7 +120,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence, - pluginID: String? = nil + pluginID: String? = nil, + hostServices: [String] = [] ) { self.providerID = providerID self.executableURL = executableURL @@ -131,6 +138,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.processRegistry = processRegistry self.moduleID = moduleID self.pluginID = pluginID + self.hostServices = hostServices } /// Derived from the last lifecycle state Rust published: there is no local @@ -193,7 +201,21 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { ([executableURL.path] + arguments).joined(separator: " "), nil ) - let started = if let pluginID { + let started = if let pluginID, !hostServices.isEmpty { + core.startPluginLanguageServer( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: initializationOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else if let pluginID { core.startPluginLanguageServer( pluginID: pluginID, providerID: providerID, @@ -680,6 +702,17 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Answers a host-service request. Core writes the answer to the + /// backend's input, which a backend that stopped reading can block, so + /// it is sent off the main actor like a request. + package func respond(to requestID: String, with outcome: Result) { + guard let sessionID else { return } + let core = core + Task.detached(priority: .userInitiated) { + _ = core.respondToPluginHostRequest(sessionID: sessionID, hostRequestID: requestID, outcome: outcome) + } + } + /// Codes Core puts in `details` when it refuses a call before it reaches /// the backend. Any other `details`, such as an OS error text from a /// failed write, is a diagnostic and never becomes the page's code. @@ -1042,6 +1075,21 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if case .integer(let value)? = object["percentage"] { percentage = value } listener(PluginFunctionProgress(message: message, percentage: percentage)) return false + case "hostRequest": + guard case .object(let object)? = event.result, + case .string(let id)? = object["hostRequestID"], + case .string(let service)? = object["service"], + let method = event.method else { + onLog?(.error, "Invalid host request event", nil, nil) + return false + } + let request = PluginHostRequest(id: id, method: method, service: service, params: object["params"] ?? .null) + if let onHostRequest { + onHostRequest(request) + } else { + respond(to: id, with: .failure(PluginHostRequestFailure(message: "The host serves no requests of this process."))) + } + return false case "vcsProvider": switch Self.decodeEventResult(event, as: VcsProviderNegotiation.self) { case .success(let negotiation): vcsNegotiation = negotiation diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 2e81d9a2b..84639f226 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -490,6 +490,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// level 3 plugin that requires anything else. Validating a capability is /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ + "backend": [1], "buildProviders": [1], "buildSystems": [1, 2], "executableResolution": [1], "executionProviders": [1], diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift new file mode 100644 index 000000000..723f92cc7 --- /dev/null +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -0,0 +1,324 @@ +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing + +/// The plugin backend table with a scripted runtime core and Rust Core's +/// real launch planning: where a backend starts, which host services it +/// receives, how its credential requests are answered, and when it stops. +@MainActor +struct MacPluginBackendSessionsTests { + /// The first page call starts the backend in its own state directory, + /// outside the package, with the services its permissions grant; later + /// calls reuse the process. + @Test + func aPageCallStartsOneBackendInItsStateDirectoryWithItsHostServices() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + + #expect(try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) == ["query.run"]) + #expect(try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) == ["query.run"]) + + let starts = fixture.core.starts + #expect(starts.count == 1) + let stateDirectory = fixture.stateRoot + .appendingPathComponent(BackendFixture.pluginID, isDirectory: true) + .appendingPathComponent("backend", isDirectory: true) + #expect(starts.first?.pluginID == BackendFixture.pluginID) + #expect(starts.first?.hostServices == ["credentials"]) + #expect(starts.first?.rootURL.standardizedFileURL.path == stateDirectory.standardizedFileURL.path) + #expect(starts.first?.arguments == ["--state", stateDirectory.standardizedFileURL.path]) + #expect(FileManager.default.fileExists(atPath: stateDirectory.path)) + } + + /// A credential request is answered from the plugin's own entries, and + /// a store failure reaches the backend without the secret. + @Test + func credentialRequestsAreAnsweredFromThePluginsOwnStore() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + try fixture.credentials.setSecret("other", pluginID: "dev.example.other", key: "connection.prod") + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + fixture.core.sendHostRequest( + id: "host-1", method: "lithe/host/credentials/set", + params: .object(["key": .string("connection.prod"), "secret": .string("s3cret")]) + ) + #expect(await fixture.core.waitForResponses(1)) + fixture.core.sendHostRequest( + id: "host-2", method: "lithe/host/credentials/get", params: .object(["key": .string("connection.prod")]) + ) + #expect(await fixture.core.waitForResponses(2)) + fixture.core.sendHostRequest( + id: "host-3", method: "lithe/host/credentials/delete", params: .object(["key": .string("connection.prod")]) + ) + #expect(await fixture.core.waitForResponses(3)) + + #expect(fixture.core.responses == [ + .init(id: "host-1", outcome: .success(.null)), + .init(id: "host-2", outcome: .success(.object(["secret": .string("s3cret")]))), + .init(id: "host-3", outcome: .success(.null)), + ]) + #expect(try fixture.credentials.secret(pluginID: BackendFixture.pluginID, key: "connection.prod") == nil) + #expect(try fixture.credentials.secret(pluginID: "dev.example.other", key: "connection.prod") == "other") + } + + /// Disabling stops the backend and refuses calls until the plugin is + /// enabled again; a replaced package never starts again in this launch. + @Test + func disablingAndReplacingThePackageStopTheBackend() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + fixture.sessions.disable(BackendFixture.pluginID) + #expect(fixture.core.stops.count == 1) + await #expect(throws: PluginFunctionFailure.self) { + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + } + + fixture.sessions.enable(BackendFixture.pluginID) + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + #expect(fixture.core.starts.count == 2) + + fixture.sessions.packageWillChange(BackendFixture.pluginID) + #expect(fixture.core.stops.count == 2) + fixture.sessions.enable(BackendFixture.pluginID) + #expect(!fixture.sessions.declaresBackend(BackendFixture.pluginID)) + await #expect(throws: PluginFunctionFailure.self) { + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + } + } + + /// The answer never echoes a secret, and a request outside the + /// credentials service is refused even if Core forwarded it. + @Test + func hostServiceFailuresNeverCarryTheSecret() { + let failing = FailingCredentialStore() + let set = PluginHostServices.answer( + PluginHostRequest( + id: "host-1", method: "lithe/host/credentials/set", service: "credentials", + params: .object(["key": .string("k"), "secret": .string("top-secret")]) + ), + pluginID: BackendFixture.pluginID, credentials: failing + ) + guard case .failure(let failure) = set else { + Issue.record("A store failure must fail the request.") + return + } + #expect(!failure.message.contains("top-secret")) + + let other = PluginHostServices.answer( + PluginHostRequest(id: "host-2", method: "lithe/host/process/run", service: "process", params: .object([:])), + pluginID: BackendFixture.pluginID, credentials: MemoryPluginCredentialStore() + ) + #expect((try? other.get()) == nil) + } +} + +private struct FailingCredentialStore: PluginCredentialStoring { + struct Locked: LocalizedError { var errorDescription: String? { "The Keychain is locked." } } + func secret(pluginID _: String, key _: String) throws -> String? { throw Locked() } + func setSecret(_: String, pluginID _: String, key _: String) throws { throw Locked() } + func deleteSecret(pluginID _: String, key _: String) throws { throw Locked() } + func deleteAll(pluginID _: String) throws { throw Locked() } +} + +/// The backend fixture of the shared contract as an installed package. +@MainActor +private struct BackendFixture { + static let pluginID = "dev.lithe.fixture.backend" + + let root: URL + let packageURL: URL + let stateRoot: URL + let credentials = MemoryPluginCredentialStore() + let core = ScriptedBackendCore() + let sessions: MacPluginBackendSessions + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-backend-\(UUID().uuidString)", isDirectory: true) + packageURL = root.appendingPathComponent("package", isDirectory: true) + stateRoot = root.appendingPathComponent("state", isDirectory: true) + let fixtureURL = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/backend.plugin.json") + let object = try #require( + try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] + ) + _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) + let executable = packageURL.appendingPathComponent("bin/backend") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let operations = DeclarativePluginTestSupport.operations + let descriptor = try operations.validate(manifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json"))) + #expect(descriptor.declaresBackend) + + sessions = MacPluginBackendSessions( + launchResolver: MacPluginLanguageServerLaunchResolver( + planner: operations, + directories: MacPluginStateDirectories(rootURL: stateRoot), + settings: InMemoryPluginSettings() + ), + tools: UnusedBackendTools(), + versionChecker: operations, + runtime: EmptyBackendToolRuntime(), + core: core, + processRegistry: nil, + credentials: credentials, + endBackendProcesses: { _, _ in } + ) + sessions.register([InstalledPluginPackage( + descriptor: descriptor, + installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .localDevelopment + ), + packageURL: packageURL + )]) + } + + func remove() { + sessions.packageWillChange(Self.pluginID) + try? FileManager.default.removeItem(at: root) + } +} + +/// Starts ready plugin backends, queues host requests on demand, and +/// records how the host answered them. +private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked Sendable { + struct Start { + let pluginID: String + let arguments: [String] + let rootURL: URL + let hostServices: [String] + } + + struct Response: Equatable { + let id: String + let outcome: Result + } + + private let lock = NSLock() + private var nextSession = 0 + private var events: [String: [LanguageServerRuntimeEvent]] = [:] + private var _starts: [Start] = [] + private var _stops: [String] = [] + private var _responses: [Response] = [] + + var starts: [Start] { lock.withLock { _starts } } + var stops: [String] { lock.withLock { _stops } } + var responses: [Response] { lock.withLock { _responses } } + + func startPluginLanguageServer( + pluginID: String, providerID _: String, executableURL _: URL, arguments: [String], + environment _: [String: String], rootURL: URL, initializationOptions _: ToolingJSONValue?, + hostServices: [String], initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, + shutdownTimeout _: TimeInterval + ) -> Result { + lock.withLock { + nextSession += 1 + let sessionID = "backend-\(nextSession)" + _starts.append(Start(pluginID: pluginID, arguments: arguments, rootURL: rootURL, hostServices: hostServices)) + events[sessionID, default: []] += [ + LanguageServerRuntimeEvent( + type: "functionsChanged", + result: .object(["functions": .array([.string("query.run")]), "removed": .array([])]) + ), + LanguageServerRuntimeEvent(type: "stateChanged", state: "ready"), + ] + return .success(LanguageServerRuntimeStart(sessionID: sessionID, state: "initializing", processID: nil)) + } + } + + /// Queues a request Core accepted on the newest session. + func sendHostRequest(id: String, method: String, params: ToolingJSONValue) { + lock.withLock { + events["backend-\(nextSession)", default: []].append(LanguageServerRuntimeEvent( + type: "hostRequest", + result: .object(["hostRequestID": .string(id), "service": .string("credentials"), "params": params]), + method: method + )) + } + } + + func respondToPluginHostRequest( + sessionID _: String, hostRequestID: String, outcome: Result + ) -> Result { + lock.withLock { _responses.append(Response(id: hostRequestID, outcome: outcome)) } + return .success(()) + } + + /// Answers arrive off the main actor; waits a bounded time for them. + func waitForResponses(_ count: Int) async -> Bool { + let deadline = ContinuousClock.now + .seconds(5) + while ContinuousClock.now < deadline { + if responses.count >= count { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return false + } + + func startLanguageServer( + providerID _: String, executableURL _: URL, arguments _: [String], environment _: [String: String], + rootURL _: URL, workingDirectoryURL _: URL, initializationOptions _: ToolingJSONValue?, + runtimeExecutableURL _: URL?, jdtlsLaunchResources _: JDTLSLaunchResources?, cacheDirectoryURL _: URL?, + workspaceFingerprint _: String?, initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, + shutdownTimeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure(code: "unexpected", message: "A backend is plugin-owned.")) + } + + func stopLanguageServer(sessionID: String) { + lock.withLock { + _stops.append(sessionID) + events[sessionID, default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "stopped")) + } + } + + func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] { + lock.withLock { events.removeValue(forKey: sessionID) ?? [] } + } + + func syncLanguageServerDocument( + sessionID _: String, fileURL _: URL, languageID _: String, text _: String + ) -> Result { + .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) + } + + func closeLanguageServerDocument(sessionID _: String, fileURL _: URL) {} + + func requestLanguageServerOperation( + sessionID _: String, operation _: LanguageServerOperation, fileURL _: URL?, virtualURI _: String?, + position _: LanguageServerPosition?, newName _: String?, range _: LanguageServerRange?, + diagnostics _: [LanguageServerDiagnostic], completionItem _: LanguageServerCompletionItem?, + codeAction _: LanguageServerCodeAction?, command _: LanguageServerCommand? + ) -> Result { + .success(LanguageServerRuntimeOperation(operationID: "unused")) + } + + func cancelLanguageServerOperation(sessionID _: String, operationID _: String) {} + func destroyLanguageServer(sessionID _: String) {} +} + +/// The packaged backend never goes through tool discovery. +private final class UnusedBackendTools: BuildToolResolving { + struct Unexpected: Error {} + + func resolve( + _: BuildInvocation, displayName _: String, workspaceURL _: URL, + checkVersion _: @escaping @Sendable (String?, String) throws -> BuildVersionCheck + ) async throws -> ResolvedBuildTool { + throw Unexpected() + } +} + +private final class EmptyBackendToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 6793100f1..70726a96f 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -325,6 +325,42 @@ struct PluginPackageStoreTests { } } + /// The backend's packaged executable is checked at installation like a + /// provider's, so a broken package is refused before a page calls it. + @Test(arguments: ["usable", "missing", "notExecutable"]) + func packagedBackendIsCheckedAtInstallation(_ defect: String) throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + let package = root.appendingPathComponent("sources/backend", isDirectory: true) + let fixtureURL = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/backend.plugin.json") + let object = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any]) + _ = try DeclarativePluginTestSupport.writePackage(at: package, manifest: object) + try FileManager.default.createDirectory(at: package.appendingPathComponent("ui"), withIntermediateDirectories: true) + try Data("".utf8).write(to: package.appendingPathComponent("ui/index.html")) + let executable = package.appendingPathComponent("bin/backend") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + switch defect { + case "missing": try FileManager.default.removeItem(at: executable) + case "notExecutable": try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: executable.path) + default: break + } + + let pluginID = PluginID("dev.lithe.fixture.backend") + guard defect != "usable" else { + #expect(try store.installPackage(from: package).descriptor.declaresBackend) + return + } + #expect(throws: PluginPackageStoreError.invalidInstalledPlugin( + pluginID, PluginPackageStoreError.backendLaunchMissing(pluginID, path: "bin/backend").localizedDescription + )) { + _ = try store.installPackage(from: package) + } + } + /// A runtime launch needs its packaged entry; the runtime itself is /// discovered on the machine and is not part of the package. @Test @@ -521,27 +557,36 @@ struct PluginPackageStoreTests { let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let directories = MacPluginStateDirectories(rootURL: root.appendingPathComponent("state", isDirectory: true)) - let store = makeStore(root: root, stateDirectories: directories) + // Backend credentials share the state's lifetime. + let credentials = MemoryPluginCredentialStore() + let store = makeStore(root: root, stateDirectories: directories, credentials: credentials) let version = PluginVersion(major: 0, minor: 3, patch: 0) func writeState(for plugin: InstalledPluginPackage) throws -> URL { let state = directories.stateDirectory(pluginID: plugin.manifest.id.rawValue, languageID: "fixture") try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) try Data("index".utf8).write(to: state.appendingPathComponent("index")) + try credentials.setSecret("s3cret", pluginID: plugin.manifest.id.rawValue, key: "connection") return directories.pluginDirectory(pluginID: plugin.manifest.id.rawValue) } + func secret(of plugin: InstalledPluginPackage) throws -> String? { + try credentials.secret(pluginID: plugin.manifest.id.rawValue, key: "connection") + } let immediate = try store.installPackage(from: makePackage(root: root, name: "one", version: version)) let immediateState = try writeState(for: immediate) try store.uninstall(immediate.manifest.id) #expect(!FileManager.default.fileExists(atPath: immediateState.path)) + #expect(try secret(of: immediate) == nil) // A staged uninstall keeps the state until the package itself goes. let staged = try store.installPackage(from: makePackage(root: root, name: "two", version: version)) let stagedState = try writeState(for: staged) try store.stageUninstall(staged.manifest.id) #expect(FileManager.default.fileExists(atPath: stagedState.path)) + #expect(try secret(of: staged) == "s3cret") try store.prepareForLaunch() #expect(!FileManager.default.fileExists(atPath: stagedState.path)) + #expect(try secret(of: staged) == nil) } /// An installed schema-1 native PHP package is reported as needing an @@ -742,12 +787,14 @@ struct PluginPackageStoreTests { root: URL, bundledRoot: URL? = nil, stateDirectories: MacPluginStateDirectories? = nil, + credentials: (any PluginCredentialStoring)? = nil, verifier: any PluginPackageSignatureVerifying = TestPluginSignatureVerifier() ) -> MacPluginPackageStore { MacPluginPackageStore( rootURL: installedRoot(root), bundledRootURL: bundledRoot, stateDirectories: stateDirectories, + credentials: credentials, validator: DeclarativePluginTestSupport.operations, verifier: verifier ) diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 02f190c72..e9a955c18 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -98,6 +98,36 @@ struct PluginWebBridgeTests { #expect(harness.host.consents.allSatisfy { $0.pluginID == "dev.example.web" }) } + /// Without `instance` a call goes to the plugin's own backend, which the + /// install review covered, so no consent is asked; a plugin without a + /// backend is refused. + @Test func functionsWithoutAnInstanceGoToThePluginsBackendWithoutConsent() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + #expect(try await harness.error("functions.list") == "plugin.web.forbidden") + + let backends = FakeBackends(declared: ["dev.example.web"]) + harness.host.backends = backends + var events: [(String, ToolingJSONValue)] = [] + harness.bridge.emit = { events.append(($0, $1)) } + + #expect(try await harness.ok("functions.list") == .object(["functions": .array([.string("query.run")])])) + let value = try await harness.ok("functions.invoke", [ + "function": .string("query.run"), "params": .object(["sql": .string("select 1")]), "call": .string("c1") + ]) + #expect(value == .object(["value": .object(["sql": .string("select 1")])])) + #expect(backends.invocations == ["dev.example.web/query.run"]) + #expect(events.map(\.0) == ["functions.progress"]) + #expect(events.first?.1 == .object([ + "call": .string("c1"), "function": .string("query.run"), "message": .string("Running"), + ])) + #expect(harness.host.consents.isEmpty) + #expect(harness.build.functionListings == 0) + + backends.declared = [] + #expect(try await harness.error("functions.invoke", ["function": .string("query.run")]) == "plugin.web.forbidden") + } + @Test func functionResultsProgressAndPluginFailuresReachThePage() async throws { let harness = try BridgeHarness() defer { harness.cleanUp() } @@ -967,6 +997,8 @@ private final class FakeHost: PluginWebBridgeHost { var outputShown = 0 var onAccess: (() -> Void)? var confirmProcessStart: (@MainActor () async -> Bool)? + var backends: FakeBackends? + var pluginWebBackends: (any PluginBackendRequesting)? { backends } init(workspaceURL: URL, build: FakeBuildAccess) { pluginWebWorkspaceURL = workspaceURL @@ -986,6 +1018,26 @@ private final class FakeHost: PluginWebBridgeHost { } } +/// Echoes a function's params after one progress report. +@MainActor +private final class FakeBackends: PluginBackendRequesting { + var declared: Set + var invocations: [String] = [] + + init(declared: Set) { self.declared = declared } + + func declaresBackend(_ pluginID: String) -> Bool { declared.contains(pluginID) } + func pluginFunctions(ofPlugin _: String) async throws -> [String] { ["query.run"] } + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout _: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + invocations.append("\(pluginID)/\(functionID)") + progress(PluginFunctionProgress(message: "Running", percentage: nil)) + return params + } +} + @MainActor private final class FakeBuildAccess: PluginWebBuildAccess { var entries: [BuildSystemEntry] diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index c53eb2f90..60b26ce11 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -440,7 +440,9 @@ pub struct VcsProviderRequest { /// service a plugin backend asked for. Exactly one of `result` and `error`. pub struct PluginHostResponse { pub session_id: String, - /// The `hostRequestID` of the `hostRequest` event being answered. + /// The `hostRequestID` of the `hostRequest` event being answered, + /// spelled as the event spells it. + #[serde(rename = "hostRequestID")] pub host_request_id: String, /// The service's result; `null` for a write or delete. #[serde(default)] @@ -12429,14 +12431,21 @@ public class Main { result: Option, error: Option<&str>, ) -> Result<(), CoreError> { - harness.session().host_response(PluginHostResponse { - session_id: harness.session_id.clone(), - host_request_id: host_request_id.to_string(), - result, - error: error.map(|message| PluginHostFailure { - message: message.to_string(), - }), - }) + // Decoded from the wire shape `lsp.hostResponse` documents, so the + // field spelling is checked with the round trip. + let mut wire = json!({ + "sessionId": harness.session_id, + "hostRequestID": host_request_id, + }); + if let Some(result) = result { + wire["result"] = result; + } + if let Some(message) = error { + wire["error"] = json!({ "message": message }); + } + let response: PluginHostResponse = + serde_json::from_value(wire).expect("documented wire shape"); + harness.session().host_response(response) } /// A granted credential request reaches the host as `hostRequest`. The diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 2b17044c2..6b29c9da6 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -458,8 +458,8 @@ providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install review; template-only build plugins remain available. It does not declare `vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. It does not declare `backend` until it -starts plugin backends. Core supports `backend: 1`, `buildProviders: 1`, and +the macOS host starts providers. It declares `backend: [1]`: it starts +plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and `vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. From f8ae6168b2df99de2b6d3f2e538a6b8305d09922 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:12:36 -0700 Subject: [PATCH 08/16] test(macos): wait for backend host responses with a continuation instead of polling The test-stability gate rejects real-time sleeps. The scripted Core now resumes waiters as answers arrive, with a five-second watchdog. Co-Authored-By: Claude Opus 5.5 --- .../MacPluginBackendSessionsTests.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index 723f92cc7..7270fe8f0 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -207,6 +207,7 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S private var _starts: [Start] = [] private var _stops: [String] = [] private var _responses: [Response] = [] + private var waiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] var starts: [Start] { lock.withLock { _starts } } var stops: [String] { lock.withLock { _stops } } @@ -247,18 +248,31 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S func respondToPluginHostRequest( sessionID _: String, hostRequestID: String, outcome: Result ) -> Result { - lock.withLock { _responses.append(Response(id: hostRequestID, outcome: outcome)) } + let satisfied = lock.withLock { + _responses.append(Response(id: hostRequestID, outcome: outcome)) + let ready = waiters.filter { $0.value.count <= _responses.count } + ready.keys.forEach { waiters.removeValue(forKey: $0) } + return ready.values.map(\.continuation) + } + satisfied.forEach { $0.resume(returning: true) } return .success(()) } - /// Answers arrive off the main actor; waits a bounded time for them. + /// Answers arrive off the main actor. The next answer resumes the + /// waiter; a watchdog fails it after five seconds instead of hanging. func waitForResponses(_ count: Int) async -> Bool { - let deadline = ContinuousClock.now + .seconds(5) - while ContinuousClock.now < deadline { - if responses.count >= count { return true } - try? await Task.sleep(for: .milliseconds(5)) + await withCheckedContinuation { continuation in + let id = UUID() + let ready: Bool = lock.withLock { + guard _responses.count < count else { return true } + waiters[id] = (count, continuation) + return false + } + if ready { return continuation.resume(returning: true) } + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [self] in + lock.withLock { waiters.removeValue(forKey: id) }?.continuation.resume(returning: false) + } } - return false } func startLanguageServer( From ce175e0ebb1a21f1876cf28b42fb8e47725a4b76 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:28:27 -0700 Subject: [PATCH 09/16] docs(plugins): list the Rust SDK for plugin backends in the contract sdk/rust in lithe-plugin-docs implements plugin-function protocols 1 and 2 with the credentials host service for packaged plugin backends, and its sample backend runs an end-to-end scenario against Core. Co-Authored-By: Claude Opus 5.5 --- shared/contracts/plugins/v1/README.md | 28 +++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 6b29c9da6..92bb546f4 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1190,7 +1190,7 @@ owns it and invokes its functions from that plugin's web pages (the `functions.*` bridge methods below). A provider served by a shared language server is started by the host for every plugin of its language, so it serves no plugin's functions. Windows has no plugin backend host. The -[official Node SDK](#official-backend-sdk) implements the backend side. +[official SDKs](#official-backend-sdk) implement the backend side. ### Capability ownership @@ -1410,13 +1410,25 @@ to shade into their backend. It enforces the same rules with the same error mapping, with one difference: on Java 8 it stops only a cancelled tool's direct child, and the host's process scope ends the rest. -Each SDK has a sample plugin (`sdk/*/examples/sample-build`), and both samples -answer one end-to-end scenario driven through a real Core session. That -repository runs it against the Lithe commit its contract copy pins, and weekly -against the latest `preview`, the branch Lithe changes merge into. - -Neither SDK adds a wire field: a backend written without one is equally -valid. +[`sdk/rust`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/rust/README.md) is the SDK for +[plugin backends](#plugin-backend), which ship as packaged executables. It +implements plugin-function protocol versions 1 and 2, including the +`credentials` host service, but neither build nor VCS providers. It needs +Rust 1.87 or newer, depends only on `serde_json`, and is released +(`sdk-rust-v`) as a `.crate` archive with its SHA-256, or used as a +Git dependency pinned by the tag. It enforces the same rules: on Unix it +points stdout at stderr so only protocol frames reach the host, and a panic +ends only its call, reported like any unexpected failure. + +The Node.js and Java SDKs each have a sample build provider +(`sdk/*/examples/sample-build`) that answers one end-to-end scenario driven +through a real Core session; the Rust SDK's sample backend +(`sdk/rust/examples/sample-backend`) answers a plugin backend scenario in which +the test plays the host and serves the credential requests. That repository +runs them against the Lithe commit its contract copy pins, and weekly against +the latest `preview`, the branch Lithe changes merge into. + +No SDK adds a wire field: a backend written without one is equally valid. ## Outcomes and error codes From 1017b6dd0351f4feb5b779d853c1b00231c25861 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:39:24 -0700 Subject: [PATCH 10/16] fix(database): read the proxy URL the macOS client sends The client encodes DatabaseConnection.proxyURL as `proxyURL`, but the sidecar's serde camelCase name was `proxyUrl`, so a connection's proxy command was silently dropped and connections went direct. The field now reads `proxyURL` and still accepts `proxyUrl`. Co-Authored-By: Claude Opus 5.5 --- rust/lithe-db-sidecar/src/main.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs index 91df8a0a3..9610c4981 100644 --- a/rust/lithe-db-sidecar/src/main.rs +++ b/rust/lithe-db-sidecar/src/main.rs @@ -80,7 +80,9 @@ struct Connection { ssh_key_path: String, #[serde(default)] ssh_local_port: u16, - #[serde(default)] + /// Lithe's clients send `proxyURL`; `proxyUrl` is the serde default + /// this field was once read as, kept so neither spelling is dropped. + #[serde(default, rename = "proxyURL", alias = "proxyUrl")] proxy_url: String, #[serde(default)] read_only: bool, @@ -4196,6 +4198,18 @@ impl Response { mod tests { use super::*; + #[test] + fn connections_read_the_proxy_url_lithe_sends() { + for key in ["proxyURL", "proxyUrl"] { + let connection: Connection = serde_json::from_value(json!({ + "kind": "mysql", + key: "ssh -W %h:%p bastion", + })) + .unwrap(); + assert_eq!(connection.proxy_url, "ssh -W %h:%p bastion", "{key}"); + } + } + #[tokio::test] async fn sqlite_metadata_preserves_primary_key_ordinals() { sqlx::any::install_default_drivers(); From d1d29d59dbf84d871779c18b6439b6346217194e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:45:31 -0700 Subject: [PATCH 11/16] fix(database): end the SSH tunnel client with the request on every path SQL Server and MongoDB requests opened a tunnel and never stopped its ssh client, so each such request left an orphaned ssh process holding the local port after the sidecar exited. MySQL, PostgreSQL, SQLite, Redis, and Nacos stopped it only on their normal path. SshTunnel now kills and reaps the client when dropped, which covers early returns and every adapter. Co-Authored-By: Claude Opus 5.5 --- rust/lithe-db-sidecar/src/main.rs | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs index 9610c4981..5ef6079d5 100644 --- a/rust/lithe-db-sidecar/src/main.rs +++ b/rust/lithe-db-sidecar/src/main.rs @@ -505,10 +505,7 @@ async fn sql_database_method(method: &str, params: Params) -> DbResult { if let Some(direct) = direct { direct.close().await; } - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } + drop(tunnel); result } @@ -522,10 +519,7 @@ async fn redis_method(method: &str, params: Params) -> DbResult { params.connection.clone() }; let result = redis_method_with_connection(method, ¶ms, &connection).await; - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } + drop(tunnel); result } @@ -1026,10 +1020,7 @@ async fn nacos_method(method: &str, params: Params) -> DbResult { Ok(mut client) => nacos_method_with_client(method, ¶ms, &connection, &mut client).await, Err(error) => Err(error), }; - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } + drop(tunnel); result } @@ -1691,6 +1682,17 @@ struct SshTunnel { child: Child, } +/// Ends the `ssh` client once the method that opened the tunnel is done, +/// whether it finished, returned early with an error, or was cancelled by +/// dropping its future. Without this the client outlives the method and +/// keeps holding the local port. +impl Drop for SshTunnel { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + async fn open_ssh_tunnel(connection: &Connection) -> Result, (String, String)> { if connection.ssh_host.is_empty() { return Ok(None); @@ -4198,6 +4200,17 @@ impl Response { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn dropping_a_tunnel_ends_its_ssh_client() { + let child = Command::new("sleep").arg("30").spawn().unwrap(); + let pid = child.id().to_string(); + drop(SshTunnel { child }); + // The client was killed and reaped, so the PID no longer exists. + let alive = Command::new("kill").args(["-0", &pid]).status().unwrap(); + assert!(!alive.success(), "ssh client {pid} outlived its tunnel"); + } + #[test] fn connections_read_the_proxy_url_lithe_sends() { for key in ["proxyURL", "proxyUrl"] { From de360835ff490150a1fe10e84530de45dc1d0129 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 08:03:29 -0700 Subject: [PATCH 12/16] =?UTF-8?q?feat(plugins):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E6=A1=A5=E6=8E=A5=E7=AC=AC=202=20=E7=89=88?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=96=87=E4=BB=B6=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件后端是无界面的子进程,数据库插件导入导出、备份恢复和 DBX 导入都需要 用户选文件。桥接版本升为 2(lithe.version),新增 dialog.openFile 与 dialog.saveFile:对话框由宿主弹出并写明发起的插件,同一页面同时只开一个, 页面只拿到用户选中的绝对路径(取消为 null),读写交给插件后端;页面在对话框 打开期间关闭则拿不到结果。旧宿主返回 plugin.web.unknownMethod,页面据版本号 决定是否提供导入导出。 同步更新插件契约 README、网页内容 Agent Note 与跨平台功能矩阵。 Co-Authored-By: Claude Opus 5.5 --- ...0-18-plugin-web-content-in-host-windows.md | 3 +- .../Features/PluginWebBridge.swift | 54 +++++++++++++++- .../Application/Features/PluginWebPages.swift | 5 +- .../Models/AppModel/AppModel+PluginWeb.swift | 4 ++ .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../MacOS/Plugins/Web/MacPluginWebPage.swift | 4 +- .../Plugins/Web/MacPluginWebStateStore.swift | 27 ++++++++ .../LitheTests/PluginWebBridgeTests.swift | 61 ++++++++++++++++++- .../LitheTests/PluginWebCaptureTests.swift | 1 + shared/contracts/plugins/v1/README.md | 15 ++++- .../features/plugin-web-tool-windows.json | 4 +- 11 files changed, 169 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md b/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md index 00cb60e35..d31ed7400 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md +++ b/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md @@ -33,11 +33,12 @@ ### 桥接对象 -宿主在页面脚本执行前注入 `window.lithe`(第 1 版):`context`、`request(method, params)` 和 `on(event, listener)`。方法和事件的全集写在 `shared/contracts/plugins/v1/README.md` 的 “Web content (`ui` 2)” 一节,这里只记录边界: +宿主在页面脚本执行前注入 `window.lithe`(当前第 2 版,`lithe.version` 给出版本号):`context`、`request(method, params)` 和 `on(event, listener)`。方法和事件的全集写在 `shared/contracts/plugins/v1/README.md` 的 “Web content (`ui` 2)” 一节,这里只记录边界: - 页面只能操作本插件声明的构建系统和当前工作区,越界返回 `plugin.web.forbidden`。 - 凡是会启动进程的请求(刷新模型、跑任务、跑测试),都要经过与宿主自身按钮相同的信任和同意流程,输出进入宿主的 Build Output。页面不能提交或修改进程 argv,也不能自己拼命令;模型保留 Core 返回的声明运行项 `run` 和任务 `category` 供展示,执行入口仍只有宿主校验过的任务与测试。 - 选项写入沿用 `build.resolveOptions` 和宿主的选项存储,因此构建选项转交给 JDT 的方式与宿主自己的选项列表一致。 +- 第 2 版只多了文件对话框 `dialog.openFile` / `dialog.saveFile`,供数据库这类带后端的插件导入导出文件。对话框由宿主弹出,说明里写明是哪个插件在请求;同一页面同时只能开一个;页面只拿到用户选中的绝对路径,真正的读写交给插件后端,页面本身仍然碰不到文件。页面在对话框打开期间被关闭,就拿不到选择结果。旧宿主没有这两个方法(返回 `plugin.web.unknownMethod`),所以页面先看 `lithe.version` 再决定是否提供导入导出。之所以加桥接方法而不是让后端自己弹窗,是因为后端是无界面的子进程,弹窗必须由宿主窗口负责,也只有宿主能把插件身份写进对话框,防止页面冒充 Lithe。 ### 正确做法 diff --git a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift index 73dbded40..a9959d2e1 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift @@ -53,6 +53,24 @@ struct PluginWebProcessConsent: Equatable, Sendable { let userArguments: [String] } +/// A file the user picks for a page (bridge version 2). The host names the +/// plugin in the dialog, so a page cannot pass its request off as Lithe's. +struct PluginWebFileRequest: Equatable, Sendable { + enum Kind: Equatable, Sendable { + case open + case save + } + + let kind: Kind + let pluginID: String + let pluginName: String + let title: String? + /// The proposed file name of a save dialog. + let suggestedName: String? + /// File name extensions without the dot; empty allows every file. + let allowedExtensions: [String] +} + /// The build feature as a page may use it: the same entry points as the /// host's own build controls. @MainActor @@ -104,6 +122,8 @@ protocol PluginWebBridgeHost: AnyObject { /// Opens a workspace file at a 1-based line and column; false when it does not exist. func pluginWebOpenFile(_ url: URL, line: Int?, column: Int?) -> Bool func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool + /// Shows a file dialog; nil when the user cancels it. + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? /// The application's plugin backends; nil on hosts that run none. var pluginWebBackends: (any PluginBackendRequesting)? { get } } @@ -164,6 +184,7 @@ final class PluginWebBridge { private var waiters: [Waiter] = [] private var isCheckScheduled = false private var isClosed = false + private var isChoosingFile = false private let requestTimeout: Duration private struct Run { @@ -255,7 +276,7 @@ final class PluginWebBridge { let known: Set = [ "build.instances", "build.refresh", "build.runTask", "build.runTests", "build.cancel", "build.dependencies", "build.setOptions", "workspace.openFile", "ui.getState", "ui.setState", - "functions.list", "functions.invoke", "functions.cancel" + "functions.list", "functions.invoke", "functions.cancel", "dialog.openFile", "dialog.saveFile" ] guard known.contains(method) else { throw PluginWebBridgeFailure.unknownMethod(method) } let workspaceURL = try currentWorkspace() @@ -305,6 +326,10 @@ final class PluginWebBridge { // awaiting consent is marked and never starts. functionCalls[call]?.cancel() return .object([:]) + case "dialog.openFile": + return try await chooseFile(.open, Parameters(params, allowed: ["title", "extensions"])) + case "dialog.saveFile": + return try await chooseFile(.save, Parameters(params, allowed: ["title", "suggestedName", "extensions"])) case "ui.getState": _ = try Parameters(params, allowed: []) guard let data = stateStore.state(pluginID: window.pluginID, windowID: window.windowID, workspaceURL: workspaceURL), @@ -736,6 +761,33 @@ final class PluginWebBridge { return .object([:]) } + /// `{ path }`, the absolute path the user chose, or `{ path: null }` + /// when the dialog was cancelled. One dialog per page at a time. + private func chooseFile(_ kind: PluginWebFileRequest.Kind, _ params: Parameters) async throws -> ToolingJSONValue { + let title = try params.optionalString("title", limit: 256) + let suggestedName = try params.optionalString("suggestedName", limit: 255) + if let suggestedName, suggestedName.contains("/") || suggestedName.hasPrefix(".") { + throw PluginWebBridgeFailure.invalidRequest("suggestedName must be a file name.") + } + let extensions = try params.strings("extensions", count: 16, limit: 16) ?? [] + guard extensions.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) && $0.isASCII } }) else { + throw PluginWebBridgeFailure.invalidRequest("extensions must be file name extensions without a dot.") + } + guard !isChoosingFile else { + throw PluginWebBridgeFailure.invalidRequest("A file dialog of this page is already open.") + } + guard let host else { throw PluginWebBridgeFailure.forbidden("The page was closed.") } + isChoosingFile = true + defer { isChoosingFile = false } + let url = await host.pluginWebChooseFile(PluginWebFileRequest( + kind: kind, pluginID: window.pluginID, pluginName: window.pluginName, title: title, + suggestedName: suggestedName, allowedExtensions: extensions + )) + // A page closed while the dialog was open learns nothing of the choice. + guard !isClosed else { throw PluginWebBridgeFailure.forbidden("The page was closed.") } + return .object(["path": url.map { .string($0.standardizedFileURL.path) } ?? .null]) + } + private func match(_ selection: Parameters.Selection, in items: [Item]?, what: String) throws -> Item { guard let items else { throw PluginWebBridgeFailure.invalidSelection("Load the model before selecting a \(what).") diff --git a/macos/Sources/Lithe/Application/Features/PluginWebPages.swift b/macos/Sources/Lithe/Application/Features/PluginWebPages.swift index 663cfc6bb..99d7239cb 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWebPages.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWebPages.swift @@ -19,6 +19,7 @@ final class PluginWebPages { /// The application's plugin backends, shared with every project window. let backends: (any PluginBackendRequesting)? private let prompt: @MainActor (PluginWebProcessConsent) async -> Bool + let chooseFile: @MainActor (PluginWebFileRequest) async -> URL? private var pages: [String: any PluginWebPageHandle] = [:] private var nativeOwners: [String: String] = [:] private var unavailablePluginIDs: Set = [] @@ -30,12 +31,14 @@ final class PluginWebPages { windows: [PluginWebWindow] = [], stateStore: (any PluginWebStateStoring)? = nil, backends: (any PluginBackendRequesting)? = nil, - prompt: @escaping @MainActor (PluginWebProcessConsent) async -> Bool = { _ in false } + prompt: @escaping @MainActor (PluginWebProcessConsent) async -> Bool = { _ in false }, + chooseFile: @escaping @MainActor (PluginWebFileRequest) async -> URL? = { _ in nil } ) { self.windows = Dictionary(windows.map { ($0.windowID, $0) }, uniquingKeysWith: { first, _ in first }) self.stateStore = stateStore ?? MemoryPluginWebStateStore() self.backends = backends self.prompt = prompt + self.chooseFile = chooseFile } /// The open page of a window, created by `make` on first use. diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift index 28d096217..3a89f1e7b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift @@ -30,6 +30,10 @@ extension AppModel: PluginWebBridgeHost { await services.pluginWebPages.confirmProcessStart(consent) } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { + await services.pluginWebPages.chooseFile(request) + } + var pluginWebBackends: (any PluginBackendRequesting)? { services.pluginWebPages.backends } /// True while the window's plugin has a new version, a rollback, or an diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index e9cb5782c..7ab488583 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -702,7 +702,8 @@ final class MacServiceContainer { fileStorage: fileStorage ), backends: pluginBackends, - prompt: MacPluginWebConsentPrompt.confirm + prompt: MacPluginWebConsentPrompt.confirm, + chooseFile: MacPluginWebFileDialog.choose ) pluginWebPageLifecycle.register(pluginWebPages) let pluginMarketplaceEvaluator = RustPluginMarketplaceOperations(core: rustCore) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebPage.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebPage.swift index 2faa134ce..1d5f4c713 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebPage.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebPage.swift @@ -196,7 +196,7 @@ final class MacPluginWebPage: NSObject, PluginWebPageHandle, WKNavigationDelegat return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true, in: .page) } - /// Bridge version 1. The handler reference is taken before any page + /// Bridge version 2, which adds file dialogs to version 1. The handler reference is taken before any page /// script runs, and `window.lithe` cannot be replaced or extended. private static let bridgeScript = #""" (() => { @@ -221,7 +221,7 @@ final class MacPluginWebPage: NSObject, PluginWebPageHandle, WKNavigationDelegat } }; const lithe = freeze({ - version: 1, + version: 2, context: freeze({ pluginID: initial.pluginID, windowID: initial.windowID, platform: initial.platform, locale: initial.locale, get theme() { return theme.theme; } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift index 7a30162bb..09f286092 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import UniformTypeIdentifiers /// `ui.setState` values in Application Support, beside the plugin's other /// state: `PluginState//web-state//.json`. @@ -68,3 +69,29 @@ enum MacPluginWebConsentPrompt { return alert.runModal() == .alertFirstButtonReturn } } + +/// The file dialogs a plugin page opens. The message names the plugin, and +/// the page learns only the path the user chose. +enum MacPluginWebFileDialog { + @MainActor + static func choose(_ request: PluginWebFileRequest) async -> URL? { + let panel: NSSavePanel + switch request.kind { + case .open: + let open = NSOpenPanel() + open.canChooseFiles = true + open.canChooseDirectories = false + open.allowsMultipleSelection = false + panel = open + case .save: + panel = NSSavePanel() + panel.canCreateDirectories = true + if let name = request.suggestedName { panel.nameFieldStringValue = name } + } + if let title = request.title { panel.title = title } + panel.message = String(format: String(localized: "Requested by the plugin “%@”."), request.pluginName) + let types = request.allowedExtensions.compactMap { UTType(filenameExtension: $0) } + if !types.isEmpty { panel.allowedContentTypes = types } + return panel.runModal() == .OK ? panel.url : nil + } +} diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index e9a955c18..babed04d5 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -549,6 +549,54 @@ struct PluginWebBridgeTests { } } + @Test func fileDialogsNameThePluginAndReturnOnlyTheChosenPath() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + let chosen = harness.root.appendingPathComponent("exports/rows.csv") + harness.host.chooseFile = { _ in chosen } + + #expect(try await harness.ok("dialog.saveFile", [ + "title": .string("Export rows"), "suggestedName": .string("rows.csv"), "extensions": .array([.string("csv")]) + ]) == .object(["path": .string(chosen.path)])) + #expect(harness.host.fileRequests == [PluginWebFileRequest( + kind: .save, pluginID: "dev.example.web", pluginName: "Fixture", title: "Export rows", + suggestedName: "rows.csv", allowedExtensions: ["csv"] + )]) + + harness.host.chooseFile = { _ in nil } + #expect(try await harness.ok("dialog.openFile") == .object(["path": .null])) + #expect(harness.host.fileRequests.last?.kind == .open) + + #expect(try await harness.error("dialog.saveFile", ["suggestedName": .string("../rows.csv")]) + == "plugin.web.invalidRequest") + #expect(try await harness.error("dialog.openFile", ["extensions": .array([.string(".csv")])]) + == "plugin.web.invalidRequest") + #expect(try await harness.error("dialog.openFile", ["suggestedName": .string("rows.csv")]) + == "plugin.web.invalidRequest") + #expect(harness.host.fileRequests.count == 2) + } + + @Test func oneFileDialogAtATimeAndAClosedPageLearnsNoChoice() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + let shown = TestGate() + let answer = TestGate() + harness.host.chooseFile = { _ in + shown.open() + _ = await answer.waitUntilOpen(timeout: .seconds(10)) + return URL(fileURLWithPath: "/tmp/chosen.sql") + } + + let pending = Task { try await harness.error("dialog.openFile") } + defer { pending.cancel() } + #expect(await shown.waitUntilOpen(), "The dialog was never shown") + #expect(try await harness.error("dialog.saveFile") == "plugin.web.invalidRequest") + harness.bridge.close() + answer.open() + #expect(try await pending.value == "plugin.web.forbidden") + #expect(harness.host.fileRequests.count == 1) + } + @Test func stateIsKeptByTheHostWithinItsLimit() async throws { let harness = try BridgeHarness() defer { harness.cleanUp() } @@ -958,6 +1006,9 @@ private final class RetainedProjectWebHost: PluginWebBridgeHost { func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool { await model.pluginWebConfirmProcessStart(consent) } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { + await model.pluginWebChooseFile(request) + } } /// Suspends the real manager's teardown so revocation is checked before shutdown ends. @@ -997,6 +1048,8 @@ private final class FakeHost: PluginWebBridgeHost { var outputShown = 0 var onAccess: (() -> Void)? var confirmProcessStart: (@MainActor () async -> Bool)? + var fileRequests: [PluginWebFileRequest] = [] + var chooseFile: (@MainActor (PluginWebFileRequest) async -> URL?)? var backends: FakeBackends? var pluginWebBackends: (any PluginBackendRequesting)? { backends } @@ -1016,6 +1069,10 @@ private final class FakeHost: PluginWebBridgeHost { if let confirmProcessStart { return await confirmProcessStart() } return allowsProcesses } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { + fileRequests.append(request) + return await chooseFile?(request) + } } /// Echoes a function's params after one progress report. @@ -1256,7 +1313,7 @@ struct PluginWebPageTests { let fetched = 'blocked'; try { await fetch('https://example.invalid/'); fetched = 'allowed'; } catch (error) {} let replaced = false; - try { window.lithe = {}; replaced = window.lithe.version !== 1; } catch (error) {} + try { window.lithe = {}; replaced = window.lithe.version !== 2; } catch (error) {} lithe.on('ui.themeChanged', (event) => lithe.request('ui.setState', { state: { themeChanged: event.theme } })); await lithe.request('ui.setState', { state: { version: lithe.version, @@ -1288,7 +1345,7 @@ struct PluginWebPageTests { let webView = try #require(page.webView) #expect(!webView.configuration.websiteDataStore.isPersistent) let report = try #require(await store.next(timeout: .seconds(10)), "The page never reported its state") - #expect(report["version"] == .integer(1)) + #expect(report["version"] == .integer(2)) #expect(report["pluginID"] == .string("dev.example.web")) #expect(report["theme"] == .string("light")) #expect(report["background"].map { $0 != .string("") } == true) diff --git a/macos/Tests/LitheTests/PluginWebCaptureTests.swift b/macos/Tests/LitheTests/PluginWebCaptureTests.swift index 7f04f1a20..8f0aebd53 100644 --- a/macos/Tests/LitheTests/PluginWebCaptureTests.swift +++ b/macos/Tests/LitheTests/PluginWebCaptureTests.swift @@ -39,6 +39,7 @@ private final class IdleHost: PluginWebBridgeHost { func pluginWebShowBuildOutput() {} func pluginWebOpenFile(_ url: URL, line: Int?, column: Int?) -> Bool { false } func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool { false } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { nil } } /// A plugin's own page, loaded through the package scheme into a window of its own. diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 92bb546f4..e93b50ed6 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1582,8 +1582,10 @@ Hosts load a page under these rules: - The root element carries `data-theme` (`light` or `dark`) and the host's `--lithe-*` CSS colour and font variables, updated with the theme. -Before the page's scripts run, the host defines `window.lithe` (bridge -version 1): `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, +Before the page's scripts run, the host defines `window.lithe`: +`lithe.version`, the bridge version (2 on current macOS hosts; version 2 +adds the file dialogs to version 1, so a page checks it before offering +them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's @@ -1605,6 +1607,8 @@ their output appears in the host's Build output window. | `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | | `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | +| `dialog.openFile` (bridge 2) | `{ title?, extensions? }` | `{ path }`: the absolute path of the file the user chose, or `null` when the dialog was cancelled | +| `dialog.saveFile` (bridge 2) | `{ title?, suggestedName?, extensions? }` | `{ path }` as for `dialog.openFile`; the host has asked before replacing an existing file, and nothing is written | | Event | Payload | | --- | --- | @@ -1613,6 +1617,13 @@ their output appears in the host's Build output window. | `functions.progress` | `{ call, instance?, function, message?, percentage? }`, only for a call made with `call`; `instance` absent for a backend call | | `ui.themeChanged` | `{ theme }` | +The file dialogs are the host's: they name the plugin that asks, a page +has at most one open at a time, and the page learns only the path the user +chose. `extensions` are up to 16 file name extensions without the dot; +`suggestedName` is a file name, not a path. A page passes the path to its +backend, which reads or writes the file; the page itself still reaches no +file. + Bridge errors are `plugin.web.unknownMethod`, `plugin.web.invalidRequest`, and `plugin.web.forbidden`. Build requests also report the `build.*` codes. Function requests report Core's codes unchanged (`plugin.function.*`, diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index 0fbaca60d..ac04871f7 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -2,7 +2,7 @@ "id": "plugin-web-tool-windows", "area": "插件", "group": "插件界面", - "capability": "插件可以在宿主管理的工具窗口里显示自己包内的 HTML 页面(ui 2、ui.web 权限):只读取已安装包内的文件,CSP 禁止联网、内联脚本和 eval,网页存储不持久;页面通过 window.lithe 桥接只能操作本插件的构建系统和当前工作区,启动构建工具前由宿主确认,输出进入 Build Output,页面只可取消本次实际启动的运行;任务与测试在确认后最终启动前拒绝已有运行,保留宿主或其他页面的运行及 Build Output,包含等待确认期间新启动的运行;选择必须唯一命中模型,目标以经过校验的完整 tags 集合区分同名同目录的 binary/example,最终执行参数从模型重建,拒绝歧义和伪造标签;模型保留 Core 的声明运行项和任务类别;异步激活、检测和依赖请求复核工作区并受本地期限约束;直接关闭原生项目窗口时,在会话首次异步退出前同步销毁隐藏页面并撤销进程许可,即使设置窗口继续持有项目模型,待处理和后续桥接请求也被拒绝,不能重新激活执行模块;插件禁用、更新或卸载时同进程所有项目窗口(含隐藏页)都被销毁,新项目窗口继承阻断状态,包变更后的旧管理器重新启用不能在重启前重建页面;页面申请摄像头或麦克风时默认没有权限,由宿主逐次弹窗确认(写明这是插件并附插件 ID,只认本插件主框架,页面不在窗口里直接拒绝,视图离开窗口即结束采集)", + "capability": "插件可以在宿主管理的工具窗口里显示自己包内的 HTML 页面(ui 2、ui.web 权限):只读取已安装包内的文件,CSP 禁止联网、内联脚本和 eval,网页存储不持久;页面通过 window.lithe 桥接只能操作本插件的构建系统和当前工作区,启动构建工具前由宿主确认,输出进入 Build Output,页面只可取消本次实际启动的运行;任务与测试在确认后最终启动前拒绝已有运行,保留宿主或其他页面的运行及 Build Output,包含等待确认期间新启动的运行;选择必须唯一命中模型,目标以经过校验的完整 tags 集合区分同名同目录的 binary/example,最终执行参数从模型重建,拒绝歧义和伪造标签;模型保留 Core 的声明运行项和任务类别;异步激活、检测和依赖请求复核工作区并受本地期限约束;直接关闭原生项目窗口时,在会话首次异步退出前同步销毁隐藏页面并撤销进程许可,即使设置窗口继续持有项目模型,待处理和后续桥接请求也被拒绝,不能重新激活执行模块;插件禁用、更新或卸载时同进程所有项目窗口(含隐藏页)都被销毁,新项目窗口继承阻断状态,包变更后的旧管理器重新启用不能在重启前重建页面;页面申请摄像头或麦克风时默认没有权限,由宿主逐次弹窗确认(写明这是插件并附插件 ID,只认本插件主框架,页面不在窗口里直接拒绝,视图离开窗口即结束采集);桥接版本 2(lithe.version)增加 dialog.openFile 与 dialog.saveFile:由宿主弹出文件对话框并写明发起的插件,同一页面同时只开一个,页面只拿到用户选中的绝对路径(取消为 null),读写交给插件后端,页面关闭后不再返回选择结果", "macos": { "evidence": [ "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", @@ -43,5 +43,5 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:WebView2 宿主尚未实现,继续只声明 ui 1。" + "verification": "自动回归:文件对话框见 PluginWebBridgeTests 的 fileDialogsNameThePluginAndReturnOnlyTheChosenPath 与 oneFileDialogAtATimeAndAClosedPageLearnsNoChoice;LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:WebView2 宿主尚未实现,继续只声明 ui 1。" } From 68f243389031c61f40abfa86ae3fe3ccabd4a005 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 09:04:24 -0700 Subject: [PATCH 13/16] feat(plugins): list the database plugin and move the built-in module's data into it - OfficialPluginCatalog lists dev.lithe.plugin.database as a publisher package downloaded from the marketplace; language-only fields move into an optional Entry.language, so a non-language plugin needs none. - MacDatabasePluginMigration moves the former module's connections, folders, SQL history, and forever-kept passwords into the plugin's ${stateDir} and credentials once, before its backend first starts (MacPluginBackendSessions gains a prepareState hook). The old data stays for older Lithe versions; the plugin's own state always wins. - Workbench notifications can carry one action and then stay until used. People with saved connections and no database plugin get one prompt with an "Open Marketplace" action. Co-Authored-By: Claude Opus 5.5 --- macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Features/DatabasePluginTransition.swift | 24 +++ .../WorkbenchNotificationFeatureModel.swift | 16 +- .../AppModel/AppModel+DatabasePlugin.swift | 20 ++ .../AppModel/AppModel+Notifications.swift | 8 + .../Lithe/Models/AppModel/AppModel.swift | 1 + .../AppModel/AppModelSupportTypes.swift | 14 ++ .../Platform/MacOS/MacServiceContainer.swift | 22 ++- .../Plugins/MacDatabasePluginMigration.swift | 169 ++++++++++++++++ .../Plugins/MacPluginBackendSessions.swift | 11 ++ .../Views/Language/LSPControlCenterView.swift | 13 +- .../WorkbenchNotificationBanner.swift | 28 ++- .../Lithe/Views/Workbench/WorkbenchView.swift | 13 +- .../Catalog/BuiltInModuleCatalog.swift | 56 ++++-- .../ModuleRuntimeTests.swift | 2 +- .../MacDatabasePluginMigrationTests.swift | 187 ++++++++++++++++++ .../MacPluginBackendSessionsTests.swift | 39 +++- .../Tests/LitheTests/PluginManagerTests.swift | 8 +- .../WorkbenchNotificationTests.swift | 48 +++++ shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 22 +++ 23 files changed, 676 insertions(+), 41 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift create mode 100644 macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 9260f8d24..bce0bd8ed 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -944,6 +944,7 @@ "Data Tools" = "Data Tools"; "Database" = "Database"; "Database Overview" = "Database Overview"; +"Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it." = "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it."; "Database types" = "Database types"; "Date" = "Date"; "Debug" = "Debug"; @@ -2233,6 +2234,7 @@ "Open GitHub profile" = "Open GitHub profile"; "Open HTML in Browser" = "Open HTML in Browser"; "Open Log Directory" = "Open Log Directory"; +"Open Marketplace" = "Open Marketplace"; "Open Patch…" = "Open Patch…"; "Open Plugin Management" = "Open Plugin Management"; "Open Project" = "Open Project"; @@ -4755,6 +4757,7 @@ "native.dataSource" = "Data Source"; "native.dataTools" = "Data Tools"; "native.database" = "Database"; +"native.databaseIsNowAPluginInstallItFromTheMarketplace" = "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it."; "native.databaseOverview" = "Database Overview"; "native.databaseTypes" = "Database types"; "native.date" = "Date"; @@ -6250,6 +6253,7 @@ "native.openInNewWindow" = "Open in New Window"; "native.openInRightSplit" = "Open in Right Split"; "native.openLogDirectory" = "Open Log Directory"; +"native.openMarketplace" = "Open Marketplace"; "native.openModulePomXml" = "Open module pom.xml"; "native.openNotificationHistory" = "Open notification history"; "native.openPagesClickTypeAndInspectABrowserThroughItsAccessibilityTree" = "Open pages, click, type and inspect a browser through its accessibility tree."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index da19ae204..f579efc75 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -944,6 +944,7 @@ "Data Tools" = "数据工具"; "Database" = "数据库连接"; "Database Overview" = "数据库概览"; +"Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it." = "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。"; "Database types" = "数据库类型"; "Date" = "日期"; "Debug" = "调试"; @@ -2233,6 +2234,7 @@ "Open GitHub profile" = "打开 GitHub 个人主页"; "Open HTML in Browser" = "在系统浏览器中打开"; "Open Log Directory" = "打开日志目录"; +"Open Marketplace" = "打开插件市场"; "Open Patch…" = "打开补丁…"; "Open Plugin Management" = "打开插件管理"; "Open Project" = "打开项目"; @@ -4755,6 +4757,7 @@ "native.dataSource" = "数据源"; "native.dataTools" = "数据工具"; "native.database" = "数据库连接"; +"native.databaseIsNowAPluginInstallItFromTheMarketplace" = "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。"; "native.databaseOverview" = "数据库概览"; "native.databaseTypes" = "数据库类型"; "native.date" = "日期"; @@ -6250,6 +6253,7 @@ "native.openInNewWindow" = "在新窗口中打开"; "native.openInRightSplit" = "在右侧分栏打开"; "native.openLogDirectory" = "打开日志目录"; +"native.openMarketplace" = "打开插件市场"; "native.openModulePomXml" = "打开所属模块的 pom.xml"; "native.openNotificationHistory" = "打开通知历史"; "native.openPagesClickTypeAndInspectABrowserThroughItsAccessibilityTree" = "打开网页、点击、输入,并通过无障碍树读取浏览器页面。"; diff --git a/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift b/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift new file mode 100644 index 000000000..d203385d0 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift @@ -0,0 +1,24 @@ +import Foundation +import LitheModuleAPI + +// Note: 数据库改为插件后的老用户提示与迁移见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +/// Lithe no longer contains a database module; the official Database plugin +/// replaces it. Someone who saved connections in the old module learns that +/// once, and their data moves into the plugin when it first runs +/// (`MacDatabasePluginMigration`). Nothing downloads on its own. +enum DatabasePluginTransition { + /// Where the old module saved its connection profiles. + static let legacyProfilesKey = "database.profiles.v1" + static let suggestionKey = "lithe.databasePluginSuggestion.v1" + + /// Suggest the plugin when the old module saved connections, the plugin + /// is not installed, and the suggestion was never shown. + static func shouldSuggest(store: any KeyValueStore, installedPluginIDs: Set) -> Bool { + guard store.object(forKey: suggestionKey) == nil, + !installedPluginIDs.contains(OfficialPluginCatalog.databasePluginID), + let data = store.data(forKey: legacyProfilesKey), + let profiles = try? JSONSerialization.jsonObject(with: data) as? [Any] else { return false } + return !profiles.isEmpty + } +} diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift index b159dc54b..09a79999f 100644 --- a/macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift @@ -38,6 +38,10 @@ final class WorkbenchNotificationFeatureModel: ObservableObject { func show(_ message: String) { show(message, arguments: []) } func show(_ message: String, arguments: [String]) { + show(message, arguments: arguments, action: nil) + } + + func show(_ message: String, arguments: [String], action: WorkbenchNotification.Action?) { // Only an identical key and its arguments merge into the row they own: the entry // stays in place, only its occurrence count grows. Without this, a // message repeated by a background workflow floods the center. @@ -51,7 +55,7 @@ final class WorkbenchNotificationFeatureModel: ObservableObject { return } - let notification = WorkbenchNotification(message: message, arguments: arguments) + let notification = WorkbenchNotification(message: message, arguments: arguments, action: action) notifications.insert(notification, at: 0) if notifications.count > WorkbenchNotificationTiming.maximumHistoryCount { notifications.removeLast( @@ -130,6 +134,14 @@ final class WorkbenchNotificationFeatureModel: ObservableObject { scheduleDismissal(for: notification, after: remaining) } + /// Runs the notification's action and closes its balloon. + func perform(_ id: UUID) { + guard let action = notifications.first(where: { $0.id == id })?.action else { return } + if let index = notifications.firstIndex(where: { $0.id == id }) { notifications[index].isRead = true } + dismiss(id) + action.perform() + } + func dismiss(_ id: UUID) { guard activeNotifications.contains(where: { $0.id == id }) else { return } activeNotifications.removeAll { $0.id == id } @@ -162,6 +174,8 @@ final class WorkbenchNotificationFeatureModel: ObservableObject { after duration: Duration ) { dismissalTasks[notification.id]?.cancel() + // An action is easy to miss in ten seconds; it waits for the user. + guard notification.action == nil else { return } guard isApplicationActive, !hoveredIDs.contains(notification.id) else { remainingDurations[notification.id] = duration return diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift new file mode 100644 index 000000000..17a9def3f --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift @@ -0,0 +1,20 @@ +import Foundation + +extension AppModel { + /// Tells someone who used the former built-in database module, once, + /// that the Database plugin replaces it, with a way to the Marketplace. + func suggestDatabasePluginIfNeeded() { + let store = services.store + guard DatabasePluginTransition.shouldSuggest( + store: store, + installedPluginIDs: Set(services.pluginManager.snapshots.map(\.id)) + ) else { return } + store.set(true, forKey: DatabasePluginTransition.suggestionKey) + showNotification( + "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + action: WorkbenchNotification.Action(title: "Open Marketplace") { [weak self] in + self?.showPluginMarketplace(searching: "Database") + } + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift index 36973768b..d35c62456 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift @@ -17,6 +17,14 @@ extension AppModel { notificationFeature.show(message, arguments: arguments) } + func showNotification(_ message: String, arguments: [String] = [], action: WorkbenchNotification.Action) { + notificationFeature.show(message, arguments: arguments, action: action) + } + + func performNotificationAction(_ id: UUID) { + notificationFeature.perform(id) + } + func setNotificationHovered(_ id: UUID, isHovered: Bool) { notificationFeature.setHovered(id, isHovered: isHovered) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index c2a0280ba..0c4dc915e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -589,6 +589,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { guard let self, let root = self.workspaceURL else { return } self.searchFeatureIfActive?.invalidateIndex(at: root, visibilityRules: self.settings.searchEverywhereVisibilityRules.searchRules) } + suggestDatabasePluginIfNeeded() detectedAIConfigurations = loadAIConfigurations() let activeProviderHasAPIKey = settings.activeCommitMessageProvider .flatMap { services.credentialResolver.readAPIKey(for: $0) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 46f0007b8..816d3ab19 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -131,10 +131,22 @@ enum LitheFeatureAvailability { } struct WorkbenchNotification: Identifiable, Equatable { + /// A button that takes the user to where they act on the notification. + struct Action: Equatable { + /// Localization key of the button title. + let title: String + let perform: @MainActor () -> Void + + /// Actions are told apart by the button they show. + static func == (lhs: Action, rhs: Action) -> Bool { lhs.title == rhs.title } + } + let id: UUID let message: String /// Raw substitutions stay locale-independent until balloon/history rendering. let arguments: [String] + /// A notification with an action stays until the user uses or dismisses it. + let action: Action? let createdAt: Date /// When the message was shown last. Repeating a message keeps the single /// entry but moves this forward, so the list reports fresh activity. @@ -150,12 +162,14 @@ struct WorkbenchNotification: Identifiable, Equatable { id: UUID = UUID(), message: String, arguments: [String] = [], + action: Action? = nil, createdAt: Date = Date(), isRead: Bool = false ) { self.id = id self.message = message self.arguments = arguments + self.action = action self.createdAt = createdAt self.updatedAt = createdAt self.isRead = isRead diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 7ab488583..6afbff51a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -299,14 +299,32 @@ final class MacServiceContainer { // first project window creates them, and every window registers the // same startup snapshot. Nothing starts until a page calls one. let pluginBackends = MacPluginBackendSessions.shared { - MacPluginBackendSessions( + // The former built-in database module's connections and + // passwords move into the database plugin before its backend + // first starts; the old data is read, never changed. + let databaseKeychain = MacKeychainSecureStore(service: fileStorage.identity.keychainService("database")) + let databaseMigration = MacDatabasePluginMigration( + preferences: store, + legacyPassword: { [secureStore] key in databaseKeychain.read(key: key) ?? secureStore.read(key: key) }, + credentials: pluginCredentials, + log: { [applicationLogging] message in + applicationLogging?.record(level: "warn", scope: "plugins.migration", message: message, fields: [:]) + } + ) + return MacPluginBackendSessions( launchResolver: pluginLaunchResolver, tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, processRegistry: processRegistry, - credentials: pluginCredentials + credentials: pluginCredentials, + prepareState: { [applicationLogging] pluginID, stateDirectory in + if case let .migrated(profiles, passwords) = databaseMigration.prepare(pluginID: pluginID, stateDirectory: stateDirectory) { + applicationLogging?.record(level: "info", scope: "plugins.migration", message: "database connections moved into the plugin", + fields: ["profiles": String(profiles), "passwords": String(passwords)]) + } + } ) } pluginBackends.register(pluginStartup.registeredPlugins) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift new file mode 100644 index 000000000..636556ff8 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift @@ -0,0 +1,169 @@ +import Foundation + +// Note: 数据库改为插件后的老用户迁移见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md + +/// Moves what Lithe's former built-in database module saved into the +/// database plugin, once, before the plugin's backend first starts: the +/// connections, folders, and SQL history from the preferences into the +/// plugin's `${stateDir}`, and the passwords kept forever from the module's +/// Keychain service into the plugin's credentials. +/// +/// The old data stays where it is, so an older Lithe still finds it, and a +/// marker records that the move happened. This is a transition shim that +/// knows both the old module's storage and the plugin's; remove it once no +/// supported Lithe version shipped the built-in module. +final class MacDatabasePluginMigration: @unchecked Sendable { + static let pluginID = "dev.lithe.plugin.database" + static let markerKey = "lithe.databasePluginMigration.v1" + static let profilesKey = DatabasePluginTransition.legacyProfilesKey + static let foldersKey = "database.connection-folders.v1" + static let historyKey = "database.sql-history.v1" + + enum Outcome: Equatable { + case otherPlugin + case alreadyMigrated + case nothingToMigrate + /// The plugin already keeps connections of its own; they win. + case keptPluginState + case migrated(profiles: Int, passwords: Int) + /// Nothing was marked, so the next start tries again. + case failed(String) + } + + /// Seconds between 1970 and 2001, the reference date the old module's + /// `JSONEncoder` wrote history dates from. + private static let referenceDateOffset: Double = 978_307_200 + private static let maximumHistoryEntries = 100 + + // UserDefaults, behind `preferences`, is safe to use from any thread. + private let preferences: any KeyValueStore + private let legacyPassword: @Sendable (_ key: String) -> String? + private let credentials: any PluginCredentialStoring + private let fileManager: FileManager + private let log: @Sendable (String) -> Void + + /// - Parameter legacyPassword: reads a password the old module stored, + /// by its old key, without writing anything. + init( + preferences: any KeyValueStore, + legacyPassword: @escaping @Sendable (_ key: String) -> String?, + credentials: any PluginCredentialStoring, + fileManager: FileManager = .default, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.preferences = preferences + self.legacyPassword = legacyPassword + self.credentials = credentials + self.fileManager = fileManager + self.log = log + } + + @discardableResult + func prepare(pluginID: String, stateDirectory: URL) -> Outcome { + guard pluginID == Self.pluginID else { return .otherPlugin } + guard preferences.object(forKey: Self.markerKey) == nil else { return .alreadyMigrated } + let connectionsURL = stateDirectory.appendingPathComponent("connections.json") + if fileManager.fileExists(atPath: connectionsURL.path) { + markDone() + return .keptPluginState + } + var profiles = objects(forKey: Self.profilesKey) + var folders = objects(forKey: Self.foldersKey) + guard !profiles.isEmpty || !folders.isEmpty else { + markDone() + return .nothingToMigrate + } + assignLegacyGroups(profiles: &profiles, folders: &folders) + profiles.sort { + (($0["name"] as? String) ?? "").localizedCaseInsensitiveCompare(($1["name"] as? String) ?? "") == .orderedAscending + } + + // Passwords move first, so a migrated connection never points at a + // password that is missing. A password that cannot move is logged, + // and its connection asks for it again. + var passwords = 0 + for profile in profiles where profile["passwordPersistence"] as? String != "untilRestart" { + guard let id = profile["id"] as? String, + let password = legacyPassword("database.connection.\(id).password"), + !password.isEmpty else { continue } + do { + try credentials.setSecret(password, pluginID: pluginID, key: "connection:\(id):password") + passwords += 1 + } catch { + log("Database plugin migration could not move the password of connection \(id): \(error.localizedDescription)") + } + } + + let profileIDs = Set(profiles.compactMap { $0["id"] as? String }) + let history = objects(forKey: Self.historyKey) + .filter { ($0["profileID"] as? String).map(profileIDs.contains) ?? false } + .prefix(Self.maximumHistoryEntries) + .map(Self.pluginHistoryEntry) + do { + try fileManager.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + if !history.isEmpty { + try write(history, to: stateDirectory.appendingPathComponent("sql-history.json")) + } + // Written last: its presence is what tells a later start that + // the plugin has state of its own. + try write(["schemaVersion": 1, "profiles": profiles, "folders": folders], to: connectionsURL) + } catch { + log("Database plugin migration could not write the plugin's state: \(error.localizedDescription)") + return .failed(error.localizedDescription) + } + markDone() + return .migrated(profiles: profiles.count, passwords: passwords) + } + + private func markDone() { + preferences.set(Date().timeIntervalSince1970, forKey: Self.markerKey) + } + + private func objects(forKey key: String) -> [[String: Any]] { + guard let data = preferences.data(forKey: key), + let array = try? JSONSerialization.jsonObject(with: data) as? [Any] else { return [] } + return array.compactMap { $0 as? [String: Any] } + } + + /// Profiles saved before folders existed name their group; the old + /// module made each name a top-level folder when it loaded them. + private func assignLegacyGroups(profiles: inout [[String: Any]], folders: inout [[String: Any]]) { + var folderIDs: [String: String] = [:] + for folder in folders where Self.isAbsent(folder["parentID"]) { + if let name = folder["name"] as? String, let id = folder["id"] as? String { + folderIDs[name.lowercased()] = folderIDs[name.lowercased()] ?? id + } + } + for index in profiles.indices where Self.isAbsent(profiles[index]["folderID"]) { + let name = ((profiles[index]["group"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + let id = folderIDs[name.lowercased()] ?? { + let id = UUID().uuidString + folders.append(["id": id, "name": name]) + folderIDs[name.lowercased()] = id + return id + }() + profiles[index]["folderID"] = id + profiles[index]["group"] = "" + } + } + + /// The old module omitted absent optionals; `null` counts the same. + private static func isAbsent(_ value: Any?) -> Bool { + value == nil || value is NSNull + } + + /// The plugin keeps `executedAt` in milliseconds since 1970. + private static func pluginHistoryEntry(_ entry: [String: Any]) -> [String: Any] { + var entry = entry + if let seconds = entry["executedAt"] as? Double { + entry["executedAt"] = Int(((seconds + referenceDateOffset) * 1000).rounded()) + } + return entry + } + + private func write(_ value: Any, to url: URL) throws { + let data = try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: .atomic) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 9acb25e19..1a075ac00 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -47,6 +47,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting { private let core: any LanguageServerRuntimeCore private weak var processRegistry: (any LanguageServerProcessRegistry)? private let credentials: any PluginCredentialStoring + private let prepareState: @Sendable (_ pluginID: String, _ stateDirectory: URL) -> Void private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void private let stuckSendGrace: Duration private var packages: [String: Package] = [:] @@ -76,6 +77,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting { core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)?, credentials: any PluginCredentialStoring, + prepareState: @escaping @Sendable (_ pluginID: String, _ stateDirectory: URL) -> Void = { _, _ in }, stuckSendGrace: Duration = .seconds(2), endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( @@ -91,6 +93,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting { self.core = core self.processRegistry = processRegistry self.credentials = credentials + self.prepareState = prepareState self.stuckSendGrace = stuckSendGrace self.endBackendProcesses = endBackendProcesses } @@ -204,6 +207,14 @@ final class MacPluginBackendSessions: PluginBackendRequesting { pluginID: String, entry: Running ) async throws -> LanguageServerRuntimeSession { + // Data the host moves into a plugin lands before its backend reads + // its state. The work touches files and the Keychain, so it runs + // off the main actor. + let prepareState = prepareState + let stateDirectory = backend.stateDirectory + await Task.detached(priority: .userInitiated) { prepareState(pluginID, stateDirectory) }.value + try Task.checkCancellation() + guard running[pluginID] === entry else { throw CancellationError() } var (executableURL, environment) = try await resolveExecutable(backend.process, directory: backend.stateDirectory) try Task.checkCancellation() guard running[pluginID] === entry else { throw CancellationError() } diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index f58582808..9aa41e581 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -134,7 +134,7 @@ struct LSPControlCenterView: View { // Languages owned by a published official plugin are controlled here only once // that plugin is installed, enabled, and loaded in this process. private func officialPluginEntry(languageID: String) -> OfficialPluginCatalog.Entry? { - OfficialPluginCatalog.publishedEntries.first { $0.languageID == languageID } + OfficialPluginCatalog.publishedEntries.first { $0.language?.id == languageID } } private func pluginSnapshot(_ entry: OfficialPluginCatalog.Entry) -> PluginManagementSnapshot? { @@ -147,9 +147,10 @@ struct LSPControlCenterView: View { } private func projectUses(_ entry: OfficialPluginCatalog.Entry) -> Bool { - model.projectFiles.contains { fileURL in - entry.fileExtensions.contains(fileURL.pathExtension.lowercased()) - || entry.projectFileNames.contains(fileURL.lastPathComponent.lowercased()) + guard let language = entry.language else { return false } + return model.projectFiles.contains { fileURL in + language.fileExtensions.contains(fileURL.pathExtension.lowercased()) + || language.projectFileNames.contains(fileURL.lastPathComponent.lowercased()) } } @@ -168,7 +169,7 @@ struct LSPControlCenterView: View { /// the uninstall-and-restart step while the retired official one remains. private func migratedPluginNotice(_ entry: OfficialPluginCatalog.Entry, replacementID: PluginID) -> String? { let name = entry.displayName - let language = entry.languageDisplayName + let language = entry.language?.displayName ?? name if let retired = pluginSnapshot(entry) { if retired.installationStatus == .uninstallPending { return settings.language.localizedFormat("native.inline.restartLitheToFinishUninstallingTheOldArg1ThenInstallTheCommunityArg2FromLi", String(describing: name), String(describing: name)) @@ -187,7 +188,7 @@ struct LSPControlCenterView: View { private func pluginNotice(_ entry: OfficialPluginCatalog.Entry) -> String { let name = entry.displayName - let language = entry.languageDisplayName + let language = entry.language?.displayName ?? name guard let snapshot = pluginSnapshot(entry) else { return settings.language.localizedFormat("native.inline.thisArg1ProjectNeedsArg2DownloadAndInstallItFromPluginManagement", String(describing: language), String(describing: name)) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchNotificationBanner.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchNotificationBanner.swift index 3372da8c9..248ae6e8a 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchNotificationBanner.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchNotificationBanner.swift @@ -14,6 +14,9 @@ struct WorkbenchNotificationBanner: View { /// capture can render the shipped translation instead of the format key. var bundle: Bundle = .main var showHistory: () -> Void = {} + /// Localization key of the action button, when the notification has one. + var actionTitle: String? + var performAction: () -> Void = {} let dismiss: () -> Void private var displayMessage: String { @@ -79,15 +82,24 @@ struct WorkbenchNotificationBanner: View { HStack(alignment: .top, spacing: 8) { LitheIDEAIcon(resourcePath: "expui/status/info.svg", size: 16, preservesOriginalColors: true) .padding(.top, 15) - Group { - if isExpanded { - ScrollView { - messageText - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 8) { + Group { + if isExpanded { + ScrollView { + messageText + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: min(textHeight, 10 * lineHeight)) + } else { + messageText.lineLimit(2) } - .frame(height: min(textHeight, 10 * lineHeight)) - } else { - messageText.lineLimit(2) + } + if let actionTitle { + Button(LocalizedStringKey(actionTitle), action: performAction) + .buttonStyle(.litheNoPress) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.accent) + .accessibilityIdentifier("workbench-notification-action") } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 1d63c6bfb..43e0d5906 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -576,7 +576,9 @@ struct WorkbenchView: View { messageArguments: notification.arguments, collapsedCount: notification.collapsedCount, occurrenceCount: notification.occurrenceCount, - showHistory: { isNotificationCenterPresented = true }) { + showHistory: { isNotificationCenterPresented = true }, + actionTitle: notification.action?.title, + performAction: { model.performNotificationAction(notification.id) }) { model.dismissNotification(notification.id) } .onHover { model.setNotificationHovered(notification.id, isHovered: $0) } @@ -1960,6 +1962,15 @@ private struct WorkbenchNotificationCenterView: View { .foregroundStyle(LitheTheme.primaryText) .fixedSize(horizontal: false, vertical: true) + if let action = notification.action { + Button(LocalizedStringKey(action.title)) { + model.performNotificationAction(notification.id) + } + .buttonStyle(.litheNoPress) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.accent) + } + Text(notification.updatedAt.formatted(date: .omitted, time: .shortened)) .font(LitheTheme.uiFont(size: 10.5)) .foregroundStyle(LitheTheme.tertiaryText) diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index df29a210e..b720ba3c9 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -228,16 +228,22 @@ public enum OfficialPluginCatalog { case migratedToCommunity(PluginID) } + /// The language an official plugin supports. + public struct Language: Equatable, Sendable { + public let id: String + public let displayName: String + /// Used to suggest the plugin for a project before it is installed. + public let fileExtensions: [String] + public let projectFileNames: [String] + } + public struct Entry: Equatable, Sendable { public let id: PluginID public let displayName: String public let signatureRequirement: PluginSignatureRequirement public let distribution: Distribution - public let languageID: String - public let languageDisplayName: String - /// Used to suggest the plugin for a project before it is installed. - public let fileExtensions: [String] - public let projectFileNames: [String] + /// nil for a plugin that supports no language, such as Database. + public let language: Language? /// The community plugin that replaces this retired official plugin. public var communityReplacementID: PluginID? { @@ -249,37 +255,53 @@ public enum OfficialPluginCatalog { public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") public static let goPluginID = PluginID("dev.lithe.plugin.go-support") public static let javaPluginID = PluginID("dev.lithe.plugin.java-support") + public static let databasePluginID = PluginID("dev.lithe.plugin.database") public static let entries: [Entry] = [ + // Published from its own repository; it handles database passwords, + // so only a Lithe publisher signature may load it. + Entry( + id: databasePluginID, + displayName: "Database", + signatureRequirement: .publisherPackage, + distribution: .download, + language: nil + ), Entry( id: javaPluginID, displayName: "Java Support", signatureRequirement: .sameTeamAsHost, distribution: .bundled, - languageID: "java", - languageDisplayName: "Java", - fileExtensions: ["java"], - projectFileNames: ["pom.xml", "build.gradle", "build.gradle.kts"] + language: Language( + id: "java", + displayName: "Java", + fileExtensions: ["java"], + projectFileNames: ["pom.xml", "build.gradle", "build.gradle.kts"] + ) ), Entry( id: goPluginID, displayName: "Go Support", signatureRequirement: .publisherPackage, distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.go-support")), - languageID: "go", - languageDisplayName: "Go", - fileExtensions: ["go"], - projectFileNames: ["go.mod", "go.work"] + language: Language( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + projectFileNames: ["go.mod", "go.work"] + ) ), Entry( id: phpPluginID, displayName: "PHP Support", signatureRequirement: .publisherPackage, distribution: .migratedToCommunity(PluginID("io.github.lithe-idea.php-support")), - languageID: "php", - languageDisplayName: "PHP", - fileExtensions: ["php", "phtml"], - projectFileNames: ["composer.json"] + language: Language( + id: "php", + displayName: "PHP", + fileExtensions: ["php", "phtml"], + projectFileNames: ["composer.json"] + ) ) ] diff --git a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift index 955c89bad..5c4790772 100644 --- a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift +++ b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift @@ -391,7 +391,7 @@ struct ModuleRuntimeTests { @Test func aiAssistanceIsBuiltInRatherThanAnOfficialDownload() { #expect(BuiltInPluginCatalog.manifest(forModule: .aiAssistance) != nil) - #expect(OfficialPluginCatalog.entries.allSatisfy { $0.languageID != "ai" }) + #expect(OfficialPluginCatalog.entries.allSatisfy { $0.language?.id != "ai" }) } @Test diff --git a/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift b/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift new file mode 100644 index 000000000..41ada8339 --- /dev/null +++ b/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift @@ -0,0 +1,187 @@ +import Foundation +@testable import Lithe +import Testing + +/// What the former built-in database module saved, moved once into the +/// database plugin's state and credentials, in the format the plugin reads. +struct MacDatabasePluginMigrationTests { + private static let pluginID = MacDatabasePluginMigration.pluginID + private static let production = "6F1B0B6E-6A8E-4C35-9F0B-1E2D3C4B5A60" + private static let scratch = "0C2A7B1D-3E4F-4A5B-8C6D-7E8F9A0B1C2D" + private static let reporting = "9A8B7C6D-5E4F-4321-8765-0FEDCBA98765" + private static let analytics = "11111111-2222-4333-8444-555555555555" + + @Test + func connectionsHistoryAndForeverPasswordsMoveIntoThePlugin() throws { + let fixture = try Fixture() + defer { fixture.remove() } + fixture.preferences.set(try json([ + ["id": Self.scratch, "name": "scratch", "kind": "sqlite", "path": "/tmp/scratch.db", + "passwordPersistence": "untilRestart"], + ["id": Self.production, "name": "Production", "kind": "postgresql", "host": "db.example", + "port": 5432, "folderID": Self.analytics, "productionProtection": true], + ["id": Self.reporting, "name": "Reporting", "kind": "mysql", "group": " Reports "], + ]), forKey: MacDatabasePluginMigration.profilesKey) + fixture.preferences.set(try json([["id": Self.analytics, "name": "Analytics"]]), + forKey: MacDatabasePluginMigration.foldersKey) + // 2025-01-01T00:00:00Z is 757,382,400 s after 2001. + fixture.preferences.set(try json([ + ["id": "A0000000-0000-4000-8000-000000000001", "profileID": Self.production, "sql": "SELECT 1", + "kind": "query", "executedAt": 757_382_400.25, "durationMilliseconds": 3], + ["id": "A0000000-0000-4000-8000-000000000002", "profileID": "DELETED0-0000-4000-8000-000000000000", + "sql": "SELECT 2", "kind": "query", "executedAt": 757_382_400, "durationMilliseconds": 1], + ]), forKey: MacDatabasePluginMigration.historyKey) + fixture.legacyPasswords["database.connection.\(Self.production).password"] = "pg-secret" + fixture.legacyPasswords["database.connection.\(Self.reporting).password"] = "my-secret" + fixture.legacyPasswords["database.connection.\(Self.scratch).password"] = "never-kept" + + #expect(fixture.migration.prepare(pluginID: Self.pluginID, stateDirectory: fixture.state) + == .migrated(profiles: 3, passwords: 2)) + + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.production):password") == "pg-secret") + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.reporting):password") == "my-secret") + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.scratch):password") == nil) + + let connections = try fixture.object("connections.json") + #expect(connections["schemaVersion"] as? Int == 1) + let profiles = try #require(connections["profiles"] as? [[String: Any]]) + #expect(profiles.compactMap { $0["name"] as? String } == ["Production", "Reporting", "scratch"]) + #expect(profiles[0]["productionProtection"] as? Bool == true) + #expect(profiles[0]["folderID"] as? String == Self.analytics) + // A legacy group becomes a top-level folder, as the old module did. + let folders = try #require(connections["folders"] as? [[String: Any]]) + let reports = try #require(folders.first { $0["name"] as? String == "Reports" }) + #expect(profiles[1]["folderID"] as? String == reports["id"] as? String) + #expect(profiles[1]["group"] as? String == "") + + let history = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: fixture.state + .appendingPathComponent("sql-history.json"))) as? [[String: Any]]) + #expect(history.count == 1) + #expect(history[0]["executedAt"] as? Int == 1_735_689_600_250) + + let stored = try String(contentsOf: fixture.state.appendingPathComponent("connections.json"), encoding: .utf8) + #expect(!stored.contains("secret")) + // The old data stays for an older Lithe. + #expect(fixture.preferences.data(forKey: MacDatabasePluginMigration.profilesKey) != nil) + } + + @Test + func theMoveHappensOnceAndNeverReplacesThePluginsOwnConnections() throws { + let fixture = try Fixture() + defer { fixture.remove() } + fixture.preferences.set(try json([["id": Self.production, "name": "Production", "kind": "postgresql"]]), + forKey: MacDatabasePluginMigration.profilesKey) + + #expect(fixture.migration.prepare(pluginID: "dev.example.other", stateDirectory: fixture.state) == .otherPlugin) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + + try FileManager.default.createDirectory(at: fixture.state, withIntermediateDirectories: true) + let own = Data(#"{"schemaVersion":1,"profiles":[],"folders":[]}"#.utf8) + try own.write(to: fixture.state.appendingPathComponent("connections.json")) + #expect(fixture.migration.prepare(pluginID: Self.pluginID, stateDirectory: fixture.state) == .keptPluginState) + #expect(try Data(contentsOf: fixture.state.appendingPathComponent("connections.json")) == own) + + try FileManager.default.removeItem(at: fixture.state) + #expect(fixture.migration.prepare(pluginID: Self.pluginID, stateDirectory: fixture.state) == .alreadyMigrated) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + } + + @Test + func aPasswordThatCannotMoveLeavesItsConnectionAskingAgain() throws { + let fixture = try Fixture(credentials: LockedCredentials()) + defer { fixture.remove() } + fixture.preferences.set(try json([["id": Self.production, "name": "Production", "kind": "postgresql"]]), + forKey: MacDatabasePluginMigration.profilesKey) + fixture.legacyPasswords["database.connection.\(Self.production).password"] = "pg-secret" + + #expect(fixture.migration.prepare(pluginID: Self.pluginID, stateDirectory: fixture.state) + == .migrated(profiles: 1, passwords: 0)) + #expect(fixture.logged.messages.count == 1) + #expect(fixture.logged.messages.allSatisfy { !$0.contains("pg-secret") }) + } + + @Test + func withoutSavedConnectionsOnlyTheMarkerIsWritten() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + #expect(fixture.migration.prepare(pluginID: Self.pluginID, stateDirectory: fixture.state) == .nothingToMigrate) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + #expect(fixture.preferences.object(forKey: MacDatabasePluginMigration.markerKey) != nil) + } + + private func json(_ value: Any) throws -> Data { + try JSONSerialization.data(withJSONObject: value) + } +} + +private struct Fixture { + let root: URL + let state: URL + let preferences = MigrationPreferences() + let credentials: any PluginCredentialStoring + let logged = MigrationLog() + let legacyPasswordBox = LegacyPasswords() + let migration: MacDatabasePluginMigration + + var legacyPasswords: [String: String] { + get { legacyPasswordBox.values } + nonmutating set { legacyPasswordBox.values = newValue } + } + + init(credentials: any PluginCredentialStoring = MemoryPluginCredentialStore()) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-database-migration-\(UUID().uuidString)", isDirectory: true) + state = root.appendingPathComponent("state/dev.lithe.plugin.database/backend", isDirectory: true) + self.credentials = credentials + let passwords = legacyPasswordBox + let log = logged + migration = MacDatabasePluginMigration( + preferences: preferences, + legacyPassword: { key in passwords.values[key] }, + credentials: credentials, + log: { message in log.append(message) } + ) + } + + func object(_ name: String) throws -> [String: Any] { + try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: state.appendingPathComponent(name))) as? [String: Any]) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +private final class MigrationPreferences: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +private final class LegacyPasswords: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String: String] = [:] + var values: [String: String] { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} + +private final class MigrationLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + var messages: [String] { lock.withLock { recorded } } + func append(_ message: String) { lock.withLock { recorded.append(message) } } +} + +private struct LockedCredentials: PluginCredentialStoring { + struct Locked: LocalizedError { var errorDescription: String? { "The Keychain is locked." } } + func secret(pluginID _: String, key _: String) throws -> String? { throw Locked() } + func setSecret(_: String, pluginID _: String, key _: String) throws { throw Locked() } + func deleteSecret(pluginID _: String, key _: String) throws { throw Locked() } + func deleteAll(pluginID _: String) throws { throw Locked() } +} diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index 7270fe8f0..1949919a0 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -33,6 +33,25 @@ struct MacPluginBackendSessionsTests { #expect(FileManager.default.fileExists(atPath: stateDirectory.path)) } + /// Host migrations into a plugin's state finish before its backend + /// starts, once per start, with the backend's own state directory. + @Test + func stateIsPreparedBeforeTheBackendStarts() async throws { + let prepared = PreparedStates() + let fixture = try BackendFixture { pluginID, stateDirectory in + try? FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + try? Data("moved".utf8).write(to: stateDirectory.appendingPathComponent("migrated.json")) + prepared.record(pluginID: pluginID, stateDirectory: stateDirectory) + } + defer { fixture.remove() } + + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + let stateDirectory = try #require(fixture.core.starts.first?.rootURL) + #expect(prepared.calls == [.init(pluginID: BackendFixture.pluginID, path: stateDirectory.standardizedFileURL.path)]) + #expect(FileManager.default.fileExists(atPath: stateDirectory.appendingPathComponent("migrated.json").path)) + } + /// A credential request is answered from the plugin's own entries, and /// a store failure reaches the backend without the secret. @Test @@ -126,6 +145,23 @@ private struct FailingCredentialStore: PluginCredentialStoring { func deleteAll(pluginID _: String) throws { throw Locked() } } +/// The state preparations a fixture's sessions ran, from any thread. +private final class PreparedStates: @unchecked Sendable { + struct Call: Equatable { + let pluginID: String + let path: String + } + + private let lock = NSLock() + private var recorded: [Call] = [] + + var calls: [Call] { lock.withLock { recorded } } + + func record(pluginID: String, stateDirectory: URL) { + lock.withLock { recorded.append(Call(pluginID: pluginID, path: stateDirectory.standardizedFileURL.path)) } + } +} + /// The backend fixture of the shared contract as an installed package. @MainActor private struct BackendFixture { @@ -138,7 +174,7 @@ private struct BackendFixture { let core = ScriptedBackendCore() let sessions: MacPluginBackendSessions - init() throws { + init(prepareState: @escaping @Sendable (_ pluginID: String, _ stateDirectory: URL) -> Void = { _, _ in }) throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-plugin-backend-\(UUID().uuidString)", isDirectory: true) packageURL = root.appendingPathComponent("package", isDirectory: true) @@ -169,6 +205,7 @@ private struct BackendFixture { core: core, processRegistry: nil, credentials: credentials, + prepareState: prepareState, endBackendProcesses: { _, _ in } ) sessions.register([InstalledPluginPackage( diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index 7a89559bb..e8f4fa1d4 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -44,10 +44,14 @@ struct PluginManagerTests { let officialLanguagePlugins = try ["GoSupport", "JavaSupport", "PhpSupport"].map { try DeclarativePluginTestSupport.officialDescriptor($0).pluginManifest } - #expect(Set(officialLanguagePlugins.map(\.id)) == Set(OfficialPluginCatalog.entries.map(\.id))) + #expect(Set(officialLanguagePlugins.map(\.id)) == Set(OfficialPluginCatalog.entries.filter { $0.language != nil }.map(\.id))) // Go and PHP moved to Lithe Store: installed copies keep their pinned // publisher signature, but the host no longer suggests them. - #expect(OfficialPluginCatalog.publishedEntries.map(\.id) == [OfficialPluginCatalog.javaPluginID]) + #expect(OfficialPluginCatalog.publishedEntries.map(\.id) + == [OfficialPluginCatalog.databasePluginID, OfficialPluginCatalog.javaPluginID]) + // Database supports no language and must carry a publisher signature. + #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.databasePluginID)?.language == nil) + #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.databasePluginID)?.signatureRequirement == .publisherPackage) #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.goPluginID)?.communityReplacementID == PluginID("io.github.lithe-idea.go-support")) #expect(OfficialPluginCatalog.entry(for: OfficialPluginCatalog.phpPluginID)?.communityReplacementID diff --git a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift index a2931ffa8..a8b90fac7 100644 --- a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift +++ b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift @@ -3,6 +3,7 @@ import AppKit import SwiftUI import Testing @testable import Lithe +import LitheModuleAPI @Suite("Workbench notifications", .serialized) @MainActor @@ -30,6 +31,44 @@ struct WorkbenchNotificationTests { } } + /// A notification with an action never times out; using the action + /// runs it, closes the balloon, and keeps the history entry. + @Test + func anActionStaysUntilUsed() { + let clock = NotificationTestClock() + let feature = WorkbenchNotificationFeatureModel(now: { clock.now }, sleep: { try await clock.sleep($0) }) + defer { feature.clear(); clock.releaseAll() } + var opened = 0 + feature.show("Database is now a plugin.", arguments: [], + action: WorkbenchNotification.Action(title: "Open Marketplace") { opened += 1 }) + #expect(clock.pending.isEmpty) + clock.advance(.seconds(100)) + let id = feature.activeNotifications[0].id + #expect(feature.activeNotifications.count == 1) + + feature.perform(id) + #expect(opened == 1) + #expect(feature.activeNotifications.isEmpty) + #expect(feature.notifications.first?.isRead == true) + #expect(feature.notifications.first?.action?.title == "Open Marketplace") + } + + @Test + func theDatabasePluginIsSuggestedOnceToPeopleWithSavedConnections() throws { + let store = NotificationTestStore() + let none = Set() + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + store.set(try JSONSerialization.data(withJSONObject: []), forKey: DatabasePluginTransition.legacyProfilesKey) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + store.set(try JSONSerialization.data(withJSONObject: [["id": "A", "name": "Production"]]), + forKey: DatabasePluginTransition.legacyProfilesKey) + #expect(DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, + installedPluginIDs: [OfficialPluginCatalog.databasePluginID])) + store.set(true, forKey: DatabasePluginTransition.suggestionKey) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + } + @Test func mavenRuntimeErrorUsesApplicationLanguage() throws { let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources") @@ -448,3 +487,12 @@ private final class WorkbenchNotificationTestStore: KeyValueStore, @unchecked Se func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } func set(_ value: Any?, forKey key: String) { values[key] = value } } + +private final class NotificationTestStore: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 7a86dc66d..49b94af1b 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9077,7 +9077,9 @@ "windows.whenASessionStarts": "When a session starts", "windows.whenATurnEnds": "When a turn ends", "windows.whenSubmittingAPrompt": "When submitting a prompt", - "windows.whenToRun": "When to run" + "windows.whenToRun": "When to run", + "native.databaseIsNowAPluginInstallItFromTheMarketplace": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "native.openMarketplace": "Open Marketplace" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 089cc96cf..856bbafaf 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9077,7 +9077,9 @@ "windows.whenASessionStarts": "会话开始时", "windows.whenATurnEnds": "一轮结束时", "windows.whenSubmittingAPrompt": "提交提示词时", - "windows.whenToRun": "何时运行" + "windows.whenToRun": "何时运行", + "native.databaseIsNowAPluginInstallItFromTheMarketplace": "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。", + "native.openMarketplace": "打开插件市场" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 5f6d1e684..aec8d80dd 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -84538,6 +84538,28 @@ "windows": { "key": "When to run" } + }, + { + "id": "native.databaseIsNowAPluginInstallItFromTheMarketplace", + "description": "Native UI: Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "arguments": [] + } + }, + { + "id": "native.openMarketplace", + "description": "Native UI: Open Marketplace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Marketplace", + "arguments": [] + } } ] } From 92277530576ab85520e21d8d5440c7dacf4115b2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 09:39:09 -0700 Subject: [PATCH 14/16] refactor: remove the built-in database module; the database is a plugin now Lithe no longer ships a database module. The official plugin dev.lithe.plugin.database (Lithe-IDEA/lithe-plugin-database) replaces it, and the earlier commit moves saved connections into it. - macOS: delete LitheDatabaseModule, the database views, adapters, recovery store, coordinator, sidecar locator, brand icons, dbx fixtures, and the database tool windows, editor tabs, theme tokens, console gutter and AppModel branches. Generic tests that used the database as a sample now use other modules. Dropdown/context-menu parameters only the database used are gone. Idle sleep keeps a visible module window open; only shutdown closes it. - Rust: delete lithe-db-sidecar and lithe-db-mcp. - Windows: delete features/database and its sidecar packaging, the databases extension contribution, and every database branch; openBuffer loses its two database-only parameters. No plugin adaptation yet. - CI and scripts: delete the database workflow, validation docker setup and sidecar/MCP build and smoke scripts; packaging, preview, verifiers, CI change classification and lane reuse drop the database. - i18n: drop strings only the removed UIs used and regenerate. - Feature matrix, Agent Notes, README, docs and Skill: the database is an official plugin; the plugin note is implemented, the multi-schema tree note is archived. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-22-database-multi-schema-tree.md | 5 + ...sitory-ownership-and-sharing-boundaries.md | 2 - .../2026-10-24-database-provider-plugin.md | 83 +- ...13-ci-build-cache-and-artifact-strategy.md | 17 +- .../2026-09-30-macos-shared-dropdown-style.md | 3 - ...lic-extension-contract-and-provider-api.md | 1 - .agents/skills/develop-lithe/SKILL.md | 4 +- .../prepare-macos-dependency-cache/action.yml | 4 +- .github/workflows/ci-database.yml | 145 - .github/workflows/ci-macos.yml | 23 +- Package.swift | 11 +- README.md | 9 +- README.zh-CN.md | 9 +- .../screenshots/database-sql-operation.png | Bin 217648 -> 0 bytes .../database-workspace-overview.png | Bin 264499 -> 0 bytes docs/ci-builds.md | 10 +- infra/docker/database-validation/compose.yaml | 86 - .../database-validation/mariadb-init.sql | 35 - .../database-validation/mongodb-init.js | 32 - .../docker/database-validation/redis-seed.sh | 13 - .../DatabaseIcons/LICENSE-APACHE-2.0.txt | 201 - macos/Resources/DatabaseIcons/NOTICE.txt | 18 - macos/Resources/DatabaseIcons/mariadb.svg | 8 - .../Resources/DatabaseIcons/mariadb_dark.svg | 7 - macos/Resources/DatabaseIcons/mongodb.svg | 8 - macos/Resources/DatabaseIcons/mysql.svg | 8 - macos/Resources/DatabaseIcons/mysql_dark.svg | 7 - macos/Resources/DatabaseIcons/nacos.svg | 4 - macos/Resources/DatabaseIcons/nacos_dark.svg | 4 - macos/Resources/DatabaseIcons/postgres.svg | 9 - .../Resources/DatabaseIcons/postgres_dark.svg | 9 - macos/Resources/DatabaseIcons/redis.svg | 14 - macos/Resources/DatabaseIcons/redis_dark.svg | 14 - macos/Resources/DatabaseIcons/sqlite.svg | 7 - macos/Resources/DatabaseIcons/sqlite_dark.svg | 7 - macos/Resources/DatabaseIcons/sqlserver.svg | 6 - .../DatabaseIcons/sqlserver_dark.svg | 6 - macos/Resources/en.lproj/Localizable.strings | 150 - .../zh-Hans.lproj/Localizable.strings | 150 - .../Application/Composition/AppServices.swift | 3 - .../DatabaseModuleCoordinator.swift | 61 - .../ModuleSessionCoordinator.swift | 9 +- .../Features/DatabasePluginTransition.swift | 2 +- .../Features/EditorTabOrderFeatureModel.swift | 13 +- .../Features/PluginHostServices.swift | 2 +- .../Features/WorkbenchFeatureModel.swift | 11 +- .../AppModel/AppModel+AgentConversation.swift | 1 - .../AppModel/AppModel+FeatureState.swift | 155 +- .../Models/AppModel/AppModel+Media.swift | 8 - .../AppModel/AppModel+PluginManagement.swift | 12 - .../Models/AppModel/AppModel+Terminal.swift | 2 - .../AppModel/AppModel+WorkbenchClose.swift | 8 - .../AppModel/AppModel+WorkbenchState.swift | 1 - .../Lithe/Models/AppModel/AppModel.swift | 32 - .../Lithe/Models/Editor/EditorTabItem.swift | 10 - .../Platform/MacOS/MacServiceContainer.swift | 25 - .../MacOS/MonacoWorkbenchEditor.swift | 2 +- .../MacDatabaseRecoveryStore.swift | 236 - .../Plugins/MacDatabasePluginMigration.swift | 2 +- .../Plugins/MacPluginBackendSessions.swift | 2 +- .../Plugins/MacPluginCredentialStore.swift | 2 +- .../Process/MacDatabaseSidecarLocator.swift | 29 - .../MacOS/Storage/MacDatabaseAdapters.swift | 26 - .../Lithe/Theme/DatabaseBrandIcon.swift | 75 - macos/Sources/Lithe/Theme/LitheTheme.swift | 89 - .../Views/Components/LitheContextMenu.swift | 12 - .../Views/Components/LitheDropdown.swift | 11 +- .../Components/LitheTextViewportLayout.swift | 26 - .../Views/Database/DatabaseLocalization.swift | 118 - .../Database/DatabaseSQLWorkspaceView.swift | 1797 ------- .../Database/DatabaseSchemaDiffView.swift | 151 - .../Views/Database/DatabaseServicesView.swift | 228 - .../Views/Database/DatabaseSidebarView.swift | 2410 --------- .../DatabaseSpecializedWorkspaceViews.swift | 658 --- .../Views/Database/DatabaseTableView.swift | 1330 ----- .../Lithe/Views/Editor/CodeEditorView.swift | 55 +- .../Lithe/Views/Editor/EditorAreaView.swift | 91 +- .../Lithe/Views/Git/GitRepositoryColor.swift | 12 + .../Views/Language/JavaProblemsView.swift | 2 +- .../WorkbenchModuleUIComposition.swift | 47 +- .../Application/DatabaseFeatureModel.swift | 2745 ---------- .../Models/DatabaseSQLSupport.swift | 487 -- .../Models/DatabaseSchemaDiff.swift | 401 -- .../Module/DatabaseModule.swift | 86 - .../Ports/DatabasePorts.swift | 58 - .../Ports/DatabaseRecovery.swift | 151 - .../Services/DatabaseConnectionStore.swift | 202 - .../Services/DatabaseDBXImportService.swift | 353 -- .../Services/DatabaseSidecarService.swift | 878 ---- .../Catalog/BuiltInModuleCatalog.swift | 15 - .../Lifecycle/ModuleTypes.swift | 2 - .../ModuleRuntimeTests.swift | 44 +- .../DatabaseModuleTests.swift | 120 - .../DatabaseGridAppearanceTests.swift | 79 - .../LitheTests/DatabaseGridClickTests.swift | 27 - .../DatabaseToolbarAppearanceTests.swift | 79 - .../DatabaseWorkspaceLifecycleTests.swift | 3084 ----------- .../EditorTabOrderFeatureModelTests.swift | 16 - .../LitheTests/LitheCoreLogicTests.swift | 1408 +---- .../ModuleSessionCoordinatorTests.swift | 30 +- .../LitheTests/OutputTimestamperTests.swift | 16 +- .../Tests/LitheTests/PluginManagerTests.swift | 26 - .../LitheTests/PluginWebBridgeTests.swift | 2 +- .../TerminalPlacementFeatureModelTests.swift | 11 +- .../WorkbenchContributionHostTests.swift | 115 +- .../WorkbenchFeatureModelTests.swift | 48 +- .../WorkbenchModuleUIRegistryTests.swift | 20 - rust/Cargo.lock | 1662 +----- rust/Cargo.toml | 2 +- rust/lithe-core/src/plugins/backend.rs | 2 +- rust/lithe-db-mcp/Cargo.toml | 11 - rust/lithe-db-mcp/src/main.rs | 678 --- rust/lithe-db-sidecar/Cargo.toml | 26 - rust/lithe-db-sidecar/src/main.rs | 4547 ----------------- rust/lithe-db-sidecar/src/mongodb_adapter.rs | 534 -- .../lithe-db-sidecar/src/sqlserver_adapter.rs | 682 --- scripts/build-database-mcp.sh | 45 - scripts/build-database-sidecar.sh | 47 - scripts/ci-workflow-gates.test.mjs | 14 +- scripts/classify-ci-changes.sh | 94 +- scripts/database-sidecar-smoke.sh | 26 - scripts/database-validation-smoke.sh | 146 - scripts/measure-macos-performance-baseline.sh | 1 - scripts/package-app.sh | 35 - scripts/plan-ci-lane-reuse.mjs | 2 +- scripts/preview.sh | 9 - scripts/test-classify-ci-changes.sh | 194 +- scripts/verify-macos-package.sh | 2 - scripts/verify-module-boundaries.sh | 36 +- scripts/verify-shared-contracts.sh | 1 - shared/fixtures/modules/built-in-v1.json | 14 - shared/i18n/locales/en-US.json | 277 - shared/i18n/locales/zh-CN.json | 277 - shared/i18n/messages.json | 2980 ++--------- .../features/database-connections.json | 29 +- .../features/database-crud-export.json | 19 +- .../features/database-multi-schema-tree.json | 21 +- .../features/database-plugin-migration.json | 27 + .../features/database-query-history.json | 19 +- .../features/database-schema.json | 16 +- .../macos-shared-product-dropdowns.json | 1 - .../macos-shared-tree-list-control.json | 3 +- third_party/dbx/manifest.json | 18 - .../tauri/src/config/backend-capabilities.ts | 1 - .../extensions/languages/language-packager.ts | 1 - .../marketplace/marketplace-extensions.ts | 3 - .../tooling/build-extensions-index.ts | 6 +- .../extensions/tooling/extension-workspace.ts | 7 - .../tooling/package-database-sidecars.ts | 201 - .../extensions/tooling/package-extensions.ts | 3 +- .../tauri/src/extensions/tooling/validate.ts | 12 - .../types/extension-contributions.ts | 12 - .../extensions/types/extension-manifest.ts | 27 - .../ui/components/extensions-sidebar.tsx | 38 - .../ai/components/input/chat-input-bar.tsx | 12 +- .../components/messages/tool-call-display.tsx | 1 - .../components/selectors/context-selector.tsx | 3 - .../src/features/ai/lib/acp-diff-output.ts | 2 +- .../features/ai/utils/ai-context-builder.ts | 1 - .../constants/database-actions.tsx | 24 - .../constants/markdown-actions.tsx | 2 - .../command-palette/types/view.types.ts | 1 - .../database/components/cell-renderer.tsx | 195 - .../database/components/column-filters.tsx | 128 - .../connection/connection-config.ts | 55 - .../connection/connection-dialog.tsx | 423 -- .../connection/connection-validation.ts | 78 - .../database/components/context-menus.tsx | 98 - .../database/components/crud-modals.tsx | 322 -- .../database/components/data-grid.tsx | 641 --- .../database/components/database-sidebar.tsx | 660 --- .../database/components/database-surface.ts | 24 - .../database/components/info-view.tsx | 109 - .../database/components/pagination.tsx | 118 - .../database/components/query-bar.tsx | 344 -- .../database/components/schema-view.tsx | 107 - .../database/components/sql-history-list.tsx | 129 - .../database/components/table-sidebar.tsx | 111 - .../database/components/table-toolbar.tsx | 250 - .../features/database/hooks/use-cell-copy.ts | 64 - .../features/database/lib/database-catalog.ts | 50 - .../features/database/lib/database-errors.ts | 57 - .../features/database/lib/database-schema.ts | 41 - .../database/lib/query-result-export.ts | 119 - .../database/lib/query-result-pagination.ts | 46 - .../database/lib/query-result-summary.ts | 47 - .../features/database/lib/sql-completions.ts | 285 -- .../features/database/lib/sql-highlight.ts | 170 - .../database/lib/sql-history-storage.ts | 84 - .../src/features/database/lib/sql-history.ts | 84 - .../features/database/lib/sql-selection.ts | 12 - .../providers/duckdb/duckdb-viewer.tsx | 4 - .../providers/duckdb/stores/duckdb.store.ts | 3 - .../providers/mongodb/mongodb-pagination.ts | 11 - .../providers/mongodb/mongodb-viewer.tsx | 326 -- .../providers/mongodb/stores/mongodb.store.ts | 492 -- .../database/providers/mysql/mysql-viewer.tsx | 4 - .../providers/mysql/stores/mysql.store.ts | 3 - .../components/create-subscription-dialog.tsx | 184 - .../components/create-subscription-form.ts | 34 - .../postgres-subscription-schema-view.tsx | 68 - .../providers/postgres/postgres-viewer.tsx | 4 - .../postgres/stores/postgres.store.ts | 3 - .../database/providers/provider-registry.ts | 74 - .../database/providers/redis/redis-viewer.tsx | 275 - .../providers/redis/stores/redis.store.ts | 409 -- .../providers/sql/sql-database-viewer.tsx | 418 -- .../providers/sql/sql-provider-viewer.tsx | 46 - .../providers/sql/stores/create-sql.store.ts | 1277 ----- .../providers/sqlite/sqlite-viewer.tsx | 4 - .../providers/sqlite/stores/sqlite.store.ts | 3 - .../services/database-provider-sidecar.ts | 57 - .../database/stores/connection.store.ts | 384 -- .../features/database/types/common.types.ts | 98 - .../features/database/types/provider.types.ts | 1 - .../src/features/database/utils/clipboard.ts | 19 - .../database/utils/create-table-form.ts | 29 - .../database/utils/data-grid-selection.ts | 208 - .../database/utils/database-file-drop.ts | 15 - .../features/database/utils/value-coercion.ts | 43 - .../utils/workspace-database-files.ts | 77 - .../editor/components/toolbar/breadcrumb.tsx | 1 - .../editor/stores/buffer-content-factory.ts | 10 - .../features/editor/stores/buffer.store.ts | 38 +- .../hooks/use-file-explorer-context-menu.tsx | 1 - .../utils/file-explorer-tree-utils.ts | 1 - .../file-system/controllers/file-utils.ts | 33 - .../workspace-session-restore.test.ts | 2 +- .../controllers/workspace-session-restore.ts | 4 +- .../file-system/stores/file-system.store.ts | 19 +- .../git/components/git-branch-manager.tsx | 3 +- .../git-line-history-popup.test.tsx | 7 +- .../components/stash/git-saved-changes.tsx | 1 - .../git/hooks/use-git-diff-actions.test.tsx | 14 +- .../git/hooks/use-git-diff-actions.ts | 2 +- .../features/git/hooks/use-git-diff-data.ts | 1 - .../git/services/git-gutter-actions.ts | 2 +- .../git/utils/open-commit-diff-buffer.ts | 3 +- .../components/github-notifications-menu.tsx | 3 +- .../keymaps/commands/command-registry.ts | 12 - .../layout/components/main-layout.tsx | 14 - .../features/layout/config/item-order.test.ts | 7 +- .../layout/utils/sidebar-pane-utils.ts | 2 - .../components/local-history-command.tsx | 2 - .../panes/components/pane-container.tsx | 36 - .../panes/types/pane-content.types.ts | 16 - .../components/run-actions-button.tsx | 3 +- .../features/settings/types/settings.types.ts | 1 - .../sidebar/utils/open-sidebar-resource.ts | 2 +- .../features/tabs/components/tab-bar-item.tsx | 3 - .../src/features/tabs/components/tab-bar.tsx | 1 - .../window/components/window-menu-bar.tsx | 8 - .../features/window/hooks/use-deep-link.ts | 1 - .../stores/ui-state/context-menu-slice.ts | 54 - .../window/stores/ui-state/modal-slice.ts | 33 - windows/tauri/src/i18n/shared.generated.ts | 404 -- windows/tauri/src/platform/tauri-core.ts | 10 - 257 files changed, 772 insertions(+), 41658 deletions(-) rename .agents/notes/{implemented => archived}/feature/2026-10-22-database-multi-schema-tree.md (98%) rename .agents/notes/{proposed => implemented}/architecture/2026-10-24-database-provider-plugin.md (67%) delete mode 100644 .github/workflows/ci-database.yml delete mode 100644 docs/assets/screenshots/database-sql-operation.png delete mode 100644 docs/assets/screenshots/database-workspace-overview.png delete mode 100644 infra/docker/database-validation/compose.yaml delete mode 100644 infra/docker/database-validation/mariadb-init.sql delete mode 100644 infra/docker/database-validation/mongodb-init.js delete mode 100755 infra/docker/database-validation/redis-seed.sh delete mode 100644 macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt delete mode 100644 macos/Resources/DatabaseIcons/NOTICE.txt delete mode 100644 macos/Resources/DatabaseIcons/mariadb.svg delete mode 100644 macos/Resources/DatabaseIcons/mariadb_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/mongodb.svg delete mode 100644 macos/Resources/DatabaseIcons/mysql.svg delete mode 100644 macos/Resources/DatabaseIcons/mysql_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/nacos.svg delete mode 100644 macos/Resources/DatabaseIcons/nacos_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/postgres.svg delete mode 100644 macos/Resources/DatabaseIcons/postgres_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/redis.svg delete mode 100644 macos/Resources/DatabaseIcons/redis_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/sqlite.svg delete mode 100644 macos/Resources/DatabaseIcons/sqlite_dark.svg delete mode 100644 macos/Resources/DatabaseIcons/sqlserver.svg delete mode 100644 macos/Resources/DatabaseIcons/sqlserver_dark.svg delete mode 100644 macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift delete mode 100644 macos/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift delete mode 100644 macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift delete mode 100644 macos/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift delete mode 100644 macos/Sources/Lithe/Theme/DatabaseBrandIcon.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseLocalization.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseServicesView.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift delete mode 100644 macos/Sources/Lithe/Views/Database/DatabaseTableView.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Module/DatabaseModule.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift delete mode 100644 macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift delete mode 100644 macos/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift delete mode 100644 macos/Tests/LitheTests/DatabaseGridAppearanceTests.swift delete mode 100644 macos/Tests/LitheTests/DatabaseGridClickTests.swift delete mode 100644 macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift delete mode 100644 macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift delete mode 100644 rust/lithe-db-mcp/Cargo.toml delete mode 100644 rust/lithe-db-mcp/src/main.rs delete mode 100644 rust/lithe-db-sidecar/Cargo.toml delete mode 100644 rust/lithe-db-sidecar/src/main.rs delete mode 100644 rust/lithe-db-sidecar/src/mongodb_adapter.rs delete mode 100644 rust/lithe-db-sidecar/src/sqlserver_adapter.rs delete mode 100755 scripts/build-database-mcp.sh delete mode 100755 scripts/build-database-sidecar.sh delete mode 100755 scripts/database-sidecar-smoke.sh delete mode 100755 scripts/database-validation-smoke.sh create mode 100644 shared/platform-feature-matrix/features/database-plugin-migration.json delete mode 100644 third_party/dbx/manifest.json delete mode 100644 windows/tauri/src/extensions/tooling/package-database-sidecars.ts delete mode 100644 windows/tauri/src/features/command-palette/constants/database-actions.tsx delete mode 100644 windows/tauri/src/features/database/components/cell-renderer.tsx delete mode 100644 windows/tauri/src/features/database/components/column-filters.tsx delete mode 100644 windows/tauri/src/features/database/components/connection/connection-config.ts delete mode 100644 windows/tauri/src/features/database/components/connection/connection-dialog.tsx delete mode 100644 windows/tauri/src/features/database/components/connection/connection-validation.ts delete mode 100644 windows/tauri/src/features/database/components/context-menus.tsx delete mode 100644 windows/tauri/src/features/database/components/crud-modals.tsx delete mode 100644 windows/tauri/src/features/database/components/data-grid.tsx delete mode 100644 windows/tauri/src/features/database/components/database-sidebar.tsx delete mode 100644 windows/tauri/src/features/database/components/database-surface.ts delete mode 100644 windows/tauri/src/features/database/components/info-view.tsx delete mode 100644 windows/tauri/src/features/database/components/pagination.tsx delete mode 100644 windows/tauri/src/features/database/components/query-bar.tsx delete mode 100644 windows/tauri/src/features/database/components/schema-view.tsx delete mode 100644 windows/tauri/src/features/database/components/sql-history-list.tsx delete mode 100644 windows/tauri/src/features/database/components/table-sidebar.tsx delete mode 100644 windows/tauri/src/features/database/components/table-toolbar.tsx delete mode 100644 windows/tauri/src/features/database/hooks/use-cell-copy.ts delete mode 100644 windows/tauri/src/features/database/lib/database-catalog.ts delete mode 100644 windows/tauri/src/features/database/lib/database-errors.ts delete mode 100644 windows/tauri/src/features/database/lib/database-schema.ts delete mode 100644 windows/tauri/src/features/database/lib/query-result-export.ts delete mode 100644 windows/tauri/src/features/database/lib/query-result-pagination.ts delete mode 100644 windows/tauri/src/features/database/lib/query-result-summary.ts delete mode 100644 windows/tauri/src/features/database/lib/sql-completions.ts delete mode 100644 windows/tauri/src/features/database/lib/sql-highlight.ts delete mode 100644 windows/tauri/src/features/database/lib/sql-history-storage.ts delete mode 100644 windows/tauri/src/features/database/lib/sql-history.ts delete mode 100644 windows/tauri/src/features/database/lib/sql-selection.ts delete mode 100644 windows/tauri/src/features/database/providers/duckdb/duckdb-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/duckdb/stores/duckdb.store.ts delete mode 100644 windows/tauri/src/features/database/providers/mongodb/mongodb-pagination.ts delete mode 100644 windows/tauri/src/features/database/providers/mongodb/mongodb-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/mongodb/stores/mongodb.store.ts delete mode 100644 windows/tauri/src/features/database/providers/mysql/mysql-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/mysql/stores/mysql.store.ts delete mode 100644 windows/tauri/src/features/database/providers/postgres/components/create-subscription-dialog.tsx delete mode 100644 windows/tauri/src/features/database/providers/postgres/components/create-subscription-form.ts delete mode 100644 windows/tauri/src/features/database/providers/postgres/components/postgres-subscription-schema-view.tsx delete mode 100644 windows/tauri/src/features/database/providers/postgres/postgres-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/postgres/stores/postgres.store.ts delete mode 100644 windows/tauri/src/features/database/providers/provider-registry.ts delete mode 100644 windows/tauri/src/features/database/providers/redis/redis-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/redis/stores/redis.store.ts delete mode 100644 windows/tauri/src/features/database/providers/sql/sql-database-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/sql/sql-provider-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/sql/stores/create-sql.store.ts delete mode 100644 windows/tauri/src/features/database/providers/sqlite/sqlite-viewer.tsx delete mode 100644 windows/tauri/src/features/database/providers/sqlite/stores/sqlite.store.ts delete mode 100644 windows/tauri/src/features/database/services/database-provider-sidecar.ts delete mode 100644 windows/tauri/src/features/database/stores/connection.store.ts delete mode 100644 windows/tauri/src/features/database/types/common.types.ts delete mode 100644 windows/tauri/src/features/database/types/provider.types.ts delete mode 100644 windows/tauri/src/features/database/utils/clipboard.ts delete mode 100644 windows/tauri/src/features/database/utils/create-table-form.ts delete mode 100644 windows/tauri/src/features/database/utils/data-grid-selection.ts delete mode 100644 windows/tauri/src/features/database/utils/database-file-drop.ts delete mode 100644 windows/tauri/src/features/database/utils/value-coercion.ts delete mode 100644 windows/tauri/src/features/database/utils/workspace-database-files.ts diff --git a/.agents/notes/implemented/feature/2026-10-22-database-multi-schema-tree.md b/.agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md similarity index 98% rename from .agents/notes/implemented/feature/2026-10-22-database-multi-schema-tree.md rename to .agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md index b14016710..f5b794454 100644 --- a/.agents/notes/implemented/feature/2026-10-22-database-multi-schema-tree.md +++ b/.agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md @@ -1,6 +1,11 @@ # Agent 笔记:MySQL 连接下的多数据库树 状态:已实现 +归档日期:2026-10-24 + +## 归档原因 + +macOS 内置数据库模块已删除,数据库改由官方插件 `dev.lithe.plugin.database` 提供,多数据库树和“显示哪些库”的选择在插件网页里重新实现。本 Note 描述的 SwiftUI 侧栏、`DatabaseFeatureModel` 和对应测试都已不存在。当前决策见[数据库拆成双端共用的独立插件](../../implemented/architecture/2026-10-24-database-provider-plugin.md)。 ## 先说结论 diff --git a/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md index b437606cd..1b835157e 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md @@ -39,7 +39,6 @@ Swift 源码,也不得依赖 macOS 类型。 | `Plugins/win/` | Windows 所有的插件包 | | `frontend/editor/` | 两端共同依赖的 Monaco 表现层、分词与编辑器模型;不调用平台 API | | `shared/` | 跨平台契约和夹具,不放编译实现 | -| `infra/` | 仓库级开发和验证基础设施 | | `third_party/` | 固定版本的上游清单和必要的局部源码补丁 | 应用层依赖方向如下: @@ -272,5 +271,4 @@ Core 操作会随协议演进持续增加,逐个新增 Tauri command 会在 - `shared/` - `frontend/editor/` - `scripts/` -- `infra/` - `third_party/` diff --git a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md b/.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md similarity index 67% rename from .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md rename to .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md index 401fa80cd..f2f8f5a77 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md @@ -1,14 +1,14 @@ # Agent 笔记:数据库拆成双端共用的独立插件 -状态:提议中 +状态:已实现 ## 先说结论 -数据库连接功能将从 Lithe 主仓库和主安装包中移出,做成一个独立仓库的官方插件。用户只装 IDE 时,看不到数据库入口;从插件市场安装后才能使用。插件的界面是包内网页,后端用 Rust 编写,macOS 和 Windows 共用同一份代码。 +数据库连接功能已经从 Lithe 主仓库和主安装包中移出,成为组织仓库 `Lithe-IDEA/lithe-plugin-database` 中的官方插件 `dev.lithe.plugin.database`。只装 IDE 时没有任何数据库入口;从插件市场安装后才能使用。插件的界面是包内网页,后端用 Rust 编写,macOS 和 Windows 将共用同一份代码。 -主仓库不再保留任何数据库专属代码,只提供通用的插件能力:不依附构建系统的插件后端、宿主凭据存储,以及之后经由 Lithe 自带 MCP 暴露的插件 Agent 工具。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 +主仓库只保留通用的插件能力:不依附构建系统的插件后端、宿主凭据存储,以及一个过渡垫片——把旧版内置模块保存的连接、历史和密码迁入插件,并提示老用户安装。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 -这次评估还发现,数据库模块已经偏离“两套界面、一个核心”:同一条 SQL 安全规则写了三份并且不一致,Windows 界面从导入起就没有后端。插件化之后,这些规则只在插件后端保留一份。 +开发时记住一条:主仓库里不再为“数据库”写任何专属代码。SQL 判定、界面、驱动都只改插件仓库;主仓库只改通用的插件能力。 ## 问题 @@ -55,12 +55,12 @@ ### 现有插件契约为什么装不下 -- **网页界面只能操作构建系统。** 网页桥接对象(`window.lithe`)只能操作本插件声明的构建系统;插件函数在 macOS 上也只由构建 provider 的会话提供。数据库插件只能伪装成构建系统,违反[公共扩展契约](2026-10-13-public-extension-contract-and-provider-api.md)的“不要伪造声明”。 +- **网页界面只能操作构建系统。** 网页桥接对象(`window.lithe`)只能操作本插件声明的构建系统;插件函数在 macOS 上也只由构建 provider 的会话提供。数据库插件只能伪装成构建系统,违反[公共扩展契约](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md)的“不要伪造声明”。 - **插件拿不到宿主的凭据存储。** 插件调用宿主服务(`lithe/host/*`)在协议 v1 中全部被拒绝,没有地方安全地保存密码。 - **Agent 用不上数据库能力。** `lithe-db-mcp` 只被打包进 `Contents/Helpers`,没有任何代码启动或登记它,Agent 实际调用不到。Lithe 已有自己的 `lithe` MCP(`lithe-mcp` 加 IDE 宿主的能力目录和项目授权),插件能力应当从这里进入,而不是再起一个独立 MCP 进程。 - **Windows 还没有声明式插件宿主和插件市场。** 声明式插件宿主正在 #1424 中开发;插件市场目前只有 macOS 版。 -## 提案 +## 决策 ### 一、最终用户看到什么 @@ -84,7 +84,7 @@ - 使用官方 ID(`dev.lithe.plugin.database`),带 Lithe 发布者签名。原因是插件会经手用户的数据库密码,不能使用不需要 Lithe 签名的社区插件身份。 - 签名在组织仓库的发布 CI 中完成:发布者私钥只作为插件仓库的 CI secret 存在,插件自己出包、签名、登记索引;主仓库的发布流程不参与插件出包。 - 每个版本按平台发布下载包(`darwin-arm64`、`darwin-x64`,Windows 就绪后再加 Windows 的包),在插件索引里登记。网页界面和清单在各平台通用,只有 Rust 可执行文件按平台编译。 -- 构建阶段固定依赖并签名,规则沿用[LSP 插件构建与资源归属](../../implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。安装目录只读,运行状态写进平台存储。 +- 构建阶段固定依赖并签名,规则沿用[LSP 插件构建与资源归属](2026-09-30-lsp-plugin-build-and-distribution.md)。安装目录只读,运行状态写进平台存储。 ### 四、主仓库要新增的通用能力 @@ -95,7 +95,7 @@ - **按需启动、随插件结束。** 本插件网页第一次调用 `functions.*` 时启动;插件停用、更新、卸载或宿主退出时结束。 - **授权就是安装审查。** 后端在安装审查中以 `packagedBackend` 等条目列出,调用时不再按工作区弹同意框;带 `instance` 的构建 provider 调用仍沿用原来的同意规则。 - **不承担领域契约。** 后端只回答插件函数,不回答 `lithe/build/*` 或 VCS 请求,避免再次“伪装成构建系统”。 - - 网页桥接 `functions.list` / `functions.invoke` 省略 `instance` 即调用本插件后端,复用现有[插件函数协议](../../implemented/architecture/2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。 + - 网页桥接 `functions.list` / `functions.invoke` 省略 `instance` 即调用本插件后端,复用现有[插件函数协议](2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。 2. **宿主凭据存储(插件函数协议 v2,已在契约中实现)。** v2 是第一个提供宿主服务的协议版本:`lithe/host/credentials/{get,set,delete}`,需要 `credentials.store` 权限(没有后端时声明即无效)。Core 在宿主看到请求前校验协议版本、授权和参数(key 只允许 `[A-Za-z0-9._:-]` 且不超过 128 字节,secret 不超过 16 KiB,拒绝未知字段以便后续版本扩展),通过 `hostRequest` 事件交给宿主,宿主用 `lsp.hostResponse` 应答;每个会话最多 16 个未应答请求。macOS 使用 Keychain,按插件 ID 划分命名空间;插件读不到别的插件或宿主自身的凭据。生命周期:停用和更新时保留,卸载时与 `${stateDir}` 一起删除。 3. **Agent 工具经由 `lithe` MCP(后续)。** 不新增“插件声明 MCP 服务”的契约,也不保留独立的 `lithe-db-mcp` 进程。之后由插件以插件函数的形式提供 Agent 工具,IDE 宿主把它们登记进现有 `lithe` MCP 的能力目录,沿用其项目授权;插件停用时工具随之消失。这一项在数据库插件上线后单独设计,不阻塞第 1 到第 5 期。 4. **按平台的可执行文件:不改契约。** 每个平台单独出下载包,可执行文件放在包内固定路径(例如 `bin/backend`),macOS 包可以是 universal 二进制。`${arch}` 本来就不能用于 `packaged` 路径,这样也不需要。Windows 的 `.exe` 等 Windows 宿主就绪时再定。 @@ -109,15 +109,18 @@ ### 六、老用户迁移 -- 现有 macOS 用户的连接配置在宿主的偏好存储里,密码在 Keychain 里。插件首次启用时,宿主把这些数据迁入插件私有的存储和凭据范围,只迁移一次,并保留迁移记录。 -- 升级到不再内置数据库的 Lithe 版本后,如果检测到已保存的连接,宿主提示一次:“数据库已改为插件,点击安装”。不自动在后台下载。 -- 迁移只在插件版本和主程序版本都就绪后进行;在此之前,不从主包删除数据库功能。 +- 迁移由宿主的过渡垫片 `MacDatabasePluginMigration` 完成。它挂在插件后端的启动路径上(`MacPluginBackendSessions` 的 `prepareState`),在数据库插件后端第一次启动、读取状态之前运行,所以插件永远不会先看到空状态。 +- 迁移内容:偏好里的连接(`database.profiles.v1`)、文件夹(`database.connection-folders.v1`)和 SQL 历史(`database.sql-history.v1`)写进插件的 `${stateDir}`(`connections.json`、`sql-history.json`);“永久”密码从旧 Keychain 服务移到插件凭据(key 为 `connection::password`)。旧的 `group` 字段变成顶层文件夹;历史只保留属于现存连接的最近 100 条,时间从 2001 年起算的秒换成 1970 年起算的毫秒。“直到重启”的密码本来就不落盘,不迁移。 +- 顺序有讲究:先迁密码,再写历史,最后写 `connections.json`,然后才写迁移标记。这样任何一步失败,下次启动都会重试;`connections.json` 的存在表示插件已有自己的状态,之后永远不会被覆盖。迁不动的密码只记日志(不含密码本身),对应连接改为重新询问。 +- 旧数据原样保留,旧版 Lithe 仍能读到。备份计划和恢复点没有迁移,因为旧模块里没有任何界面能列出或使用它们。 +- 升级后,保存过连接、又没装插件的用户会看到一次通知“数据库已改为插件……”,带“打开插件市场”按钮(`DatabasePluginTransition`、`AppModel+DatabasePlugin`)。不自动在后台下载。通知按钮是通用能力:带动作的工作台通知会一直保留到用户处理。 +- 垫片同时知道旧模块和插件的存储格式,这是有意为之的过渡代码;等所有受支持的 Lithe 版本都不再内置数据库模块后删除。 ### 七、Windows -- **现在就做:** 功能矩阵把 Windows 的数据库能力改为 `missing`;停止把 `.db`、`.sqlite`、`.duckdb` 文件交给数据库查看器;README 注明数据库目前只支持 macOS。 +- 功能矩阵中 Windows 的数据库能力为 `missing`;`.db`、`.sqlite`、`.duckdb` 文件按普通文件打开。 - **暂不适配:** 这次改造不为 Windows 做任何插件侧适配,也不要求 #1424 现在对齐。等 Windows 插件宿主和插件市场改造完成后,再按当时的宿主能力小幅调整插件逻辑,补 Windows 构建、WebView2 回归测试和发布。 -- 现有 9400 行 Windows 数据库前端不迁移,跟随主仓库的数据库代码一起删除。暂不支持 DuckDB。 +- 原有约 9400 行 Windows 数据库前端没有迁移,已随主仓库的数据库代码一起删除。暂不支持 DuckDB。 ### 八、分期 @@ -130,7 +133,7 @@ | 5 | macOS 发布插件,加入迁移提示,从主仓库删除数据库代码 | 第 4 期 | | 6 | Windows 插件宿主和插件市场就绪后,按当时的宿主能力小幅调整插件,补 Windows 构建后发布 | Windows 插件市场 | -第 1 到第 5 期不需要等待 Windows,全部在同一个 PR 中分步提交。第 6 期只做小幅调整和 Windows 构建,插件本体不重写。 +第 1 到第 5 期不需要等待 Windows,已在同一个 PR(#1470)中分步提交。第 6 期只做小幅调整和 Windows 构建,插件本体不重写。 ### 九、远程开发 @@ -166,23 +169,22 @@ 它最吸引人的地方是最快就能减小体积。不采用,因为它不解决规则多份和双端不一致的问题,Windows 也用不上,迟早要返工。 -## 验收标准 +## 后果 -- 只安装 Lithe 时没有任何数据库入口;`Lithe.app` 中不含数据库程序和界面代码;主仓库中搜不到数据库专属模块。 -- 从插件市场安装后,现有连接、查询、编辑、导入导出、结构对比、Redis 和 Nacos 功能可用。老用户的连接和密码完成迁移。 -- 停用或卸载后,入口消失,插件进程结束。停用后重新启用、或更新插件后,凭据和连接仍在;卸载后凭据和 `${stateDir}` 一并删除。 -- 插件仓库中只有一份 SQL 危险判定。换行写的 `DELETE … WHERE` 可以直接执行,不带 `WHERE` 的删除会弹出确认框。 -- 插件后端和凭据宿主服务的通用样例在 Rust Core 测试中通过;Windows 宿主实现后,同一批样例在 Windows 上也通过。 -- `scripts/verify-runtime-bundle-immutability.sh` 覆盖插件安装和典型数据库工作流,发行目录哈希不变。 -- Windows 宿主就绪后,同一插件版本只增加 Windows 下载包即可安装使用。 +- `Lithe.app` 不再包含 `lithe-db-sidecar`、`lithe-db-mcp`、数据库图标和数据库界面代码;主仓库删除了 `LitheDatabaseModule`、`Views/Database/`、两个 Rust crate、Windows 的 `features/database/`、数据库 CI 工作流与分流、打包脚本中的数据库部分和对应测试。 +- **发布顺序是硬约束:** 主程序删除数据库后,老用户只能从插件找回功能。所以插件的第一个版本必须先在插件仓库发布,并在 `Plugins/marketplace/index.json` 登记,之后才能发布不含数据库的 Lithe。 +- **签名私钥:** 私钥放在插件仓库 `release` 环境的受保护 secret 中,只有 `v*` 标签触发并经审批的发布工作流能读取,PR 构建接触不到。泄露面因此扩大到插件仓库的发布流程,需要维护者审批来兜底。 +- **网页界面体验:** macOS 上不再是原生 SwiftUI。插件使用宿主注入的主题变量和语言上下文贴近宿主风格;数据表格和 SQL 编辑器由插件自带,包体积和性能需要随版本实测。 +- **Windows 暂时没有数据库功能:** 第 6 期依赖 Windows 插件宿主和插件市场;在此之前 Windows 用户看不到数据库入口(以前的入口本来就不能用)。 +- Agent 用的数据库工具暂时没有。以后由插件以插件函数提供,登记进现有 `lithe` MCP,不再单独起 MCP 进程。 -## 风险 +## 验证 -- **Windows 进度:** 第 6 期依赖 Windows 插件宿主和插件市场。插件只依赖公开契约,届时按 Windows 宿主的实际能力调整,不预先适配。 -- **网页界面体验:** macOS 上不再是原生 SwiftUI。要使用宿主注入的主题和语言上下文,尽量贴近宿主风格;数据表格和 SQL 编辑器要在插件里自带组件,包体积和性能需要实测。 -- **凭据能力的安全边界:** 宿主凭据是第一项宿主能力,权限、隔离和审查必须先写进契约和样例,不能先实现后补。 -- **签名私钥:** 私钥放在组织仓库 CI secret 中,泄露面扩大到插件仓库的 CI。只允许发布工作流在受保护分支和标签上读取它,PR 构建不能接触私钥。 -- **升级断档:** 第 5 期必须同时具备插件、迁移和提示;任何一项没有完成,都不能从主包删除数据库。 +- `./scripts/test-macos.sh --filter "MacDatabasePluginMigrationTests|MacPluginBackendSessionsTests|WorkbenchNotificationTests|PluginManagerTests"`:迁移的内容、格式、只执行一次、不覆盖插件自有状态、密码迁移失败的处理;迁移先于后端启动;带动作的通知和一次性安装提示;目录中的官方插件条目。 +- `./scripts/verify-module-boundaries.sh` 与 `./scripts/verify-shared-contracts.sh`:内置模块列表和共享模块样例中不再有数据库模块。 +- `bash scripts/test-classify-ci-changes.sh` 与 `node --test scripts/ci-workflow-gates.test.mjs`:CI 分流不再有数据库通道。 +- `./scripts/verify-runtime-bundle-immutability.sh`:运行时不向发行目录写入。 +- 插件仓库的 `node scripts/e2e.mjs`:用 Lithe 的 Rust Core 启动打包好的插件后端,回答凭据请求,走完 SQLite 连接的全部页面函数,并确认安装包未被改写、状态目录只有 `connections.json` 和 `sql-history.json`。 ## 已定决策 @@ -208,28 +210,21 @@ - 按平台可执行文件不改契约,各平台下载包使用固定路径; - Agent 工具之后经由现有 `lithe` MCP 暴露,不单独起 MCP 进程; - 网页界面全新编写(TypeScript,符合 CSP 的打包产物),参照 macOS 现有功能; -- 老用户迁移由宿主侧的过渡垫片完成,第 5 期设计。 - -## 待确认 - -- 无。 +- 老用户迁移由宿主侧的过渡垫片完成,挂在插件后端启动路径上,只迁移一次。 ## 适用范围 -- `rust/lithe-db-sidecar/` -- `rust/lithe-db-mcp/` -- `macos/Sources/LitheDatabaseModule/` -- `macos/Sources/Lithe/Views/Database/` -- `macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift` - `macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift` -- `macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift` -- `windows/tauri/src/features/database/` -- `windows/tauri/src/extensions/tooling/package-database-sidecars.ts` -- `windows/tauri/src/features/file-system/controllers/file-utils.ts` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift` +- `macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostServices.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift` - `shared/contracts/plugins/v1/` - `rust/lithe-core/src/plugins/backend.rs` - `rust/lithe-core/src/lsp/interface/plugin_function.rs` - `shared/platform-feature-matrix/features/` -- `scripts/package-app.sh` +- `Plugins/marketplace/index.json` -相关 Note:[统一 VCS 模型与 Provider 接入](2026-10-22-unified-vcs-provider-model.md)(同类 provider 做法)、[模块运行时抽取路线](2026-09-13-module-runtime-extraction-roadmap.md)(数据库被列为适合插件化)、[插件自带网页界面](../../implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md)、[插件市场](../../implemented/feature/2026-10-12-plugin-marketplace.md)、[MySQL 连接下的多数据库树](../../implemented/feature/2026-10-22-database-multi-schema-tree.md)。 +相关 Note:[统一 VCS 模型与 Provider 接入](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)(同类 provider 做法)、[模块运行时抽取路线](../../proposed/architecture/2026-09-13-module-runtime-extraction-roadmap.md)(数据库被列为适合插件化)、[插件自带网页界面](2026-10-18-plugin-web-content-in-host-windows.md)、[插件市场](../feature/2026-10-12-plugin-marketplace.md)。 diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index a5907fd18..f1a4f1626 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -180,10 +180,10 @@ macOS runner(GitHub 托管的 Mac 构建机)按组织共享,Free 计划同 5 个 macOS job,超出的 job 只能排队。排队不是配额耗尽,也不是滥用信号, 但会拖慢所有 PR 的反馈。因此: -- 主 Swift 测试和数据库 Swift 测试共用 macOS CI 的 **Swift tests** job,只执行 +- 主 Swift 测试和 Rust Core 桥接检查共用 macOS CI 的 **Swift tests** job,只执行 一次 `./scripts/test-macos.sh list` 编译,再按分类结果运行各自的过滤测试。 - 过去数据库 job 单独编译约 10 分钟,只为运行十几秒测试。Database CI 只保留 - Linux 上的 Rust 数据库测试和 gate。 + 过去独立的数据库 job 单独编译约 10 分钟,只为运行十几秒测试;数据库改为插件 + 后,这条 lane 和 Database CI 已一起删除。 - 远程工作区的三个 crate(`lithe-remote-*`)走独立的 Remote Workspace CI,只在 Linux 上运行。Linux runner 能执行服务的 Linux 适配器,同时交叉检查 macOS 目标, 所以不需要为它申请 macOS runner;这些 crate 暂未被产品链接,改动它们也不会触发 @@ -356,11 +356,11 @@ macOS CI 失败来修复。 插件过滤测试和官方插件打包验证串进主 Swift job,延长包含插件改动的 PR 的 主反馈路径,因此插件 CI 保持独立并依赖分类器跳过。 -### 用 workflow 的 `paths` 过滤跳过插件或数据库 CI +### 用 workflow 的 `paths` 过滤跳过插件 CI -`Plugin CI gate` 和 `Database CI gate` 是 `preview` ruleset 的必需检查。 -workflow 被 `paths` 过滤掉后不会上报状态,PR 会一直等待这个检查,因此继续 -使用“Linux 分类 job + gate”的方式:未选中时 gate 在 Linux 上几秒内通过。 +gate 检查一旦被设为 `preview` ruleset 的必需检查,workflow 被 `paths` 过滤掉后 +就不会上报状态,PR 会一直等待这个检查,因此继续使用“Linux 分类 job + gate”的 +方式:未选中时 gate 在 Linux 上几秒内通过。 ### 为所有 CI 任务强制上传完整安装包 @@ -403,7 +403,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 ## 验证 -- `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml .github/workflows/ci-database.yml .github/workflows/ci-plugins.yml .github/workflows/ci-remote.yml` +- `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml .github/workflows/ci-plugins.yml .github/workflows/ci-remote.yml` - `./scripts/test-classify-ci-changes.sh`:包含 Draft PR 关闭 runner lane、保留 Linux lane,以及 macOS Java 不触发 real-JDT、共享引擎和 Windows Java 仍触发的正反例。 - `node --test scripts/ci-workflow-gates.test.mjs scripts/plan-ci-lane-reuse.test.mjs scripts/verify-reused-ci-jobs.test.mjs`:从实际工作流执行 gate shell,验证取消条件、分类/测试失败、lane 覆盖及复用来源的失败传播。 - `node --test scripts/cancel-closed-pr-runs.test.mjs`:用离线 GitHub CLI 替身执行关闭清理,验证分页历史运行、来源隔离、关闭时间、取消竞争和 API 失败传播。 @@ -429,7 +429,6 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `docs/ci-builds.md` - `.github/workflows/ci-macos.yml` - `.github/workflows/ci-windows.yml` -- `.github/workflows/ci-database.yml` - `.github/workflows/ci-remote.yml` - `.github/workflows/ci-plugins.yml` - `scripts/classify-ci-changes.sh` diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 47ca402d9..47f6b9ef1 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -1930,9 +1930,6 @@ JDT Run marker 的测试元数据,不根据文件名包含 Test 推断。深 数据库范围列表复用共享树样式,不使用普通菜单行。Git 原有的 IDEA 三态复选框提升为 `LitheTreeCheckbox`,仍消费原 `commit/checkBox*.svg` 的完整深浅/焦点/禁用状态,Git 调用显式传入提交语义,Run 浏览器沿用批量选择语义;数据库通过可选标题组成完整可点击树行。`LitheSettingsCheckbox` 保持设置页原行为,不增加菜单专用变体。范围列表使用 `litheTreeRow` 与共享树边距,底部说明复用弹层背景;整个选择面板局部禁用小手,入口徽标保留小手且没有悬停提示。数据库宿主回归在库名右侧空白发送真实鼠标事件,Git 原资源检查继续覆盖全部状态。 -### 2026-10-23 结果气泡保留宿主、独立绘制表面 - -数据库测试结果并非菜单。`LitheDropdownPopover.contentProvidesSurface` 默认为 false,仅该结果气泡显式启用,跳过 `litheContextMenuSurface` 的背景/圆角/字体包装;仍复用现有 presenter 的定位、父子窗口、键盘/外部点击关闭与销毁。不改普通菜单背景或全局圆角,不复制窗口生命周期。气泡的 Community 来源、locale 字体分支与截图适配边界见[数据库多库树 Note](../feature/2026-10-22-database-multi-schema-tree.md)。用 ContextMenuCoverageTests 验证默认调用者,数据库深浅主题像素检查验证背景、边框、指针和透明底带。 ### 公共树的展开入口补齐(2026-10-21) 项目树与声明式插件树共用 `LitheTreeDisclosureIcon`,由它持有原始 chevron SVG、 diff --git a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md index deaa09ca0..95e8f1015 100644 --- a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md @@ -82,7 +82,6 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 - `macos/Sources/LitheApplicationKernel/` - `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` - `macos/Sources/Lithe/Views/Workbench/` -- `macos/Sources/Lithe/Views/Database/` - `windows/` ## 跟踪任务 diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index e29f08ad8..80353f17a 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -330,7 +330,7 @@ the existing stack can reasonably avoid. - Keep selection distinct from checkbox inclusion, chosen settings values, status badges, activity-rail buttons, menu highlight, tool-window tabs and editor text selection. Those retain their owning styles; tool-window tabs - use `LitheToolWindowTabStyle`, tables use `DatabaseGrid` tokens. Do not add + use `LitheToolWindowTabStyle`. Do not add persistent selection to a view that does not already have it. Verify dark/light focused/inactive rows, focus moving to a neighboring list or editor, whole window deactivation and reactivation, keyboard navigation, and shared field @@ -396,7 +396,7 @@ The decision and owning components are recorded in #### Rust Core comments Apply the following comment standard to first-party code under -`rust/lithe-core/`. It does not require comment coverage in the database helpers, +`rust/lithe-core/`. It does not require comment coverage in other helper crates, Windows/Tauri Rust crates, generated code, or third-party sources. - Write comments in English and keep them accurate when behavior changes. diff --git a/.github/actions/prepare-macos-dependency-cache/action.yml b/.github/actions/prepare-macos-dependency-cache/action.yml index c7cc77577..d30008897 100644 --- a/.github/actions/prepare-macos-dependency-cache/action.yml +++ b/.github/actions/prepare-macos-dependency-cache/action.yml @@ -228,7 +228,7 @@ runs: env: KEY_PREFIX: macos-cargo-build-v1-${{ runner.arch }}-${{ steps.cargo_build_identity.outputs.identity }} BUILD_ARCHITECTURE: ${{ inputs.cargo-build-architecture }} - SOURCE_HASH: ${{ hashFiles('rust/Cargo.lock', 'rust/**/Cargo.toml', 'rust/.cargo/**', 'scripts/build-rust-core.sh', 'scripts/build-database-*.sh') }} + SOURCE_HASH: ${{ hashFiles('rust/Cargo.lock', 'rust/**/Cargo.toml', 'rust/.cargo/**', 'scripts/build-rust-core.sh', 'scripts/build-ide-mcp.sh') }} run: | echo "prefix=$KEY_PREFIX" >> "$GITHUB_OUTPUT" echo "key=$KEY_PREFIX-$BUILD_ARCHITECTURE-$SOURCE_HASH" >> "$GITHUB_OUTPUT" @@ -243,7 +243,7 @@ runs: continue-on-error: true uses: actions/cache/restore@v6 with: - # Core uses target/macos; database helpers use target directly. Include + # Core uses target/macos; the MCP helper uses target directly. Include # both layouts, while leaving final executables for Cargo to regenerate. path: | rust/target/**/.fingerprint diff --git a/.github/workflows/ci-database.yml b/.github/workflows/ci-database.yml deleted file mode 100644 index e44a14a04..000000000 --- a/.github/workflows/ci-database.yml +++ /dev/null @@ -1,145 +0,0 @@ -# Registered by the standalone preview repository. -name: Database CI - -on: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: database-ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - changes: - name: Classify changes - runs-on: ubuntu-latest - outputs: - rust_database: ${{ steps.classify.outputs.rust_database }} - - steps: - - name: Check out source - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Resolve comparison base - id: base - env: - EVENT_NAME: ${{ github.event_name }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PUSH_BASE_SHA: ${{ github.event.before }} - shell: bash - run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "manual=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - base_sha="$PUSH_BASE_SHA" - if [[ "$EVENT_NAME" == "pull_request" ]]; then - base_sha="$PR_BASE_SHA" - fi - { - echo "manual=false" - echo "base_sha=$base_sha" - } >> "$GITHUB_OUTPUT" - - - name: Classify changed paths - id: classify - env: - MANUAL: ${{ steps.base.outputs.manual }} - BASE_SHA: ${{ steps.base.outputs.base_sha }} - BASE_REF: ${{ github.base_ref }} - HEAD_REF: ${{ github.head_ref }} - HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - shell: bash - run: | - if [[ "$MANUAL" == "true" ]]; then - echo "rust_database=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Promoting preview to main reruns no lane; macOS CI requires the - # preview verification of the same commit instead. - if [[ "$BASE_REF" == "main" && "$HEAD_REF" == "preview" && "$HEAD_REPOSITORY" == "$GITHUB_REPOSITORY" ]]; then - export LITHE_CI_SCOPE=release_promotion - fi - ./scripts/classify-ci-changes.sh "$BASE_SHA" "$GITHUB_SHA" >> "$GITHUB_OUTPUT" - - - name: Summarize selected lanes - shell: bash - env: - RUST_DATABASE: ${{ steps.classify.outputs.rust_database }} - run: | - { - echo "### Database CI lanes" - echo "| Lane | Run |" - echo "| --- | --- |" - echo "| Rust database tests | $RUST_DATABASE |" - echo "" - echo "Swift database tests run in **macOS CI → Swift tests**; check **macOS CI gate** for their result." - } >> "$GITHUB_STEP_SUMMARY" - - rust-database-tests: - name: Rust database tests - needs: changes - if: needs.changes.outputs.rust_database == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Check out source - uses: actions/checkout@v7 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Restore Cargo dependencies - uses: actions/cache@v6 - with: - path: | - ~/.cargo/git - ~/.cargo/registry - key: cargo-dependencies-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }} - - - name: Validate database container configuration - run: docker compose -f infra/docker/database-validation/compose.yaml config --quiet - - - name: Check formatting and test database crates - run: | - cargo fmt --manifest-path rust/Cargo.toml -p lithe-db-sidecar -p lithe-db-mcp -- --check - cargo test --manifest-path rust/Cargo.toml -p lithe-db-sidecar - cargo test --manifest-path rust/Cargo.toml -p lithe-db-mcp - - gate: - name: Database CI gate - needs: - - changes - - rust-database-tests - if: always() && !cancelled() - runs-on: ubuntu-latest - - steps: - - name: Verify required jobs - env: - CHANGES_RESULT: ${{ needs.changes.result }} - RUST_DATABASE_SELECTED: ${{ needs.changes.outputs.rust_database }} - RUST_DATABASE_RESULT: ${{ needs.rust-database-tests.result }} - shell: bash - run: | - [[ "$CHANGES_RESULT" == "success" ]] - require_selected_job() { - local selected="$1" - local result="$2" - if [[ "$selected" == "true" ]]; then - [[ "$result" == "success" ]] - else - [[ "$result" == "skipped" ]] - fi - } - require_selected_job "$RUST_DATABASE_SELECTED" "$RUST_DATABASE_RESULT" diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e095276d3..48c414021 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -32,7 +32,6 @@ jobs: actions: write outputs: swift: ${{ steps.classify.outputs.swift }} - swift_database: ${{ steps.classify.outputs.swift_database }} rust_core: ${{ steps.classify.outputs.rust_core }} macos_release: ${{ steps.classify.outputs.macos_release }} rust_comments: ${{ steps.classify.outputs.rust_comments }} @@ -123,7 +122,6 @@ jobs: { echo "ci_scope=pull_request" echo "swift=true" - echo "swift_database=true" echo "rust_core=true" echo "macos_release=true" echo "rust_comments=false" @@ -175,7 +173,6 @@ jobs: LITHE_CI_CLASSIFICATION: | ci_scope=${{ steps.classify.outputs.ci_scope }} swift=${{ steps.classify.outputs.swift }} - swift_database=${{ steps.classify.outputs.swift_database }} rust_core=${{ steps.classify.outputs.rust_core }} macos_release=${{ steps.classify.outputs.macos_release }} shell: bash @@ -215,7 +212,6 @@ jobs: shell: bash env: SWIFT: ${{ steps.classify.outputs.swift }} - SWIFT_DATABASE: ${{ steps.classify.outputs.swift_database }} RUST_CORE: ${{ steps.classify.outputs.rust_core }} DRAFT: ${{ github.event.pull_request.draft == true }} MACOS_RELEASE: ${{ steps.classify.outputs.macos_release }} @@ -229,7 +225,6 @@ jobs: echo "| Lane | Run |" echo "| --- | --- |" echo "| Swift tests | $SWIFT |" - echo "| Swift database tests | $SWIFT_DATABASE |" echo "| Rust Core tests | $RUST_CORE |" echo "| Release package verification | $MACOS_RELEASE |" echo "| Rust comment verification | $RUST_COMMENTS |" @@ -241,10 +236,9 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" - # The main and database Swift suites share one test build. A separate database - # job spent about ten minutes compiling the same products for seconds of - # filtered tests and held another of the five organization macOS runners. - # The Rust Core bridge checks reuse the same build for the same reason. + # The Rust Core bridge checks reuse the Swift test build: a separate job + # spent about ten minutes compiling the same products and held another of + # the five organization macOS runners. # Plugin tests stay in macOS Plugin CI so that unrelated changes skip them. swift-tests: name: Swift tests @@ -300,7 +294,6 @@ jobs: --skip StableRollbackDiskImageIntegrationTests \ --skip GitStatusObservationTests \ --skip LitheGitPerformanceTests \ - --skip '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' \ --skip '([Pp]lugin|LinuxDo)' - name: Run stable rollback disk image integration test @@ -341,11 +334,6 @@ jobs: --skip-build \ --filter StableRollbackDiskImageIntegrationTests - - name: Run database-focused Swift tests - if: needs.changes.outputs.swift_database == 'true' - timeout-minutes: 17 - run: ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/macos-database.json --suite-timeout-seconds 960 -- --skip-build --filter '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' - # A failed build cannot produce reports; the build step already fails the # job, so do not stack a second "no reports found" failure on top of it. - name: Generate combined Swift test report @@ -519,8 +507,7 @@ jobs: for executable in \ "$app_path/Contents/MacOS/Lithe" \ "$app_path/Contents/Frameworks/Sparkle.framework/Sparkle" \ - "$app_path/Contents/Helpers/lithe-db-sidecar" \ - "$app_path/Contents/Helpers/lithe-db-mcp"; do + "$app_path/Contents/Helpers/lithe-mcp"; do test -x "$executable" lipo "$executable" -verify_arch "${architectures[@]}" done @@ -591,7 +578,7 @@ jobs: RUN_SWIFT: ${{ needs.changes.outputs.run_swift }} RUN_RUST: ${{ needs.changes.outputs.run_rust }} RUN_RELEASE: ${{ needs.changes.outputs.run_release }} - SWIFT_SELECTED: ${{ needs.changes.outputs.swift == 'true' || needs.changes.outputs.swift_database == 'true' || needs.changes.outputs.rust_core == 'true' }} + SWIFT_SELECTED: ${{ needs.changes.outputs.swift == 'true' || needs.changes.outputs.rust_core == 'true' }} RUST_CORE_SELECTED: ${{ needs.changes.outputs.rust_core }} RELEASE_SELECTED: ${{ needs.changes.outputs.macos_release }} REUSE: ${{ needs.changes.outputs.reuse }} diff --git a/Package.swift b/Package.swift index dfa5cec9e..adc8033a9 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,6 @@ let package = Package( .library(name: "LitheSearchModule", targets: ["LitheSearchModule"]), .library(name: "LitheLocalHistoryModule", targets: ["LitheLocalHistoryModule"]), .library(name: "LitheTerminalModule", targets: ["LitheTerminalModule"]), - .library(name: "LitheDatabaseModule", targets: ["LitheDatabaseModule"]), .library(name: "LitheAIAssistanceModule", targets: ["LitheAIAssistanceModule"]), .library(name: "LitheAgentConversationModule", targets: ["LitheAgentConversationModule"]), .library(name: "LitheExecutionModule", targets: ["LitheExecutionModule"]), @@ -80,7 +79,6 @@ let package = Package( swiftSettings: [.swiftLanguageMode(.v6)] ), .target(name: "LitheTerminalModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheTerminalModule", swiftSettings: [.swiftLanguageMode(.v6)]), - .target(name: "LitheDatabaseModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheDatabaseModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheAIAssistanceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheAIAssistanceModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheAgentConversationModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheAgentConversationModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheExecutionModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheExecutionModule", swiftSettings: [.swiftLanguageMode(.v6)]), @@ -107,7 +105,6 @@ let package = Package( "LitheSearchModule", "LitheLocalHistoryModule", "LitheTerminalModule", - "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheExecutionModule", @@ -149,7 +146,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["LitheTestDesktopIsolation", "Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheExecutionModule", "LithePluginPackageSigning", .product(name: "Testing", package: "swift-testing")], + dependencies: ["LitheTestDesktopIsolation", "Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheExecutionModule", "LithePluginPackageSigning", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -215,12 +212,6 @@ let package = Package( path: "macos/Tests/LitheGitPerformanceTests", swiftSettings: [.swiftLanguageMode(.v6)] ), - .testTarget( - name: "LitheDatabaseModuleTests", - dependencies: ["LitheDatabaseModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], - path: "macos/Tests/LitheDatabaseModuleTests", - swiftSettings: [.swiftLanguageMode(.v6)] - ), .testTarget( name: "LitheLanguageIntelligenceModuleTests", dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], diff --git a/README.md b/README.md index c4b9784da..d2d8809c3 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Codex, Claude Code, and other AI coding tools can now handle much of the impleme Lithe is a lightweight IntelliJ IDEA alternative built first for Java and Spring Boot developers. It keeps the core workflows for browsing, editing, navigation, search, Maven, run and debug, Git diff review, local history, and databases. -The Lithe application typically uses about **300–400 MB of baseline memory** after opening a regular project. Language servers, terminals, build tools, debuggers, and database helpers start on demand. Actual usage varies with the project and active services. +The Lithe application typically uses about **300–400 MB of baseline memory** after opening a regular project. Language servers, terminals, build tools, and debuggers start on demand. Actual usage varies with the project and active services. > **AI writes the code. Lithe helps you understand it, run it, and review it.** @@ -102,7 +102,7 @@ Only use these steps for an app whose source you trust. Homebrew installations u 14. Automatic project entry-point detection with one-click run support for Spring Boot, Java, Maven, Gradle, npm, Cargo, Go, Python, Make, Docker Compose, Procfile, and shell projects. 15. Per-language service switches so language servers can be enabled or disabled independently to match the machine's resources. 16. Multi-line editor tabs for keeping more files visible in the same workspace. -17. Database connection workspace (currently macOS only) with multiple database types, connection management, SQL history, table browsing, and database operations. +17. Database connections as an official plugin (currently macOS only), installed from the Plugin Marketplace: multiple database types, connection management, SQL history, table browsing, and database operations. 18. Ongoing bug fixes and user experience improvements. @@ -145,11 +145,6 @@ Only use these steps for an app whose source you trust. Homebrew installations u

Multi-line editor tabs - Database connection workspace -

- -

- Database SQL operation and table structure

diff --git a/README.zh-CN.md b/README.zh-CN.md index 5810baaf9..eb838f164 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,7 +59,7 @@ Codex、Claude Code 等 AI 编程工具已经可以承担大量编码工作, Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替代品,保留项目浏览、代码编辑与导航、搜索、Maven、运行调试、Git Diff、本地历史和数据库等核心工作流。 -打开普通项目后,Lithe 应用本身的基础内存占用通常约为 **300~400 MB**。语言服务器、终端、构建工具、调试器和数据库组件均按需启动;实际占用会随项目和启用的服务变化。 +打开普通项目后,Lithe 应用本身的基础内存占用通常约为 **300~400 MB**。语言服务器、终端、构建工具和调试器均按需启动;实际占用会随项目和启用的服务变化。 > **AI 负责编写代码,Lithe 负责帮你看懂、跑通并审查修改。** @@ -102,7 +102,7 @@ Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替 14. 自动识别 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python、Make、Docker Compose、Procfile 和 Shell 项目的可运行入口,并支持一键运行。 15. 支持按语言独立开关语言服务,可根据电脑配置自由启用或停用。 16. 支持类似 IDEA 的多行编辑器标签,同时展示更多打开的文件。 -17. 新增数据库连接工作台(目前仅支持 macOS),支持多种数据库类型、连接管理、SQL 历史、表浏览和数据库操作。 +17. 数据库连接改为官方插件(目前仅支持 macOS),从插件市场安装,支持多种数据库类型、连接管理、SQL 历史、表浏览和数据库操作。 18. 持续修复问题并优化用户体验。 @@ -145,11 +145,6 @@ Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替

多行编辑器标签 - 数据库连接工作台 -

- -

- 数据库 SQL 操作和表结构

diff --git a/docs/assets/screenshots/database-sql-operation.png b/docs/assets/screenshots/database-sql-operation.png deleted file mode 100644 index ee4734d101328e80135501d1821932964855fe59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 217648 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%0Dy)S(H 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%0A?#Mr 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�(fW^60YOM5Nf1$ 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 G$NvX~vGd6Q diff --git a/docs/assets/screenshots/database-workspace-overview.png b/docs/assets/screenshots/database-workspace-overview.png deleted file mode 100644 index 11314019ea62c5c42a9a9f8d48672d1ff6fcb4a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 264499 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ŶJbpTyJYuwRFk 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 z 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 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index c09c956b6..dbc151d0a 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -31,10 +31,8 @@ an installer; Windows installers come from preview and stable release workflows. The summary records the exact checked-out revision. For a pull request this is normally GitHub's test merge commit. Check **macOS CI gate** or **Windows CI gate** for the combined test result. The macOS **Swift tests** job builds the -Swift tests once and runs the main and database suites selected by the -classifier, so **macOS CI gate** also owns the Swift database result; -**Database CI gate** checks the Linux Rust database lane and **Remote Workspace -CI gate** the Linux remote workspace lane (`scripts/verify-remote-workspace.sh`), +Swift tests once and runs the suites selected by the classifier; +**Remote Workspace CI gate** checks the Linux remote workspace lane (`scripts/verify-remote-workspace.sh`), which draft pull requests keep because it uses no macOS runner. **macOS Plugin CI** stays separate: it only reserves a macOS runner when the classifier selects the plugin lane, and otherwise **Plugin CI gate** passes on Linux in seconds. @@ -276,7 +274,7 @@ were already hitting, but macOS had no cache for compiled Rust dependencies. The macOS package CI and preview workflows now cache Cargo fingerprints, build script outputs, and dependency outputs for both `rust/target/macos` (Core) and -`rust/target` (database helpers). Keys include runner architecture, compiler, +`rust/target` (the `lithe-mcp` helper). Keys include runner architecture, compiler, Xcode/SDK/macOS versions, build flags, dependency manifests, and build scripts. An architecture-specific job can restore the universal cache from its base branch. Final executables are not cached; Cargo still runs before packaging. @@ -352,7 +350,7 @@ LITHE_RUN_GIT_COMPOSITOR_TESTS=1 \ Swift 文本文件策略直接调用 Rust Core,因此 `scripts/test-macos.sh` 会先构建并链接 当前工作树的 Core 静态库。它复用现有 `rust/target/macos` 构建路径;这是可变编译状态, 不允许跨工作树复制或共享,也没有新增可复用资源目录。依赖下载仍按下文清单复用。 -Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先通过 +Swift 单元和插件 CI 通道复用已有 Cargo 下载缓存。先通过 `./scripts/test-macos.sh list` 构建并发现测试,独立步骤上限为 40 分钟;随后计时工具 传入 `--skip-build`,测试阶段保留 960 秒总时限及 17 分钟外层步骤时限,给超时清理 与报告留出一分钟。构建失败时不生成合并报告,避免同一失败被重复计为“未生成测试 diff --git a/infra/docker/database-validation/compose.yaml b/infra/docker/database-validation/compose.yaml deleted file mode 100644 index 40abed4e7..000000000 --- a/infra/docker/database-validation/compose.yaml +++ /dev/null @@ -1,86 +0,0 @@ -name: lithe-database-validation - -services: - mariadb: - image: mariadb:11.4 - container_name: lithe-database-validation-mariadb - restart: unless-stopped - ports: - - "${LITHE_BIND_ADDRESS:-127.0.0.1}:${LITHE_MARIADB_PORT:-53307}:3306" - environment: - MARIADB_ROOT_PASSWORD: "${LITHE_DB_PASSWORD:-lithe_root}" - MARIADB_DATABASE: lithe_test - volumes: - - mariadb-data:/var/lib/mysql - - ./mariadb-init.sql:/docker-entrypoint-initdb.d/001-validation.sql:ro - healthcheck: - test: - - CMD-SHELL - - mariadb-admin ping -h 127.0.0.1 -uroot -p"$$MARIADB_ROOT_PASSWORD" --silent - interval: 5s - timeout: 5s - retries: 30 - start_period: 20s - - mongodb: - image: mongo:7.0 - container_name: lithe-database-validation-mongodb - restart: unless-stopped - ports: - - "${LITHE_BIND_ADDRESS:-127.0.0.1}:${LITHE_MONGODB_PORT:-57019}:27017" - environment: - MONGO_INITDB_ROOT_USERNAME: root - MONGO_INITDB_ROOT_PASSWORD: "${LITHE_DB_PASSWORD:-lithe_root}" - MONGO_INITDB_DATABASE: lithe_test - volumes: - - mongodb-data:/data/db - - ./mongodb-init.js:/docker-entrypoint-initdb.d/001-validation.js:ro - healthcheck: - test: - - CMD-SHELL - - 'mongosh --quiet --username root --password "$$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --eval ''db.adminCommand({ ping: 1 }).ok'' | grep 1' - interval: 5s - timeout: 5s - retries: 30 - start_period: 20s - - redis: - image: redis:7.4-alpine - container_name: lithe-database-validation-redis - restart: unless-stopped - command: - - redis-server - - --appendonly - - "yes" - - --requirepass - - "${LITHE_DB_PASSWORD:-lithe_root}" - environment: - REDIS_PASSWORD: "${LITHE_DB_PASSWORD:-lithe_root}" - REDISCLI_AUTH: "${LITHE_DB_PASSWORD:-lithe_root}" - ports: - - "${LITHE_BIND_ADDRESS:-127.0.0.1}:${LITHE_REDIS_PORT:-6381}:6379" - volumes: - - redis-data:/data - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 5s - timeout: 5s - retries: 30 - - redis-seed: - image: redis:7.4-alpine - container_name: lithe-database-validation-redis-seed - depends_on: - redis: - condition: service_healthy - environment: - REDIS_PASSWORD: "${LITHE_DB_PASSWORD:-lithe_root}" - entrypoint: ["/bin/sh", "/usr/local/bin/redis-seed.sh"] - volumes: - - ./redis-seed.sh:/usr/local/bin/redis-seed.sh:ro - restart: "no" - -volumes: - mariadb-data: - mongodb-data: - redis-data: diff --git a/infra/docker/database-validation/mariadb-init.sql b/infra/docker/database-validation/mariadb-init.sql deleted file mode 100644 index a40dc1af3..000000000 --- a/infra/docker/database-validation/mariadb-init.sql +++ /dev/null @@ -1,35 +0,0 @@ -ALTER DATABASE lithe_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE lithe_test; -SET NAMES utf8mb4; - -CREATE TABLE IF NOT EXISTS records ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - title VARCHAR(120) NOT NULL, - nullable_note TEXT NULL, - empty_value VARCHAR(32) NOT NULL, - flag BOOLEAN NOT NULL, - tiny_value TINYINT NOT NULL, - event_time TIMESTAMP(6) NOT NULL, - amount DECIMAL(10, 2) NOT NULL, - payload JSON NOT NULL, - binary_value VARBINARY(8) NOT NULL, - PRIMARY KEY (id), - KEY records_title_idx (title), - KEY records_event_time_idx (event_time) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS audit_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - action_name VARCHAR(64) NOT NULL, - created_at DATETIME(6) NOT NULL, - PRIMARY KEY (id) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; - -INSERT INTO records - (title, nullable_note, empty_value, flag, tiny_value, event_time, amount, payload, binary_value) -VALUES - ('中文 / emoji 🚀', NULL, '', TRUE, 1, '2026-08-10 09:30:00.123456', 1234.50, '{"tags":["demo","中文"],"active":true}', UNHEX('00010203')), - ('second row', 'a non-empty note', 'filled', FALSE, 127, '2026-08-11 10:45:01.000000', 0.01, '{"tags":[],"active":false}', UNHEX('FF00')); - -INSERT INTO audit_log (action_name, created_at) -VALUES ('seed', '2026-08-10 09:00:00.000000'); diff --git a/infra/docker/database-validation/mongodb-init.js b/infra/docker/database-validation/mongodb-init.js deleted file mode 100644 index 0c9cf09cc..000000000 --- a/infra/docker/database-validation/mongodb-init.js +++ /dev/null @@ -1,32 +0,0 @@ -const validation = db.getSiblingDB('lithe_test'); - -validation.items.deleteMany({}); -validation.items.insertMany([ - { - _id: ObjectId('66b5a9a00000000000000001'), - name: '中文 / emoji 🚀', - empty_value: '', - nullable_value: null, - active: true, - score: NumberInt(42), - amount: NumberDecimal('1234.50'), - happened_at: ISODate('2026-08-10T09:30:00.123Z'), - nested: { region: 'cn', labels: ['demo', '数据库'] }, - binary_value: new BinData(0, 'AAEC') - }, - { - _id: ObjectId('66b5a9a00000000000000002'), - name: 'second row', - empty_value: 'filled', - nullable_value: null, - active: false, - score: NumberInt(7), - amount: NumberDecimal('0.01'), - happened_at: ISODate('2026-08-11T10:45:01.000Z'), - nested: { region: 'us', labels: [] }, - binary_value: new BinData(0, '/wA=') - } -]); - -validation.items.createIndex({ name: 1 }, { name: 'items_name_idx' }); -validation.items.createIndex({ active: 1, score: -1 }, { name: 'items_active_score_idx' }); diff --git a/infra/docker/database-validation/redis-seed.sh b/infra/docker/database-validation/redis-seed.sh deleted file mode 100755 index aa3860124..000000000 --- a/infra/docker/database-validation/redis-seed.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -set -eu - -export REDISCLI_AUTH="$REDIS_PASSWORD" - -redis-cli -h redis DEL profile:42 cache:empty session:42 queue:jobs ttl:short -redis-cli -h redis SET profile:42 'Alice / demo' -redis-cli -h redis SET cache:empty '' -redis-cli -h redis HSET session:42 user_id 42 locale zh-CN -redis-cli -h redis RPUSH queue:jobs job-1 job-2 -redis-cli -h redis SETEX ttl:short 3600 'expires later' -redis-cli -h redis DBSIZE diff --git a/macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt b/macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt deleted file mode 100644 index 261eeb9e9..000000000 --- a/macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/macos/Resources/DatabaseIcons/NOTICE.txt b/macos/Resources/DatabaseIcons/NOTICE.txt deleted file mode 100644 index dce730218..000000000 --- a/macos/Resources/DatabaseIcons/NOTICE.txt +++ /dev/null @@ -1,18 +0,0 @@ -Database brand icon assets -========================== - -MySQL, MariaDB, PostgreSQL, SQLite, Microsoft SQL Server, MongoDB and Redis -SVGs (including available dark variants) are unmodified IntelliJ Community -platform/icons/src/providers assets. PostgreSQL, SQL Server and MongoDB retain -local filenames postgres, sqlserver and mongodb, respectively. -Source: https://github.com/JetBrains/intellij-community -Revision: c7f91397daa3a961b4e78bc634fe467a0a7d9ade -Each imported SVG retains its upstream copyright and Apache-2.0 header. - -Nacos has no dedicated provider mark in this Community revision. Its local -nacos{,_dark}.svg files are unmodified platform/icons/src/expui/general/settings -variants from the same revision, used as a generic configuration icon. -Apache License 2.0 is included as LICENSE-APACHE-2.0.txt. - -Names and logos are trademarks of their respective owners, used only to -identify connection types without endorsement or affiliation. diff --git a/macos/Resources/DatabaseIcons/mariadb.svg b/macos/Resources/DatabaseIcons/mariadb.svg deleted file mode 100644 index 15245689a..000000000 --- a/macos/Resources/DatabaseIcons/mariadb.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/mariadb_dark.svg b/macos/Resources/DatabaseIcons/mariadb_dark.svg deleted file mode 100644 index 19afde2e2..000000000 --- a/macos/Resources/DatabaseIcons/mariadb_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/macos/Resources/DatabaseIcons/mongodb.svg b/macos/Resources/DatabaseIcons/mongodb.svg deleted file mode 100644 index 0622dfeda..000000000 --- a/macos/Resources/DatabaseIcons/mongodb.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/mysql.svg b/macos/Resources/DatabaseIcons/mysql.svg deleted file mode 100644 index 04f5be96e..000000000 --- a/macos/Resources/DatabaseIcons/mysql.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/mysql_dark.svg b/macos/Resources/DatabaseIcons/mysql_dark.svg deleted file mode 100644 index 7a2d0f3bc..000000000 --- a/macos/Resources/DatabaseIcons/mysql_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/macos/Resources/DatabaseIcons/nacos.svg b/macos/Resources/DatabaseIcons/nacos.svg deleted file mode 100644 index c30de8209..000000000 --- a/macos/Resources/DatabaseIcons/nacos.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/macos/Resources/DatabaseIcons/nacos_dark.svg b/macos/Resources/DatabaseIcons/nacos_dark.svg deleted file mode 100644 index 62f328167..000000000 --- a/macos/Resources/DatabaseIcons/nacos_dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/macos/Resources/DatabaseIcons/postgres.svg b/macos/Resources/DatabaseIcons/postgres.svg deleted file mode 100644 index 3b178c4f7..000000000 --- a/macos/Resources/DatabaseIcons/postgres.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/postgres_dark.svg b/macos/Resources/DatabaseIcons/postgres_dark.svg deleted file mode 100644 index 97202e9ad..000000000 --- a/macos/Resources/DatabaseIcons/postgres_dark.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/redis.svg b/macos/Resources/DatabaseIcons/redis.svg deleted file mode 100644 index b9cdd4554..000000000 --- a/macos/Resources/DatabaseIcons/redis.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/redis_dark.svg b/macos/Resources/DatabaseIcons/redis_dark.svg deleted file mode 100644 index b9cdd4554..000000000 --- a/macos/Resources/DatabaseIcons/redis_dark.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/macos/Resources/DatabaseIcons/sqlite.svg b/macos/Resources/DatabaseIcons/sqlite.svg deleted file mode 100644 index 99511deb1..000000000 --- a/macos/Resources/DatabaseIcons/sqlite.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/macos/Resources/DatabaseIcons/sqlite_dark.svg b/macos/Resources/DatabaseIcons/sqlite_dark.svg deleted file mode 100644 index e2f272296..000000000 --- a/macos/Resources/DatabaseIcons/sqlite_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/macos/Resources/DatabaseIcons/sqlserver.svg b/macos/Resources/DatabaseIcons/sqlserver.svg deleted file mode 100644 index 1fe8edcd0..000000000 --- a/macos/Resources/DatabaseIcons/sqlserver.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/macos/Resources/DatabaseIcons/sqlserver_dark.svg b/macos/Resources/DatabaseIcons/sqlserver_dark.svg deleted file mode 100644 index 249275e64..000000000 --- a/macos/Resources/DatabaseIcons/sqlserver_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index bce0bd8ed..77a5b896f 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -10,7 +10,6 @@ "%@ (%@ failed)" = "%@ (%@ failed)"; "%@ (%lld)" = "%@ (%lld)"; "%@ (not available for this Lithe or Mac)" = "%@ (not available for this Lithe or Mac)"; -"%@ Query History" = "%@ Query History"; "%@ already exists. Choose another name or location." = "%@ already exists. Choose another name or location."; "%@ answered with status %lld. %@" = "%@ answered with status %lld. %@"; "%@ build files changed" = "%@ build files changed"; @@ -55,7 +54,6 @@ "%d lines of its own" = "%d lines of its own"; "%d messages" = "%d messages"; "%d minutes ago" = "%d minutes ago"; -"%d rows retrieved in %d ms" = "%d rows retrieved in %d ms"; "%d selected" = "%d selected"; "%d tools" = "%d tools"; "%lld CPU cores" = "%lld CPU cores"; @@ -103,7 +101,6 @@ "%lldh %lldm %llds" = "%lldh %lldm %llds"; "%lldm %llds" = "%lldm %llds"; "%llds" = "%llds"; -"%llu rows affected in %d ms" = "%llu rows affected in %d ms"; ", " = ", "; "0.5 seconds" = "0.5 seconds"; "1 file" = "1 file"; @@ -123,7 +120,6 @@ "A basic project with a free structure." = "A basic project with a free structure."; "A custom Agent can follow either your Codex or your Claude configuration." = "A custom Agent can follow either your Codex or your Claude configuration."; "A detailed message with a clear subject and body" = "A detailed message with a clear subject and body"; -"A folder with this name already exists." = "A folder with this name already exists."; "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply." = "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply."; "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing." = "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing."; "A message creates an annotated tag." = "A message creates an annotated tag."; @@ -253,7 +249,6 @@ "All modules" = "All modules"; "All projects" = "All projects"; "All projects · project can override" = "All projects · project can override"; -"All schemas" = "All schemas"; "All servers" = "All servers"; "All sessions" = "All sessions"; "All severities" = "All severities"; @@ -367,7 +362,6 @@ "Average time to first token (TTFT)" = "Average time to first token (TTFT)"; "Average total" = "Average total"; "Back" = "Back"; -"Back Up Database as SQL…" = "Back Up Database as SQL…"; "Back to all references" = "Back to all references"; "Back to the conversation" = "Back to the conversation"; "Background Services" = "Background Services"; @@ -403,7 +397,6 @@ "Breakdown unavailable" = "Breakdown unavailable"; "Breakpoint actions" = "Breakpoint actions"; "Breakpoints" = "Breakpoints"; -"Browse Database" = "Browse Database"; "Browse…" = "Browse…"; "Bug report" = "Bug report"; "Build" = "Build"; @@ -596,7 +589,6 @@ "Clear date filter" = "Clear date filter"; "Clear debug output" = "Clear debug output"; "Clear log search" = "Clear log search"; -"Clear output" = "Clear output"; "Clear path filter" = "Clear path filter"; "Clear run output" = "Clear run output"; "Clear search" = "Clear search"; @@ -666,13 +658,11 @@ "Collapse All" = "Collapse All"; "Collapse JSON block" = "Collapse JSON block"; "Collapse all" = "Collapse all"; -"Collapse database" = "Collapse database"; "Collapse directory" = "Collapse directory"; "Collapse long graph edges" = "Collapse long graph edges"; "Collapse notification" = "Collapse notification"; "Collapse repository" = "Collapse repository"; "Collapse section" = "Collapse section"; -"Collapse table" = "Collapse table"; "Collapsed" = "Collapsed"; "Collected logs cleared." = "Collected logs cleared."; "Color theme" = "Color theme"; @@ -719,7 +709,6 @@ "Committer name" = "Committer name"; "Committing…" = "Committing…"; "Commit…" = "Commit…"; -"Common Actions" = "Common Actions"; "Community" = "Community"; "Community / Disk" = "Community / Disk"; "Community plugin installed from disk. Its publisher and repository have not been verified." = "Community plugin installed from disk. Its publisher and repository have not been verified."; @@ -774,11 +763,7 @@ "Connecting…" = "Connecting…"; "Connection" = "Connection"; "Connection recovery is taking longer. The Agent is still retrying." = "Connection recovery is taking longer. The Agent is still retrying."; -"Connection successful" = "Connection successful"; -"Connection test duration: %lld ms" = "Connection test duration: %lld ms"; -"Connection test result" = "Connection test result"; "Connection timeout" = "Connection timeout"; -"Connections" = "Connections"; "Connections: %lld" = "Connections: %lld"; "Console" = "Console"; "Content" = "Content"; @@ -940,12 +925,8 @@ "Danger Zone" = "Danger Zone"; "Dark" = "Dark"; "Dart Support" = "Dart Support"; -"Data Source" = "Data Source"; -"Data Tools" = "Data Tools"; "Database" = "Database"; -"Database Overview" = "Database Overview"; "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it." = "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it."; -"Database types" = "Database types"; "Date" = "Date"; "Debug" = "Debug"; "Debug target" = "Debug target"; @@ -1077,7 +1058,6 @@ "Don't ask again" = "Don't ask again"; "Done" = "Done"; "Don’t Allow" = "Don’t Allow"; -"Double-click a table to view data" = "Double-click a table to view data"; "Download" = "Download"; "Download %@" = "Download %@"; "Download PHP Support from the official plugin release." = "Download PHP Support from the official plugin release."; @@ -1101,7 +1081,6 @@ "Drop this shelf?" = "Drop this shelf?"; "Dropped: %lld; write failures: %lld" = "Dropped: %lld; write failures: %lld"; "Duplicate" = "Duplicate"; -"Duplicate Connection" = "Duplicate Connection"; "Duration: %lld ms" = "Duration: %lld ms"; "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first." = "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first."; "Each built-in preset includes a preview of the generated message." = "Each built-in preset includes a preview of the generated message."; @@ -1121,7 +1100,6 @@ "Edit Commit Message" = "Edit Commit Message"; "Edit Commit Message…" = "Edit Commit Message…"; "Edit Configuration…" = "Edit Configuration…"; -"Edit Connection" = "Edit Connection"; "Edit Hook" = "Edit Hook"; "Edit MCP server" = "Edit MCP server"; "Edit Service" = "Edit Service"; @@ -1236,20 +1214,16 @@ "Expand %lld lines" = "Expand %lld lines"; "Expand All" = "Expand All"; "Expand JSON block" = "Expand JSON block"; -"Expand database" = "Expand database"; "Expand directory" = "Expand directory"; "Expand notification" = "Expand notification"; "Expand repository" = "Expand repository"; "Expand section" = "Expand section"; -"Expand table" = "Expand table"; "Expand temporary Git configuration or command details" = "Expand temporary Git configuration or command details"; "Expanded" = "Expanded"; "Explain the intent, testing, and anything reviewers should know…" = "Explain the intent, testing, and anything reviewers should know…"; "Explain what must change before this pull request can be approved." = "Explain what must change before this pull request can be approved."; "Export Diagnostics Bundle" = "Export Diagnostics Bundle"; "Export Diagnostics Bundle…" = "Export Diagnostics Bundle…"; -"Export Table as CSV…" = "Export Table as CSV…"; -"Export Table as JSON…" = "Export Table as JSON…"; "Export as PDF…" = "Export as PDF…"; "Export conversations" = "Export conversations"; "Export logs" = "Export logs"; @@ -1265,7 +1239,6 @@ "F# Support" = "F# Support"; "Failed" = "Failed"; "Failed to start; check the language server configuration" = "Failed to start; check the language server configuration"; -"Failed, line %d" = "Failed, line %d"; "Failures" = "Failures"; "Fast" = "Fast"; "Fast mode" = "Fast mode"; @@ -1319,7 +1292,6 @@ "Files will stay unchanged. Nothing will be staged or committed automatically." = "Files will stay unchanged. Nothing will be staged or committed automatically."; "Filter by changed path" = "Filter by changed path"; "Filter by title, author, or label" = "Filter by title, author, or label"; -"Filter database type" = "Filter database type"; "Filter log messages" = "Filter log messages"; "Filter problems by severity" = "Filter problems by severity"; "Filter results" = "Filter results"; @@ -1344,7 +1316,6 @@ "Fixup" = "Fixup"; "Folder" = "Folder"; "Folder name" = "Folder name"; -"Folder name is required." = "Folder name is required."; "Follow Git setting" = "Follow Git setting"; "Follow Git tag rules" = "Follow Git tag rules"; "Follow the instructions you define below" = "Follow the instructions you define below"; @@ -1354,10 +1325,8 @@ "For example, notify" = "For example, notify"; "For example, notify me when a turn ends" = "For example, notify me when a turn ends"; "Force Checkout" = "Force Checkout"; -"Force Refresh" = "Force Refresh"; "Force Remove" = "Force Remove"; "Force remove worktree?" = "Force remove worktree?"; -"Forever" = "Forever"; "Forget" = "Forget"; "Fork conversation" = "Fork conversation"; "Format" = "Format"; @@ -1529,14 +1498,10 @@ "Ignored entries: %lld" = "Ignored entries: %lld"; "Imperative subject" = "Imperative subject"; "Implementations" = "Implementations"; -"Import CSV…" = "Import CSV…"; -"Import Data Sources…" = "Import Data Sources…"; "Import JSON" = "Import JSON"; -"Import JSON…" = "Import JSON…"; "Import MCP configuration" = "Import MCP configuration"; "Import SKILL.md" = "Import SKILL.md"; "Import a regular file or directory instead of a symbolic link." = "Import a regular file or directory instead of a symbolic link."; -"Import connections from DBX" = "Import connections from DBX"; "Import from Claude" = "Import from Claude"; "Import from Codex" = "Import from Codex"; "Import skill" = "Import skill"; @@ -1992,12 +1957,10 @@ "New Directory…" = "New Directory…"; "New File…" = "New File…"; "New Folder" = "New Folder"; -"New Folder…" = "New Folder…"; "New Hook" = "New Hook"; "New MCP server" = "New MCP server"; "New Project" = "New Project"; "New Project…" = "New Project…"; -"New Query" = "New Query"; "New Run Configuration" = "New Run Configuration"; "New SSH Connection" = "New SSH Connection"; "New Tag" = "New Tag"; @@ -2049,8 +2012,6 @@ "No Remote Branches" = "No Remote Branches"; "No SSH connections yet." = "No SSH connections yet."; "No Spring Boot or Maven Module configurations" = "No Spring Boot or Maven Module configurations"; -"No Table Selected" = "No Table Selected"; -"No accessible databases" = "No accessible databases"; "No available local branch. Branches already checked out in another worktree cannot be reused." = "No available local branch. Branches already checked out in another worktree cannot be reused."; "No background image selected" = "No background image selected"; "No branches found" = "No branches found"; @@ -2058,7 +2019,6 @@ "No callable target at this location" = "No callable target at this location"; "No changed files" = "No changed files"; "No changes" = "No changes"; -"No columns to display" = "No columns to display"; "No command" = "No command"; "No commit selected" = "No commit selected"; "No commits" = "No commits"; @@ -2071,7 +2031,6 @@ "No credential found in Codex configuration" = "No credential found in Codex configuration"; "No current branch" = "No current branch"; "No custom providers yet." = "No custom providers yet."; -"No databases loaded" = "No databases loaded"; "No default shell was detected. Choose a detected shell or run detection again." = "No default shell was detected. Choose a detected shell or run detection again."; "No dependencies" = "No dependencies"; "No dependencies." = "No dependencies."; @@ -2127,7 +2086,6 @@ "No published release is available yet." = "No published release is available yet."; "No pull requests" = "No pull requests"; "No pull requests match the selected state." = "No pull requests match the selected state."; -"No query output" = "No query output"; "No recent progress has been received. The task is still active." = "No recent progress has been received. The task is still active."; "No recent projects" = "No recent projects"; "No release is available yet" = "No release is available yet"; @@ -2139,7 +2097,6 @@ "No runnable services were detected in this project." = "No runnable services were detected in this project."; "No running debug process accepts standard input" = "No running debug process accepts standard input"; "No saved changes" = "No saved changes"; -"No schemas selected" = "No schemas selected"; "No settings found" = "No settings found"; "No shell was found at %@. Choose a detected shell or run detection again." = "No shell was found at %@. Choose a detected shell or run detection again."; "No shelves" = "No shelves"; @@ -2161,7 +2118,6 @@ "Node.js %lld or later is required; found %@." = "Node.js %lld or later is required; found %@."; "Node.js %lld or later was not found. Install it, then check again." = "Node.js %lld or later was not found. Install it, then check again."; "Node.js Support" = "Node.js Support"; -"Non-Introspected" = "Non-Introspected"; "Non-streaming" = "Non-streaming"; "Non-streaming responses" = "Non-streaming responses"; "None" = "None"; @@ -2170,7 +2126,6 @@ "Not Assigned" = "Not Assigned"; "Not Supported" = "Not Supported"; "Not available yet: dependencies are not indexed" = "Not available yet: dependencies are not indexed"; -"Not connected" = "Not connected"; "Not detected" = "Not detected"; "Not fetched yet." = "Not fetched yet."; "Not found" = "Not found"; @@ -2259,7 +2214,6 @@ "Open a project to manage its skills." = "Open a project to manage its skills."; "Open a project to see its language service." = "Open a project to see its language service."; "Open a project to use Git" = "Open a project to use Git"; -"Open a table to inspect its structure" = "Open a table to inspect its structure"; "Open again" = "Open again"; "Open file" = "Open file"; "Open file in editor to see problems" = "Open file in editor to see problems"; @@ -2317,7 +2271,6 @@ "Password or passphrase" = "Password or passphrase"; "Paste" = "Paste"; "Paste Patch" = "Paste Patch"; -"Paste TSV from Clipboard" = "Paste TSV from Clipboard"; "Paste a JSON object containing mcpServers." = "Paste a JSON object containing mcpServers."; "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root." = "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root."; "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry." = "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry."; @@ -2382,7 +2335,6 @@ "Preparing conversation…" = "Preparing conversation…"; "Preparing download…" = "Preparing download…"; "Preserves every commit and adds a merge commit to the base branch." = "Preserves every commit and adds a merge commit to the base branch."; -"Press Enter or click outside the list to apply" = "Press Enter or click outside the list to apply"; "Press shortcut…" = "Press shortcut…"; "Preview" = "Preview"; "Preview Update" = "Preview Update"; @@ -2511,9 +2463,7 @@ "Push to %@" = "Push to %@"; "Push…" = "Push…"; "Python Support" = "Python Support"; -"Query Console" = "Query Console"; "Quick Information" = "Quick Information"; -"Quick Start" = "Quick Start"; "Quick responses with basic reasoning" = "Quick responses with basic reasoning"; "Quit" = "Quit"; "Quit with Selection" = "Quit with Selection"; @@ -2553,7 +2503,6 @@ "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase." = "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase."; "Recent" = "Recent"; "Recent Projects" = "Recent Projects"; -"Recent SQL" = "Recent SQL"; "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts."; "Reconnect" = "Reconnect"; "Reconnect this Agent to refresh its model list." = "Reconnect this Agent to refresh its model list."; @@ -2572,14 +2521,12 @@ "Refresh Preview" = "Refresh Preview"; "Refresh changes" = "Refresh changes"; "Refresh connection structure" = "Refresh connection structure"; -"Refresh databases" = "Refresh databases"; "Refresh dependencies" = "Refresh dependencies"; "Refresh detected runtimes" = "Refresh detected runtimes"; "Refresh history" = "Refresh history"; "Refresh local history" = "Refresh local history"; "Refresh project history" = "Refresh project history"; "Refresh pull requests" = "Refresh pull requests"; -"Refresh selected structure" = "Refresh selected structure"; "Refresh skills" = "Refresh skills"; "Refresh worktrees" = "Refresh worktrees"; "Refreshed %@" = "Refreshed %@"; @@ -2655,7 +2602,6 @@ "Rename Branch" = "Rename Branch"; "Rename ChangeList" = "Rename ChangeList"; "Rename Chat" = "Rename Chat"; -"Rename Folder" = "Rename Folder"; "Rename conversation" = "Rename conversation"; "Renamed" = "Renamed"; "Rename…" = "Rename…"; @@ -2670,7 +2616,6 @@ "Replace" = "Replace"; "Replace current commit message?" = "Replace current commit message?"; "Replace existing content" = "Replace existing content"; -"Replace in Current Page…" = "Replace in Current Page…"; "Replace in File" = "Replace in File"; "Replace in Files" = "Replace in Files"; "Replace in File…" = "Replace in File…"; @@ -2738,12 +2683,10 @@ "Restore Default Template" = "Restore Default Template"; "Restore Default UI Layout" = "Restore Default UI Layout"; "Restore Defaults" = "Restore Defaults"; -"Restore SQL Backup…" = "Restore SQL Backup…"; "Restore conversations" = "Restore conversations"; "Restore path" = "Restore path"; "Restoring GitHub connection" = "Restoring GitHub connection"; "Result" = "Result"; -"Result %d" = "Result %d"; "Results" = "Results"; "Resume" = "Resume"; "Resume the paused debug session" = "Resume the paused debug session"; @@ -2850,13 +2793,10 @@ "SKILL.md needs a name and description." = "SKILL.md needs a name and description."; "SKILL.md needs instructions after its frontmatter." = "SKILL.md needs instructions after its frontmatter."; "SQL Support" = "SQL Support"; -"SQL execution markers" = "SQL execution markers"; "SSH" = "SSH"; -"SSH / TLS" = "SSH / TLS"; "SSH Agent" = "SSH Agent"; "SSH Authentication" = "SSH Authentication"; "STAGED" = "STAGED"; -"Safety and Labels" = "Safety and Labels"; "Sample interval" = "Sample interval"; "Save" = "Save"; "Save Changes" = "Save Changes"; @@ -2872,12 +2812,10 @@ "Save local changes with" = "Save local changes with"; "Save message" = "Save message"; "Save or discard unsaved files before deleting this item" = "Save or discard unsaved files before deleting this item"; -"Save password" = "Save password"; "Save scope" = "Save scope"; "Save shared instructions" = "Save shared instructions"; "Save the active document" = "Save the active document"; "Save title" = "Save title"; -"Save:" = "Save:"; "Saved" = "Saved"; "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared." = "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared."; "Saved in .lithe/run/local.json and excluded from Git." = "Saved in .lithe/run/local.json and excluded from Git."; @@ -2906,7 +2844,6 @@ "Search connections" = "Search connections"; "Search conversation" = "Search conversation"; "Search conversation titles or IDs…" = "Search conversation titles or IDs…"; -"Search databases" = "Search databases"; "Search diff" = "Search diff"; "Search exclusions" = "Search exclusions"; "Search files and contents" = "Search files and contents"; @@ -2941,7 +2878,6 @@ "Select a Spring Boot or Maven Module configuration before starting Debug" = "Select a Spring Boot or Maven Module configuration before starting Debug"; "Select a changed file first" = "Select a changed file first"; "Select a commit" = "Select a commit"; -"Select a connection in the database tree" = "Select a connection in the database tree"; "Select a file" = "Select a file"; "Select a file to preview" = "Select a file to preview"; "Select a file to review" = "Select a file to review"; @@ -2956,7 +2892,6 @@ "Select branch" = "Select branch"; "Select configurations for batch actions" = "Select configurations for batch actions"; "Select conversation" = "Select conversation"; -"Select databases" = "Select databases"; "Select for Compare" = "Select for Compare"; "Select repository for commits and branch operations" = "Select repository for commits and branch operations"; "Select run configuration" = "Select run configuration"; @@ -3120,7 +3055,6 @@ "Some dependencies are not shown." = "Some dependencies are not shown."; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; -"Sort connections" = "Sort connections"; "Sort plugins" = "Sort plugins"; "Source" = "Source"; "Source Code" = "Source Code"; @@ -3228,14 +3162,12 @@ "Subject maximum length" = "Subject maximum length"; "Submitting review…" = "Submitting review…"; "Succeeded" = "Succeeded"; -"Succeeded, line %d" = "Succeeded, line %d"; "Supplementary message cancelled." = "Supplementary message cancelled."; "Supported placeholders: {summary}, {changes}, {testing}, {risks}." = "Supported placeholders: {summary}, {changes}, {testing}, {risks}."; "Svelte Support" = "Svelte Support"; "Swap Direction" = "Swap Direction"; "Swift Support" = "Swift Support"; "Switch Agent" = "Switch Agent"; -"Switch current database" = "Switch current database"; "Switch project view" = "Switch project view"; "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again." = "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again."; "Switched" = "Switched"; @@ -3278,7 +3210,6 @@ "Test process tree · Resident memory" = "Test process tree · Resident memory"; "Test the connection to see the host's system, resources, and address." = "Test the connection to see the host's system, resources, and address."; "Testing" = "Testing"; -"Testing connection…" = "Testing connection…"; "Tests" = "Tests"; "Tests failed" = "Tests failed"; "Tests passed" = "Tests passed"; @@ -3349,7 +3280,6 @@ "The credential store version is unsupported. Existing credentials were preserved." = "The credential store version is unsupported. Existing credentials were preserved."; "The current file will be saved to Local History before it is replaced." = "The current file will be saved to Local History before it is replaced."; "The current worktree cannot be removed here." = "The current worktree cannot be removed here."; -"The database selection is no longer available. Refresh and try again." = "The database selection is no longer available. Refresh and try again."; "The default ChangeList cannot be renamed." = "The default ChangeList cannot be renamed."; "The downloaded app does not match the selected stable release. Nothing was installed." = "The downloaded app does not match the selected stable release. Nothing was installed."; "The downloaded disk image does not contain Lithe.app." = "The downloaded disk image does not contain Lithe.app."; @@ -3414,10 +3344,8 @@ "The selected background image is unavailable and was removed." = "The selected background image is unavailable and was removed."; "The selected branch diff is sent to the active AI provider when you generate." = "The selected branch diff is sent to the active AI provider when you generate."; "The selected branches have no textual changes to summarize." = "The selected branches have no textual changes to summarize."; -"The selected connection folder no longer exists." = "The selected connection folder no longer exists."; "The selected file changed before generation finished" = "The selected file changed before generation finished"; "The selected file has no textual diff to summarize." = "The selected file has no textual diff to summarize."; -"The selected folder cannot be moved there." = "The selected folder cannot be moved there."; "The selected table has no columns to display." = "The selected table has no columns to display."; "The selected versions match" = "The selected versions match"; "The server asked again. Check the previous answer." = "The server asked again. Check the previous answer."; @@ -3544,8 +3472,6 @@ "Timed Out" = "Timed Out"; "Timeout (seconds)" = "Timeout (seconds)"; "Title" = "Title"; -"To Folder…" = "To Folder…"; -"To Top Level" = "To Top Level"; "To debug your code, do one of the following:" = "To debug your code, do one of the following:"; "To track changes to code:" = "To track changes to code:"; "Today" = "Today"; @@ -3654,7 +3580,6 @@ "Unstaged changes" = "Unstaged changes"; "Unsupported content" = "Unsupported content"; "Until (UTC ISO 8601)" = "Until (UTC ISO 8601)"; -"Until restart" = "Until restart"; "Untitled conversation" = "Untitled conversation"; "Untracked" = "Untracked"; "Unused code" = "Unused code"; @@ -3915,7 +3840,6 @@ "native.aChangeListWithThisNameAlreadyExists" = "A ChangeList with this name already exists."; "native.aCustomAgentCanFollowEitherYourCodexOrYourClaudeConfiguration" = "A custom Agent can follow either your Codex or your Claude configuration."; "native.aDetailedMessageWithAClearSubjectAndBody" = "A detailed message with a clear subject and body"; -"native.aFolderWithThisNameAlreadyExists" = "A folder with this name already exists."; "native.aHookWithThisNameAlreadyExists" = "A Hook with this name already exists."; "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit" = "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply."; "native.aJavaProjectWithNoBuildToolMavenOrGradle" = "A Java project with no build tool, Maven, or Gradle."; @@ -4036,7 +3960,6 @@ "native.allProjectsProjectCanOverride" = "All projects · project can override"; "native.allReferences" = "All References"; "native.allRepositories" = "All Repositories"; -"native.allSchemas" = "All schemas"; "native.allServers" = "All servers"; "native.allSessions" = "All sessions"; "native.allSeverities" = "All severities"; @@ -4173,7 +4096,6 @@ "native.avg" = "avg"; "native.backToAllReferences" = "Back to all references"; "native.backToTheConversation" = "Back to the conversation"; -"native.backUpDatabaseAsSQL" = "Back Up Database as SQL…"; "native.backgroundServices" = "Background Services"; "native.backgroundServices.variant2" = "Background services"; "native.backgroundServicesStartedInLitheAreStillRunning" = "Background services started in Lithe are still running"; @@ -4209,7 +4131,6 @@ "native.breakpointActions" = "Breakpoint actions"; "native.breakpoints" = "Breakpoints"; "native.browse" = "Browse…"; -"native.browseDatabase" = "Browse Database"; "native.bugReport" = "Bug report"; "native.build" = "Build"; "native.build.variant2" = "Build %1$@ → %2$@"; @@ -4398,7 +4319,6 @@ "native.clearGitConsole" = "Clear Git console"; "native.clearLogSearch" = "Clear log search"; "native.clearLogs" = "Clear Logs"; -"native.clearOutput" = "Clear output"; "native.clearOverride" = "Clear Override"; "native.clearPathFilter" = "Clear path filter"; "native.clearRunOutput" = "Clear run output"; @@ -4470,14 +4390,12 @@ "native.collaborationMode" = "Collaboration mode"; "native.collapse" = "Collapse"; "native.collapseAll" = "Collapse all"; -"native.collapseDatabase" = "Collapse database"; "native.collapseDirectory" = "Collapse directory"; "native.collapseJSONBlock" = "Collapse JSON block"; "native.collapseLongGraphEdges" = "Collapse long graph edges"; "native.collapseNotification" = "Collapse notification"; "native.collapseRepository" = "Collapse repository"; "native.collapseSection" = "Collapse section"; -"native.collapseTable" = "Collapse table"; "native.collapsed" = "Collapsed"; "native.collectedLogsCleared" = "Collected logs cleared."; "native.colorTheme" = "Color theme"; @@ -4526,7 +4444,6 @@ "native.committerEmail" = "Committer email"; "native.committerName" = "Committer name"; "native.committing" = "Committing…"; -"native.commonActions" = "Common Actions"; "native.community" = "Community"; "native.communityDisk" = "Community / Disk"; "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified" = "Community plugin installed from disk. Its publisher and repository have not been verified."; @@ -4581,11 +4498,7 @@ "native.connecting" = "Connecting…"; "native.connection" = "Connection"; "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying" = "Connection recovery is taking longer. The Agent is still retrying."; -"native.connectionSuccessful" = "Connection successful"; -"native.connectionTestDurationLldMs" = "Connection test duration: %1$lld ms"; -"native.connectionTestResult" = "Connection test result"; "native.connectionTimeout" = "Connection timeout"; -"native.connections" = "Connections"; "native.connectionsLld" = "Connections: %1$lld"; "native.console" = "Console"; "native.content" = "Content"; @@ -4748,18 +4661,13 @@ "native.dConversations" = "%1$d conversations"; "native.dMessages" = "%1$d messages"; "native.dMinutesAgo" = "%1$d minutes ago"; -"native.dRowsRetrievedInDMs" = "%1$d rows retrieved in %2$d ms"; "native.dSelected" = "%1$d selected"; "native.daemon" = "Daemon"; "native.dangerZone" = "Danger Zone"; "native.dark" = "Dark"; "native.dartSupport" = "Dart Support"; -"native.dataSource" = "Data Source"; -"native.dataTools" = "Data Tools"; "native.database" = "Database"; "native.databaseIsNowAPluginInstallItFromTheMarketplace" = "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it."; -"native.databaseOverview" = "Database Overview"; -"native.databaseTypes" = "Database types"; "native.date" = "Date"; "native.debug" = "Debug"; "native.debugResume" = "Debug: Resume"; @@ -4895,7 +4803,6 @@ "native.doesNotProvideArchivedConversations" = "%1$@ does not provide archived conversations."; "native.donTAllow" = "Don’t Allow"; "native.donTAskAgain" = "Don't ask again"; -"native.doubleClickATableToViewData" = "Double-click a table to view data"; "native.download" = "Download"; "native.download.variant2" = "Download %1$@"; "native.downloadAndInstall" = "Download and Install"; @@ -4919,7 +4826,6 @@ "native.dropThisShelf" = "Drop this shelf?"; "native.droppedLldWriteFailuresLld" = "Dropped: %1$lld; write failures: %2$lld"; "native.duplicate" = "Duplicate"; -"native.duplicateConnection" = "Duplicate Connection"; "native.duplicateOmitted" = "duplicate omitted"; "native.durationLldMs" = "Duration: %1$lld ms"; "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1" = "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first."; @@ -4938,7 +4844,6 @@ "native.editCommitMessage.variant2" = "Edit Commit Message…"; "native.editConfiguration" = "Edit Configuration…"; "native.editConfigurationText" = "Edit configuration text"; -"native.editConnection" = "Edit Connection"; "native.editExceptionBreakpoint" = "Edit %1$@ exception breakpoint"; "native.editExceptionBreakpoint.variant2" = "Edit exception breakpoint"; "native.editFieldBreakpoint" = "Edit %1$@ field breakpoint"; @@ -5051,14 +4956,12 @@ "native.exitCodeLld" = "Exit code %1$lld"; "native.exitedWithStatusD" = "%1$@ exited with status %2$d.\n%3$@"; "native.expand" = "Expand"; -"native.expandDatabase" = "Expand database"; "native.expandDirectory" = "Expand directory"; "native.expandJSONBlock" = "Expand JSON block"; "native.expandLldLines" = "Expand %1$lld lines"; "native.expandNotification" = "Expand notification"; "native.expandRepository" = "Expand repository"; "native.expandSection" = "Expand section"; -"native.expandTable" = "Expand table"; "native.expandTemporaryGitConfigurationOrCommandDetails" = "Expand temporary Git configuration or command details"; "native.expanded" = "Expanded"; "native.explainTheIntentTestingAndAnythingReviewersShouldKnow" = "Explain the intent, testing, and anything reviewers should know…"; @@ -5069,8 +4972,6 @@ "native.exportDiagnosticsBundle" = "Export Diagnostics Bundle"; "native.exportDiagnosticsBundle.variant2" = "Export Diagnostics Bundle…"; "native.exportLogs" = "Export logs"; -"native.exportTableAsCSV" = "Export Table as CSV…"; -"native.exportTableAsJSON" = "Export Table as JSON…"; "native.exportingConversations" = "Exporting conversations…"; "native.exportsTheIndexAgainstHEAD" = "Exports the index against HEAD."; "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles" = "Exports the working tree's net changes against HEAD, including selected untracked files."; @@ -5082,7 +4983,6 @@ "native.fSupport" = "F# Support"; "native.failed" = "%1$@ (%2$@ failed)"; "native.failed.variant2" = "Failed"; -"native.failedLineD" = "Failed, line %1$d"; "native.failedToStartCheckTheLanguageServerConfiguration" = "Failed to start; check the language server configuration"; "native.failures" = "Failures"; "native.fast" = "Fast"; @@ -5139,7 +5039,6 @@ "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically" = "Files will stay unchanged. Nothing will be staged or committed automatically."; "native.filterByChangedPath" = "Filter by changed path"; "native.filterByTitleAuthorOrLabel" = "Filter by title, author, or label"; -"native.filterDatabaseType" = "Filter database type"; "native.filterLogMessages" = "Filter log messages"; "native.filterProblemsBySeverity" = "Filter problems by severity"; "native.filterResults" = "Filter results"; @@ -5165,7 +5064,6 @@ "native.fixup" = "Fixup"; "native.folder" = "Folder"; "native.folderName" = "Folder name"; -"native.folderNameIsRequired" = "Folder name is required."; "native.followGitSetting" = "Follow Git setting"; "native.followGitTagRules" = "Follow Git tag rules"; "native.followTheInstructionsYouDefineBelow" = "Follow the instructions you define below"; @@ -5174,10 +5072,8 @@ "native.forExampleNotify" = "For example, notify"; "native.forExampleNotifyMeWhenATurnEnds" = "For example, notify me when a turn ends"; "native.forceCheckout" = "Force Checkout"; -"native.forceRefresh" = "Force Refresh"; "native.forceRemove" = "Force Remove"; "native.forceRemoveWorktree" = "Force remove worktree?"; -"native.forever" = "Forever"; "native.forget" = "Forget"; "native.forkConversation" = "Fork conversation"; "native.format" = "Format"; @@ -5355,12 +5251,8 @@ "native.imperativeSubject" = "Imperative subject"; "native.implementations" = "Implementations"; "native.importARegularFileOrDirectoryInsteadOfASymbolicLink" = "Import a regular file or directory instead of a symbolic link."; -"native.importCSV" = "Import CSV…"; -"native.importConnectionsFromDBX" = "Import connections from DBX"; -"native.importDataSources" = "Import Data Sources…"; "native.importFromClaude" = "Import from Claude"; "native.importFromCodex" = "Import from Codex"; -"native.importJSON" = "Import JSON…"; "native.importJson" = "Import JSON"; "native.importMcpConfiguration" = "Import MCP configuration"; "native.importSkill" = "Import skill"; @@ -5721,7 +5613,6 @@ "native.lldhLldmLlds" = "%1$lldh %2$lldm %3$llds"; "native.lldmLlds" = "%1$lldm %2$llds"; "native.llds" = "%1$llds"; -"native.lluRowsAffectedInDMs" = "%1$llu rows affected in %2$d ms"; "native.load" = "Load"; "native.loadDiskVersion" = "Load Disk Version"; "native.loadMore" = "Load More"; @@ -5987,12 +5878,10 @@ "native.newDirectory" = "New Directory…"; "native.newFile" = "New File…"; "native.newFolder" = "New Folder"; -"native.newFolderX633810e9b0" = "New Folder…"; "native.newHook" = "New Hook"; "native.newMcpServer" = "New MCP server"; "native.newProject" = "New Project…"; "native.newProjectX6f8d176462" = "New Project"; -"native.newQuery" = "New Query"; "native.newRunConfiguration" = "New Run Configuration"; "native.newSSHConnection" = "New SSH Connection"; "native.newSkill" = "New skill"; @@ -6013,7 +5902,6 @@ "native.noAIProviderIsConfiguredYet" = "No AI provider is configured yet."; "native.noAPIKeyFoundInClaudeConfiguration" = "No API key found in Claude configuration"; "native.noAPIKeyFoundInCodexAuthJson" = "No API key found in Codex auth.json"; -"native.noAccessibleDatabases" = "No accessible databases"; "native.noAgent" = "no Agent"; "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndChooseALocalOrCustomProvider" = "No Agent is ready yet. Open Settings > AI > Agents to install an Agent and choose a local or custom provider."; "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndFetchItsLocalConfiguration" = "No Agent is ready yet. Open Settings > AI > Agents to install an Agent and fetch its local configuration."; @@ -6029,7 +5917,6 @@ "native.noChanges" = "No changes"; "native.noCodexConfigurationWasFound" = "No Codex configuration was found"; "native.noColumns" = "No Columns"; -"native.noColumnsToDisplay" = "No columns to display"; "native.noCommand" = "No command"; "native.noCommitSelected" = "No commit selected"; "native.noCommits" = "No commits"; @@ -6043,7 +5930,6 @@ "native.noCredentialFoundInCodexConfiguration" = "No credential found in Codex configuration"; "native.noCurrentBranch" = "No current branch"; "native.noCustomProvidersYet" = "No custom providers yet."; -"native.noDatabasesLoaded" = "No databases loaded"; "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain" = "No default shell was detected. Choose a detected shell or run detection again."; "native.noDependencies" = "No dependencies"; "native.noDependencies.variant2" = "No dependencies."; @@ -6116,7 +6002,6 @@ "native.noPublishedReleaseIsAvailableYet" = "No published release is available yet."; "native.noPullRequests" = "No pull requests"; "native.noPullRequestsMatchTheSelectedState" = "No pull requests match the selected state."; -"native.noQueryOutput" = "No query output"; "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive" = "No recent progress has been received. The task is still active."; "native.noRecentProjects" = "No recent projects"; "native.noReleaseIsAvailableYet" = "No release is available yet"; @@ -6130,14 +6015,12 @@ "native.noRunningDebugProcessAcceptsStandardInput" = "No running debug process accepts standard input"; "native.noSSHConnectionsYet" = "No SSH connections yet."; "native.noSavedChanges" = "No saved changes"; -"native.noSchemasSelected" = "No schemas selected"; "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain" = "No shell was found at %1$@. Choose a detected shell or run detection again."; "native.noShelves" = "No shelves"; "native.noSkillsInThisScope" = "No skills in this scope."; "native.noSpringBootOrMavenModuleConfigurations" = "No Spring Boot or Maven Module configurations"; "native.noSubagentOutputYet" = "No subagent output yet"; "native.noSubagentsYet" = "No subagents yet"; -"native.noTableSelected" = "No Table Selected"; "native.noTasksYet" = "No tasks yet"; "native.noTextualDiffAvailable" = "No textual diff available"; "native.noTextualDifference" = "No textual difference"; @@ -6153,7 +6036,6 @@ "native.nodeJsLldOrLaterIsRequiredFound" = "Node.js %1$lld or later is required; found %2$@."; "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain" = "Node.js %1$lld or later was not found. Install it, then check again."; "native.nodeJsSupport" = "Node.js Support"; -"native.nonIntrospected" = "Non-Introspected"; "native.nonStreaming" = "Non-streaming"; "native.nonStreamingResponses" = "Non-streaming responses"; "native.none" = "None"; @@ -6162,7 +6044,6 @@ "native.notAssigned" = "Not Assigned"; "native.notAvailableForThisLitheOrMac" = "%1$@ (not available for this Lithe or Mac)"; "native.notAvailableYetDependenciesAreNotIndexed" = "Not available yet: dependencies are not indexed"; -"native.notConnected" = "Not connected"; "native.notDetected" = "Not detected"; "native.notFetchedYet" = "Not fetched yet."; "native.notFound" = "Not found"; @@ -6231,7 +6112,6 @@ "native.openAProjectToManageItsSkills" = "Open a project to manage its skills."; "native.openAProjectToSeeItsLanguageService" = "Open a project to see its language service."; "native.openAProjectToUseGit" = "Open a project to use Git"; -"native.openATableToInspectItsStructure" = "Open a table to inspect its structure"; "native.openAgain" = "Open again"; "native.openAgentSettings" = "Open Agent Settings"; "native.openAutoBuildDownloads" = "Open AutoBuild Downloads"; @@ -6315,7 +6195,6 @@ "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790" = "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root."; "native.pastePatch" = "Paste Patch"; "native.pasteStandardJsonWithAnMcpserversObjectChooseAServerToReviewBeforeSavingOnlyTheEditorSSupportedCommandArgumentsEnvironmentUrlAndHeaderFieldsAreImportedAuthenticationAndClientSpecificOptionsMustBeConfiguredSeparatelyImportingDoesNotTakeOwnershipOfAnExistingEntry" = "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry."; -"native.pasteTSVFromClipboard" = "Paste TSV from Clipboard"; "native.pasteThisIntoYourMCPClientConfiguration" = "Paste this into your MCP client configuration."; "native.pastedFiles" = "Pasted files"; "native.patchCanBeAppliedToTheSelectedDestination" = "Patch can be applied to the selected destination."; @@ -6379,7 +6258,6 @@ "native.preparingConversation" = "Preparing conversation…"; "native.preparingDownload" = "Preparing download…"; "native.preservesEveryCommitAndAddsAMergeCommitToTheBaseBranch" = "Preserves every commit and adds a merge commit to the base branch."; -"native.pressEnterOrClickOutsideTheListToApply" = "Press Enter or click outside the list to apply"; "native.pressShortcut" = "Press shortcut…"; "native.previewAndExport" = "Preview and Export…"; "native.previewAndExportTheSelectedLogsWithEnvironmentInformationCredentialsAndHomeDir65f650979b" = "Preview and export the selected logs with environment information. Credentials and home-directory paths are redacted; source code, editor buffers and terminal history are excluded."; @@ -6508,11 +6386,8 @@ "native.pushTo" = "Push to %1$@"; "native.pushXc68c0bf1cd" = "Push '%1$@'?"; "native.pythonSupport" = "Python Support"; -"native.queryConsole" = "Query Console"; -"native.queryHistory" = "%1$@ Query History"; "native.quickInformation" = "Quick Information"; "native.quickResponsesWithBasicReasoning" = "Quick responses with basic reasoning"; -"native.quickStart" = "Quick Start"; "native.quit" = "Quit"; "native.quitWithSelection" = "Quit with Selection"; "native.quittingLitheEndsThesePrograms" = "Quitting Lithe ends these programs."; @@ -6552,7 +6427,6 @@ "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase" = "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase."; "native.recent" = "Recent"; "native.recentProjects" = "Recent Projects"; -"native.recentSQL" = "Recent SQL"; "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78" = "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts."; "native.reconnect" = "Reconnect"; "native.reconnectThisAgentToRefreshItsModelList" = "Reconnect this Agent to refresh its model list."; @@ -6564,7 +6438,6 @@ "native.refreshAll" = "Refresh All"; "native.refreshChanges" = "Refresh changes"; "native.refreshConnectionStructure" = "Refresh connection structure"; -"native.refreshDatabases" = "Refresh databases"; "native.refreshDependencies" = "Refresh dependencies"; "native.refreshDetectedRuntimes" = "Refresh detected runtimes"; "native.refreshFiles" = "Refresh Files"; @@ -6577,7 +6450,6 @@ "native.refreshPreview" = "Refresh Preview"; "native.refreshProjectHistory" = "Refresh project history"; "native.refreshPullRequests" = "Refresh pull requests"; -"native.refreshSelectedStructure" = "Refresh selected structure"; "native.refreshSkills" = "Refresh skills"; "native.refreshWorktrees" = "Refresh worktrees"; "native.refreshed" = "Refreshed %1$@"; @@ -6652,7 +6524,6 @@ "native.renameChangeList" = "Rename ChangeList"; "native.renameChat" = "Rename Chat"; "native.renameConversation" = "Rename conversation"; -"native.renameFolder" = "Rename Folder"; "native.renamed" = "Renamed"; "native.reopenPullRequest" = "Reopen Pull Request"; "native.reopenPullRequest.variant2" = "Reopen pull request"; @@ -6665,7 +6536,6 @@ "native.replace" = "Replace"; "native.replaceCurrentCommitMessage" = "Replace current commit message?"; "native.replaceExistingContent" = "Replace existing content"; -"native.replaceInCurrentPage" = "Replace in Current Page…"; "native.replaceInFile" = "Replace in File"; "native.replaceInFile.variant2" = "Replace in File…"; "native.replaceInFiles" = "Replace in Files"; @@ -6734,10 +6604,8 @@ "native.restoreDefaultTemplate" = "Restore Default Template"; "native.restoreDefaultUILayout" = "Restore Default UI Layout"; "native.restorePath" = "Restore path"; -"native.restoreSQLBackup" = "Restore SQL Backup…"; "native.restoringGitHubConnection" = "Restoring GitHub connection"; "native.result" = "Result"; -"native.resultD" = "Result %1$d"; "native.results" = "Results"; "native.results.variant2" = "results"; "native.resume" = "Resume"; @@ -6841,10 +6709,7 @@ "native.sSH" = "SSH"; "native.sSHAgent" = "SSH Agent"; "native.sSHAuthentication" = "SSH Authentication"; -"native.sSHTLS" = "SSH / TLS"; -"native.safetyAndLabels" = "Safety and Labels"; "native.sampleInterval" = "Sample interval"; -"native.save" = "Save:"; "native.saveAfter" = "Save after"; "native.saveChangedFilesAutomatically" = "Save changed files automatically"; "native.saveChanges" = "Save Changes"; @@ -6857,7 +6722,6 @@ "native.saveMessage.variant2" = "Save message"; "native.saveMetadata" = "Save Metadata"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "Save or discard unsaved files before deleting this item"; -"native.savePassword" = "Save password"; "native.savePatch" = "Save Patch…"; "native.saveScope" = "Save scope"; "native.saveSharedInstructions" = "Save shared instructions"; @@ -6886,7 +6750,6 @@ "native.searchConnections" = "Search connections"; "native.searchConversation" = "Search conversation"; "native.searchConversationTitlesOrIDs" = "Search conversation titles or IDs…"; -"native.searchDatabases" = "Search databases"; "native.searchDiff" = "Search diff"; "native.searchEverywhere" = "Search Everywhere"; "native.searchEverywhere.variant2" = "Search Everywhere…"; @@ -6922,7 +6785,6 @@ "native.select.variant2" = "Select %1$@"; "native.selectAChangedFileFirst" = "Select a changed file first"; "native.selectACommit" = "Select a commit"; -"native.selectAConnectionInTheDatabaseTree" = "Select a connection in the database tree"; "native.selectAFile" = "Select a file"; "native.selectAFileToPreview" = "Select a file to preview"; "native.selectAFileToReview" = "Select a file to review"; @@ -6941,7 +6803,6 @@ "native.selectBranch" = "Select branch"; "native.selectConfigurationsForBatchActions" = "Select configurations for batch actions"; "native.selectConversation" = "Select conversation"; -"native.selectDatabases" = "Select databases"; "native.selectDebugInTheEditorContextMenu" = "— Select \"Debug...\" in the editor context menu"; "native.selectForCompare" = "Select for Compare"; "native.selectRepositoryForCommitsAndBranchOperations" = "Select repository for commits and branch operations"; @@ -7114,7 +6975,6 @@ "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying" = "Some MCP configuration files could not be restored: %1$@. Refresh and check these files before retrying."; "native.sortByLastUpdated" = "Sort by Last Updated"; "native.sortByName" = "Sort by Name"; -"native.sortConnections" = "Sort connections"; "native.sortPlugins" = "Sort plugins"; "native.source" = "Source"; "native.sourceCode" = "Source Code"; @@ -7124,7 +6984,6 @@ "native.speed" = "Speed"; "native.springBoot" = "Spring Boot"; "native.springEndpoints" = "Spring Endpoints"; -"native.sqlExecutionMarkers" = "SQL execution markers"; "native.sqlSupport" = "SQL Support"; "native.squash" = "Squash"; "native.squash1" = "Squash → 1"; @@ -7226,14 +7085,12 @@ "native.subjectMaximumLength" = "Subject maximum length"; "native.submittingReview" = "Submitting review…"; "native.succeeded" = "Succeeded"; -"native.succeededLineD" = "Succeeded, line %1$d"; "native.supplementaryMessageCancelled" = "Supplementary message cancelled."; "native.supportedPlaceholdersSummaryChangesTestingRisks" = "Supported placeholders: {summary}, {changes}, {testing}, {risks}."; "native.svelteSupport" = "Svelte Support"; "native.swapDirection" = "Swap Direction"; "native.swiftSupport" = "Swift Support"; "native.switchAgent" = "Switch Agent"; -"native.switchCurrentDatabase" = "Switch current database"; "native.switchProjectView" = "Switch project view"; "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain" = "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again."; "native.switched" = "Switched"; @@ -7277,7 +7134,6 @@ "native.testRunTimedOutAfterSeconds" = "%1$@ test run timed out after %2$@ seconds."; "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress" = "Test the connection to see the host's system, resources, and address."; "native.testing" = "Testing"; -"native.testingConnection" = "Testing connection…"; "native.tests" = "Tests"; "native.testsFailed" = "Tests failed"; "native.testsPassed" = "Tests passed"; @@ -7328,7 +7184,6 @@ "native.theCredentialStoreVersionIsUnsupportedExistingCredentialsWerePreserved" = "The credential store version is unsupported. Existing credentials were preserved."; "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "The current file will be saved to Local History before it is replaced."; "native.theCurrentWorktreeCannotBeRemovedHere" = "The current worktree cannot be removed here."; -"native.theDatabaseSelectionIsNoLongerAvailableRefreshAndTryAgain" = "The database selection is no longer available. Refresh and try again."; "native.theDefaultChangeListCannotBeRenamed" = "The default ChangeList cannot be renamed."; "native.theDownloadedAppDoesNotMatchTheSelectedStableReleaseNothingWasInstalled" = "The downloaded app does not match the selected stable release. Nothing was installed."; "native.theDownloadedDiskImageDoesNotContainLitheApp" = "The downloaded disk image does not contain Lithe.app."; @@ -7411,10 +7266,8 @@ "native.theSelectedBackgroundImageIsUnavailableAndWasRemoved" = "The selected background image is unavailable and was removed."; "native.theSelectedBranchDiffIsSentToTheActiveAIProviderWhenYouGenerate" = "The selected branch diff is sent to the active AI provider when you generate."; "native.theSelectedBranchesHaveNoTextualChangesToSummarize" = "The selected branches have no textual changes to summarize."; -"native.theSelectedConnectionFolderNoLongerExists" = "The selected connection folder no longer exists."; "native.theSelectedFileChangedBeforeGenerationFinished" = "The selected file changed before generation finished"; "native.theSelectedFileHasNoTextualDiffToSummarize" = "The selected file has no textual diff to summarize."; -"native.theSelectedFolderCannotBeMovedThere" = "The selected folder cannot be moved there."; "native.theSelectedTableHasNoColumnsToDisplay" = "The selected table has no columns to display."; "native.theSelectedVersionsMatch" = "The selected versions match"; "native.theServerAskedAgainCheckThePreviousAnswer" = "The server asked again. Check the previous answer."; @@ -7546,8 +7399,6 @@ "native.timeoutSeconds" = "Timeout (seconds)"; "native.title" = "Title"; "native.toDebugYourCodeDoOneOfTheFollowing" = "To debug your code, do one of the following:"; -"native.toFolder" = "To Folder…"; -"native.toTopLevel" = "To Top Level"; "native.toTrackChangesToCode" = "To track changes to code:"; "native.today" = "Today"; "native.toggle" = "Toggle %1$@"; @@ -7656,7 +7507,6 @@ "native.unstaged" = "Unstaged"; "native.unstagedChanges" = "Unstaged changes"; "native.unsupportedContent" = "Unsupported content"; -"native.untilRestart" = "Until restart"; "native.untilUTCISO8601" = "Until (UTC ISO 8601)"; "native.untitledConversation" = "Untitled conversation"; "native.untracked" = "Untracked"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f579efc75..a48a26065 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -10,7 +10,6 @@ "%@ (%@ failed)" = "%@(失败 %@)"; "%@ (%lld)" = "%@(%lld)"; "%@ (not available for this Lithe or Mac)" = "%@(当前 Lithe 或这台 Mac 无法使用)"; -"%@ Query History" = "%@ 查询历史"; "%@ already exists. Choose another name or location." = "%@ 已存在,请更换名称或位置。"; "%@ answered with status %lld. %@" = "%1$@ 返回状态 %2$lld。%3$@"; "%@ build files changed" = "%@ 构建文件已更改"; @@ -55,7 +54,6 @@ "%d lines of its own" = "%d 行自有内容"; "%d messages" = "%d 条消息"; "%d minutes ago" = "%d分钟前"; -"%d rows retrieved in %d ms" = "取回 %d 行,耗时 %d 毫秒"; "%d selected" = "已选 %d 项"; "%d tools" = "%d 个工具"; "%lld CPU cores" = "%lld 核 CPU"; @@ -103,7 +101,6 @@ "%lldh %lldm %llds" = "%lld小时 %lld分 %lld秒"; "%lldm %llds" = "%lld分 %lld秒"; "%llds" = "%lld秒"; -"%llu rows affected in %d ms" = "影响 %llu 行,耗时 %d 毫秒"; ", " = ","; "0.5 seconds" = "0.5 秒"; "1 file" = "1 个文件"; @@ -123,7 +120,6 @@ "A basic project with a free structure." = "具有自由结构的基本项目。"; "A custom Agent can follow either your Codex or your Claude configuration." = "自定义 Agent 可以跟随你本机的 Codex 或 Claude 配置。"; "A detailed message with a clear subject and body" = "带有清晰标题和正文的详细说明"; -"A folder with this name already exists." = "已存在同名文件夹。"; "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply." = "主机名、IP 地址,或 ~/.ssh/config 中的 Host 别名。该文件里的 ProxyJump、IdentityFile 等设置仍然生效。"; "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing." = "Lithe 管理的 Hook 已在外部修改,请先恢复或移除该原生条目再同步。"; "A message creates an annotated tag." = "填写说明将创建附注标签。"; @@ -253,7 +249,6 @@ "All modules" = "全部模块"; "All projects" = "所有项目"; "All projects · project can override" = "所有项目 · 项目可覆盖"; -"All schemas" = "所有架构"; "All servers" = "所有服务器"; "All sessions" = "全部会话"; "All severities" = "全部级别"; @@ -367,7 +362,6 @@ "Average time to first token (TTFT)" = "首 token 平均(TTFT)"; "Average total" = "平均总计"; "Back" = "后退"; -"Back Up Database as SQL…" = "将数据库备份为 SQL…"; "Back to all references" = "返回所有引用"; "Back to the conversation" = "返回对话"; "Background Services" = "后台服务"; @@ -403,7 +397,6 @@ "Breakdown unavailable" = "分项暂不可用"; "Breakpoint actions" = "断点操作"; "Breakpoints" = "断点"; -"Browse Database" = "浏览数据库"; "Browse…" = "浏览…"; "Bug report" = "错误报告"; "Build" = "构建"; @@ -596,7 +589,6 @@ "Clear date filter" = "清除日期筛选"; "Clear debug output" = "清除调试输出"; "Clear log search" = "清除日志搜索"; -"Clear output" = "清除输出"; "Clear path filter" = "清除路径筛选"; "Clear run output" = "清除运行输出"; "Clear search" = "清除搜索"; @@ -666,13 +658,11 @@ "Collapse All" = "全部折叠"; "Collapse JSON block" = "收起 JSON 段落"; "Collapse all" = "全部折叠"; -"Collapse database" = "折叠数据库"; "Collapse directory" = "折叠目录"; "Collapse long graph edges" = "收束长连线"; "Collapse notification" = "收起通知"; "Collapse repository" = "折叠仓库"; "Collapse section" = "折叠分组"; -"Collapse table" = "折叠表"; "Collapsed" = "已折叠"; "Collected logs cleared." = "已清空收集的日志。"; "Color theme" = "配色主题"; @@ -719,7 +709,6 @@ "Committer name" = "提交者姓名"; "Committing…" = "正在提交…"; "Commit…" = "提交…"; -"Common Actions" = "常用操作"; "Community" = "社区"; "Community / Disk" = "社区 / 磁盘"; "Community plugin installed from disk. Its publisher and repository have not been verified." = "从磁盘安装的社区插件,尚未验证发布者与源码仓库。"; @@ -774,11 +763,7 @@ "Connecting…" = "正在连接…"; "Connection" = "连接"; "Connection recovery is taking longer. The Agent is still retrying." = "连接恢复等待较久,Agent 仍在重试。"; -"Connection successful" = "连接成功"; -"Connection test duration: %lld ms" = "连接检测耗时:%lld 毫秒"; -"Connection test result" = "连接测试结果"; "Connection timeout" = "连接超时"; -"Connections" = "连接"; "Connections: %lld" = "连接:%lld"; "Console" = "控制台"; "Content" = "内容"; @@ -940,12 +925,8 @@ "Danger Zone" = "危险操作"; "Dark" = "深色"; "Dart Support" = "Dart 支持"; -"Data Source" = "数据源"; -"Data Tools" = "数据工具"; "Database" = "数据库连接"; -"Database Overview" = "数据库概览"; "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it." = "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。"; -"Database types" = "数据库类型"; "Date" = "日期"; "Debug" = "调试"; "Debug target" = "调试目标"; @@ -1077,7 +1058,6 @@ "Don't ask again" = "不再询问"; "Done" = "完成"; "Don’t Allow" = "不允许"; -"Double-click a table to view data" = "在数据库树中双击表以查看数据"; "Download" = "下载"; "Download %@" = "下载 %@"; "Download PHP Support from the official plugin release." = "从官方插件版本下载 PHP 支持。"; @@ -1101,7 +1081,6 @@ "Drop this shelf?" = "丢弃此 Shelf 吗?"; "Dropped: %lld; write failures: %lld" = "已丢弃:%lld;写入失败:%lld"; "Duplicate" = "复制"; -"Duplicate Connection" = "复制连接"; "Duration: %lld ms" = "耗时:%lld 毫秒"; "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first." = "每个 Agent 使用各自的配置文件。关闭某个 Agent 只会移除 Lithe 写入的条目;在 Lithe 之外创建的条目必须先接管所有权才会被移除。"; "Each built-in preset includes a preview of the generated message." = "三个内置预设都会显示生成信息示例。"; @@ -1121,7 +1100,6 @@ "Edit Commit Message" = "编辑提交消息"; "Edit Commit Message…" = "编辑提交消息…"; "Edit Configuration…" = "编辑运行配置…"; -"Edit Connection" = "编辑连接"; "Edit Hook" = "编辑 Hook"; "Edit MCP server" = "编辑 MCP 服务器"; "Edit Service" = "编辑服务"; @@ -1236,20 +1214,16 @@ "Expand %lld lines" = "展开中间 %lld 行"; "Expand All" = "全部展开"; "Expand JSON block" = "展开 JSON 段落"; -"Expand database" = "展开数据库"; "Expand directory" = "展开目录"; "Expand notification" = "展开通知"; "Expand repository" = "展开仓库"; "Expand section" = "展开分组"; -"Expand table" = "展开表"; "Expand temporary Git configuration or command details" = "展开临时 Git 参数或命令详情"; "Expanded" = "已展开"; "Explain the intent, testing, and anything reviewers should know…" = "说明更改目的、测试情况,以及审查者需要了解的内容…"; "Explain what must change before this pull request can be approved." = "说明在批准此拉取请求之前必须修改的内容。"; "Export Diagnostics Bundle" = "导出诊断包"; "Export Diagnostics Bundle…" = "导出诊断包…"; -"Export Table as CSV…" = "将表导出为 CSV…"; -"Export Table as JSON…" = "将表导出为 JSON…"; "Export as PDF…" = "导出为 PDF…"; "Export conversations" = "导出会话"; "Export logs" = "导出日志"; @@ -1265,7 +1239,6 @@ "F# Support" = "F# 支持"; "Failed" = "失败"; "Failed to start; check the language server configuration" = "启动失败,请检查语言服务器配置"; -"Failed, line %d" = "失败,第 %d 行"; "Failures" = "失败详情"; "Fast" = "快速"; "Fast mode" = "快速模式"; @@ -1319,7 +1292,6 @@ "Files will stay unchanged. Nothing will be staged or committed automatically." = "文件内容保持不变,不会自动暂存或提交。"; "Filter by changed path" = "按更改路径筛选"; "Filter by title, author, or label" = "按标题、作者或标签筛选"; -"Filter database type" = "筛选数据库类型"; "Filter log messages" = "筛选日志内容"; "Filter problems by severity" = "按严重级别筛选问题"; "Filter results" = "筛选结果"; @@ -1344,7 +1316,6 @@ "Fixup" = "合并并丢弃消息"; "Folder" = "分组"; "Folder name" = "文件夹名称"; -"Folder name is required." = "请输入文件夹名称。"; "Follow Git setting" = "遵循 Git 配置"; "Follow Git tag rules" = "遵循 Git 标签规则"; "Follow the instructions you define below" = "遵循你在下方定义的指令"; @@ -1354,10 +1325,8 @@ "For example, notify" = "例如 notify"; "For example, notify me when a turn ends" = "例如:一轮结束时提醒我"; "Force Checkout" = "强制检出"; -"Force Refresh" = "强制刷新"; "Force Remove" = "强制删除"; "Force remove worktree?" = "强制删除工作树?"; -"Forever" = "永久"; "Forget" = "忘记"; "Fork conversation" = "分叉会话"; "Format" = "格式"; @@ -1529,14 +1498,10 @@ "Ignored entries: %lld" = "已忽略条目:%lld"; "Imperative subject" = "动作式标题"; "Implementations" = "实现"; -"Import CSV…" = "导入 CSV…"; -"Import Data Sources…" = "导入数据源…"; "Import JSON" = "导入 JSON"; -"Import JSON…" = "导入 JSON…"; "Import MCP configuration" = "导入 MCP 配置"; "Import SKILL.md" = "导入 SKILL.md"; "Import a regular file or directory instead of a symbolic link." = "请导入普通文件或目录,而不是符号链接。"; -"Import connections from DBX" = "从 DBX 导入连接"; "Import from Claude" = "从 Claude 导入"; "Import from Codex" = "从 Codex 导入"; "Import skill" = "导入技能"; @@ -1992,12 +1957,10 @@ "New Directory…" = "新建目录…"; "New File…" = "新建文件…"; "New Folder" = "新建文件夹"; -"New Folder…" = "新建文件夹…"; "New Hook" = "新 Hook"; "New MCP server" = "新建 MCP 服务器"; "New Project" = "新建项目"; "New Project…" = "新建项目…"; -"New Query" = "新建查询"; "New Run Configuration" = "新建运行配置"; "New SSH Connection" = "新建 SSH 连接"; "New Tag" = "新建标签"; @@ -2049,8 +2012,6 @@ "No Remote Branches" = "没有远程分支"; "No SSH connections yet." = "还没有 SSH 连接。"; "No Spring Boot or Maven Module configurations" = "没有 Spring Boot 或 Maven Module 配置"; -"No Table Selected" = "未选择表"; -"No accessible databases" = "没有可访问的数据库"; "No available local branch. Branches already checked out in another worktree cannot be reused." = "没有可用的本地分支。已在其他工作树检出的分支不能重复使用。"; "No background image selected" = "未选择背景图片"; "No branches found" = "没有匹配的分支"; @@ -2058,7 +2019,6 @@ "No callable target at this location" = "当前位置没有可调用目标"; "No changed files" = "没有更改的文件"; "No changes" = "没有更改"; -"No columns to display" = "没有可显示的列"; "No command" = "无命令"; "No commit selected" = "未选择提交"; "No commits" = "没有提交记录"; @@ -2071,7 +2031,6 @@ "No credential found in Codex configuration" = "Codex 配置中未找到凭据"; "No current branch" = "没有当前分支"; "No custom providers yet." = "尚未添加自定义供应商。"; -"No databases loaded" = "尚未加载数据库"; "No default shell was detected. Choose a detected shell or run detection again." = "未检测到系统默认 Shell。请选择已检测到的 Shell,或重新执行检测。"; "No dependencies" = "无依赖"; "No dependencies." = "没有依赖。"; @@ -2127,7 +2086,6 @@ "No published release is available yet." = "暂时没有已发布的版本。"; "No pull requests" = "暂无拉取请求"; "No pull requests match the selected state." = "当前状态下没有拉取请求。"; -"No query output" = "暂无查询输出"; "No recent progress has been received. The task is still active." = "暂未收到新的进度,任务仍在进行。"; "No recent projects" = "没有最近项目"; "No release is available yet" = "暂时没有可用版本"; @@ -2139,7 +2097,6 @@ "No runnable services were detected in this project." = "未在此项目中检测到可运行的服务。"; "No running debug process accepts standard input" = "没有正在运行且接受标准输入的调试进程"; "No saved changes" = "没有已保存的更改"; -"No schemas selected" = "未选择架构"; "No settings found" = "未找到设置"; "No shell was found at %@. Choose a detected shell or run detection again." = "在 %@ 未找到 Shell。请选择已检测到的 Shell,或重新执行检测。"; "No shelves" = "没有 Shelf"; @@ -2161,7 +2118,6 @@ "Node.js %lld or later is required; found %@." = "需要 Node.js %lld 或更高版本,当前为 %@。"; "Node.js %lld or later was not found. Install it, then check again." = "未找到 Node.js %lld 或更高版本。请安装后重新检测。"; "Node.js Support" = "Node.js 支持"; -"Non-Introspected" = "未内省"; "Non-streaming" = "非流式"; "Non-streaming responses" = "非流式响应"; "None" = "无"; @@ -2170,7 +2126,6 @@ "Not Assigned" = "未分配"; "Not Supported" = "不支持"; "Not available yet: dependencies are not indexed" = "尚不可用:依赖尚未建立索引"; -"Not connected" = "未连接"; "Not detected" = "未检测"; "Not fetched yet." = "尚未获取。"; "Not found" = "未找到"; @@ -2259,7 +2214,6 @@ "Open a project to manage its skills." = "请先打开项目,再管理项目技能。"; "Open a project to see its language service." = "打开项目后可查看其语言服务。"; "Open a project to use Git" = "请打开项目以使用 Git"; -"Open a table to inspect its structure" = "打开表以查看其结构"; "Open again" = "重新打开"; "Open file" = "打开文件"; "Open file in editor to see problems" = "在编辑器中打开文件以查看问题"; @@ -2317,7 +2271,6 @@ "Password or passphrase" = "密码或密钥口令"; "Paste" = "粘贴"; "Paste Patch" = "粘贴补丁"; -"Paste TSV from Clipboard" = "从剪贴板粘贴 TSV"; "Paste a JSON object containing mcpServers." = "请粘贴包含 mcpServers 的 JSON 对象。"; "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root." = "粘贴 https 插件索引地址或 GitHub 仓库地址。GitHub 仓库的根目录需要包含 lithe-plugin-index.json。"; "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry." = "粘贴包含 mcpServers 的标准 JSON,选择服务器并检查后再保存。只导入编辑器支持的命令、参数、环境变量、URL 和请求头;认证及客户端专有选项需另行配置。导入不会接管已有条目。"; @@ -2382,7 +2335,6 @@ "Preparing conversation…" = "正在准备对话…"; "Preparing download…" = "正在准备下载…"; "Preserves every commit and adds a merge commit to the base branch." = "保留全部提交,并在目标分支中添加一个合并提交。"; -"Press Enter or click outside the list to apply" = "按 Enter 或点击列表外应用"; "Press shortcut…" = "请按下快捷键…"; "Preview" = "预览"; "Preview Update" = "预览版更新"; @@ -2511,9 +2463,7 @@ "Push to %@" = "推送到 %@"; "Push…" = "推送…"; "Python Support" = "Python 支持"; -"Query Console" = "查询控制台"; "Quick Information" = "快速信息"; -"Quick Start" = "快速开始"; "Quick responses with basic reasoning" = "快速响应,基础推理"; "Quit" = "退出"; "Quit with Selection" = "按选择退出"; @@ -2553,7 +2503,6 @@ "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase." = "变基需要干净的工作区。选择变基前,请先提交或暂存本地更改。"; "Recent" = "最近"; "Recent Projects" = "最近项目"; -"Recent SQL" = "最近的 SQL"; "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts." = "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。"; "Reconnect" = "重新连接"; "Reconnect this Agent to refresh its model list." = "重新连接此 Agent 即可刷新模型列表。"; @@ -2572,14 +2521,12 @@ "Refresh Preview" = "刷新预览"; "Refresh changes" = "刷新更改"; "Refresh connection structure" = "刷新连接结构"; -"Refresh databases" = "刷新数据库"; "Refresh dependencies" = "刷新依赖"; "Refresh detected runtimes" = "刷新运行时检测结果"; "Refresh history" = "刷新历史"; "Refresh local history" = "刷新本地历史"; "Refresh project history" = "刷新项目历史"; "Refresh pull requests" = "刷新拉取请求"; -"Refresh selected structure" = "刷新选中对象的结构"; "Refresh skills" = "刷新技能"; "Refresh worktrees" = "刷新工作树"; "Refreshed %@" = "已于 %@ 刷新"; @@ -2655,7 +2602,6 @@ "Rename Branch" = "重命名分支"; "Rename ChangeList" = "重命名更改列表"; "Rename Chat" = "重命名对话"; -"Rename Folder" = "重命名文件夹"; "Rename conversation" = "重命名会话"; "Renamed" = "已重命名"; "Rename…" = "重命名…"; @@ -2670,7 +2616,6 @@ "Replace" = "替换"; "Replace current commit message?" = "替换当前提交信息?"; "Replace existing content" = "替换现有内容"; -"Replace in Current Page…" = "在当前页替换…"; "Replace in File" = "在文件中替换"; "Replace in Files" = "在项目中替换"; "Replace in File…" = "在文件中替换…"; @@ -2738,12 +2683,10 @@ "Restore Default Template" = "恢复默认模板"; "Restore Default UI Layout" = "恢复默认 UI 布局"; "Restore Defaults" = "恢复默认设置"; -"Restore SQL Backup…" = "恢复 SQL 备份…"; "Restore conversations" = "恢复会话"; "Restore path" = "恢复路径"; "Restoring GitHub connection" = "正在恢复 GitHub 连接"; "Result" = "结果"; -"Result %d" = "结果 %d"; "Results" = "测试结果"; "Resume" = "恢复执行"; "Resume the paused debug session" = "继续已暂停的调试会话"; @@ -2850,13 +2793,10 @@ "SKILL.md needs a name and description." = "SKILL.md 需要技能名称和说明。"; "SKILL.md needs instructions after its frontmatter." = "请在 SKILL.md 的文件头后添加技能正文。"; "SQL Support" = "SQL 支持"; -"SQL execution markers" = "SQL 执行状态标记"; "SSH" = "SSH"; -"SSH / TLS" = "SSH / TLS"; "SSH Agent" = "SSH Agent"; "SSH Authentication" = "SSH 认证"; "STAGED" = "已暂存"; -"Safety and Labels" = "安全与标记"; "Sample interval" = "采样间隔"; "Save" = "保存"; "Save Changes" = "保存更改"; @@ -2872,12 +2812,10 @@ "Save local changes with" = "本地更改保存方式"; "Save message" = "保存信息"; "Save or discard unsaved files before deleting this item" = "删除此项目之前,请先保存或丢弃未保存的文件"; -"Save password" = "保存密码"; "Save scope" = "保存范围"; "Save shared instructions" = "保存共享指令"; "Save the active document" = "保存当前文档"; "Save title" = "保存标题"; -"Save:" = "保存:"; "Saved" = "已保存"; "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared." = "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。"; "Saved in .lithe/run/local.json and excluded from Git." = "保存在 .lithe/run/local.json,并已从 Git 中排除。"; @@ -2906,7 +2844,6 @@ "Search connections" = "搜索连接"; "Search conversation" = "搜索对话"; "Search conversation titles or IDs…" = "搜索会话标题或 ID…"; -"Search databases" = "搜索数据库"; "Search diff" = "搜索 Diff"; "Search exclusions" = "搜索排除规则"; "Search files and contents" = "搜索文件和内容"; @@ -2941,7 +2878,6 @@ "Select a Spring Boot or Maven Module configuration before starting Debug" = "开始调试前请选择 Spring Boot 或 Maven Module 配置"; "Select a changed file first" = "请先选择一个更改文件"; "Select a commit" = "选择提交"; -"Select a connection in the database tree" = "在数据库树中选择连接"; "Select a file" = "选择文件"; "Select a file to preview" = "选择文件以预览"; "Select a file to review" = "选择文件以查看"; @@ -2956,7 +2892,6 @@ "Select branch" = "选择分支"; "Select configurations for batch actions" = "勾选配置以批量操作"; "Select conversation" = "选择会话"; -"Select databases" = "选择数据库"; "Select for Compare" = "选择以进行比较"; "Select repository for commits and branch operations" = "选择用于提交和分支操作的仓库"; "Select run configuration" = "选择运行配置"; @@ -3120,7 +3055,6 @@ "Some dependencies are not shown." = "部分依赖未显示。"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; -"Sort connections" = "连接排序"; "Sort plugins" = "插件排序"; "Source" = "配置来源"; "Source Code" = "源代码"; @@ -3228,14 +3162,12 @@ "Subject maximum length" = "标题最大长度"; "Submitting review…" = "正在提交审查…"; "Succeeded" = "成功"; -"Succeeded, line %d" = "成功,第 %d 行"; "Supplementary message cancelled." = "补充消息已取消。"; "Supported placeholders: {summary}, {changes}, {testing}, {risks}." = "支持的占位符:{summary}、{changes}、{testing}、{risks}。"; "Svelte Support" = "Svelte 支持"; "Swap Direction" = "交换方向"; "Swift Support" = "Swift 支持"; "Switch Agent" = "切换 Agent"; -"Switch current database" = "切换当前数据库"; "Switch project view" = "切换项目视图"; "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again." = "请切换到安装此 CLI 的 Node.js/npm 环境,在该环境中更新后重新检测。"; "Switched" = "已切换"; @@ -3278,7 +3210,6 @@ "Test process tree · Resident memory" = "测试进程树 · 驻留内存"; "Test the connection to see the host's system, resources, and address." = "测试连接后,这里会显示主机的系统、资源和地址。"; "Testing" = "测试"; -"Testing connection…" = "正在测试连接…"; "Tests" = "测试"; "Tests failed" = "测试失败"; "Tests passed" = "测试通过"; @@ -3349,7 +3280,6 @@ "The credential store version is unsupported. Existing credentials were preserved." = "凭据存储版本暂不支持,已保留原有凭据。"; "The current file will be saved to Local History before it is replaced." = "替换当前文件前,会将其保存到本地历史。"; "The current worktree cannot be removed here." = "不能在这里删除当前工作树。"; -"The database selection is no longer available. Refresh and try again." = "数据库选择已失效,请刷新后重试。"; "The default ChangeList cannot be renamed." = "默认更改列表不能重命名。"; "The downloaded app does not match the selected stable release. Nothing was installed." = "下载的应用与所选正式版不符,未执行安装。"; "The downloaded disk image does not contain Lithe.app." = "下载的磁盘映像中没有 Lithe.app。"; @@ -3414,10 +3344,8 @@ "The selected background image is unavailable and was removed." = "所选背景图片不可用,已移除。"; "The selected branch diff is sent to the active AI provider when you generate." = "生成时,所选分支的差异内容会发送给当前 AI 服务商。"; "The selected branches have no textual changes to summarize." = "所选分支之间没有可供总结的文本更改。"; -"The selected connection folder no longer exists." = "所选连接文件夹已不存在。"; "The selected file changed before generation finished" = "生成完成前,选中的文件已发生变化"; "The selected file has no textual diff to summarize." = "所选文件没有可用于总结的文本 Diff。"; -"The selected folder cannot be moved there." = "无法将所选文件夹移动到该位置。"; "The selected table has no columns to display." = "所选表没有可显示的列。"; "The selected versions match" = "所选版本相同"; "The server asked again. Check the previous answer." = "服务器再次提问,请检查上一次的回答。"; @@ -3544,8 +3472,6 @@ "Timed Out" = "已超时"; "Timeout (seconds)" = "超时(秒)"; "Title" = "标题"; -"To Folder…" = "移至文件夹…"; -"To Top Level" = "移至顶层"; "To debug your code, do one of the following:" = "要调试代码,请执行以下操作之一:"; "To track changes to code:" = "要跟踪代码更改:"; "Today" = "今天"; @@ -3654,7 +3580,6 @@ "Unstaged changes" = "未暂存更改"; "Unsupported content" = "不支持的内容"; "Until (UTC ISO 8601)" = "结束时间(UTC ISO 8601)"; -"Until restart" = "直到重启"; "Untitled conversation" = "未命名对话"; "Untracked" = "未跟踪"; "Unused code" = "未使用的代码"; @@ -3915,7 +3840,6 @@ "native.aChangeListWithThisNameAlreadyExists" = "已存在同名的更改列表。"; "native.aCustomAgentCanFollowEitherYourCodexOrYourClaudeConfiguration" = "自定义 Agent 可以跟随你本机的 Codex 或 Claude 配置。"; "native.aDetailedMessageWithAClearSubjectAndBody" = "带有清晰标题和正文的详细说明"; -"native.aFolderWithThisNameAlreadyExists" = "已存在同名文件夹。"; "native.aHookWithThisNameAlreadyExists" = "已存在同名 Hook。"; "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit" = "主机名、IP 地址,或 ~/.ssh/config 中的 Host 别名。该文件里的 ProxyJump、IdentityFile 等设置仍然生效。"; "native.aJavaProjectWithNoBuildToolMavenOrGradle" = "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。"; @@ -4036,7 +3960,6 @@ "native.allProjectsProjectCanOverride" = "所有项目 · 项目可覆盖"; "native.allReferences" = "所有引用"; "native.allRepositories" = "全部仓库"; -"native.allSchemas" = "所有架构"; "native.allServers" = "所有服务器"; "native.allSessions" = "全部会话"; "native.allSeverities" = "全部级别"; @@ -4173,7 +4096,6 @@ "native.avg" = "平均"; "native.backToAllReferences" = "返回所有引用"; "native.backToTheConversation" = "返回对话"; -"native.backUpDatabaseAsSQL" = "将数据库备份为 SQL…"; "native.backgroundServices" = "后台服务"; "native.backgroundServices.variant2" = "后台服务"; "native.backgroundServicesStartedInLitheAreStillRunning" = "在 Lithe 中启动的后台服务仍在运行"; @@ -4209,7 +4131,6 @@ "native.breakpointActions" = "断点操作"; "native.breakpoints" = "断点"; "native.browse" = "浏览…"; -"native.browseDatabase" = "浏览数据库"; "native.bugReport" = "错误报告"; "native.build" = "构建"; "native.build.variant2" = "构建 %1$@ → %2$@"; @@ -4398,7 +4319,6 @@ "native.clearGitConsole" = "清空 Git 控制台"; "native.clearLogSearch" = "清除日志搜索"; "native.clearLogs" = "清空日志"; -"native.clearOutput" = "清除输出"; "native.clearOverride" = "清除覆盖"; "native.clearPathFilter" = "清除路径筛选"; "native.clearRunOutput" = "清除运行输出"; @@ -4470,14 +4390,12 @@ "native.collaborationMode" = "协作模式"; "native.collapse" = "折叠"; "native.collapseAll" = "全部折叠"; -"native.collapseDatabase" = "折叠数据库"; "native.collapseDirectory" = "折叠目录"; "native.collapseJSONBlock" = "收起 JSON 段落"; "native.collapseLongGraphEdges" = "收束长连线"; "native.collapseNotification" = "收起通知"; "native.collapseRepository" = "折叠仓库"; "native.collapseSection" = "折叠分组"; -"native.collapseTable" = "折叠表"; "native.collapsed" = "已折叠"; "native.collectedLogsCleared" = "已清空收集的日志。"; "native.colorTheme" = "配色主题"; @@ -4526,7 +4444,6 @@ "native.committerEmail" = "提交者邮箱"; "native.committerName" = "提交者姓名"; "native.committing" = "正在提交…"; -"native.commonActions" = "常用操作"; "native.community" = "社区"; "native.communityDisk" = "社区 / 磁盘"; "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified" = "从磁盘安装的社区插件,尚未验证发布者与源码仓库。"; @@ -4581,11 +4498,7 @@ "native.connecting" = "正在连接…"; "native.connection" = "连接"; "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying" = "连接恢复等待较久,Agent 仍在重试。"; -"native.connectionSuccessful" = "连接成功"; -"native.connectionTestDurationLldMs" = "连接检测耗时:%1$lld 毫秒"; -"native.connectionTestResult" = "连接测试结果"; "native.connectionTimeout" = "连接超时"; -"native.connections" = "连接"; "native.connectionsLld" = "连接:%1$lld"; "native.console" = "控制台"; "native.content" = "内容"; @@ -4748,18 +4661,13 @@ "native.dConversations" = "共 %1$d 个会话"; "native.dMessages" = "%1$d 条消息"; "native.dMinutesAgo" = "%1$d分钟前"; -"native.dRowsRetrievedInDMs" = "取回 %1$d 行,耗时 %2$d 毫秒"; "native.dSelected" = "已选 %1$d 项"; "native.daemon" = "守护进程"; "native.dangerZone" = "危险操作"; "native.dark" = "深色"; "native.dartSupport" = "Dart 支持"; -"native.dataSource" = "数据源"; -"native.dataTools" = "数据工具"; "native.database" = "数据库连接"; "native.databaseIsNowAPluginInstallItFromTheMarketplace" = "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。"; -"native.databaseOverview" = "数据库概览"; -"native.databaseTypes" = "数据库类型"; "native.date" = "日期"; "native.debug" = "调试"; "native.debugResume" = "调试:继续"; @@ -4895,7 +4803,6 @@ "native.doesNotProvideArchivedConversations" = "%1$@ 不支持归档会话。"; "native.donTAllow" = "不允许"; "native.donTAskAgain" = "不再询问"; -"native.doubleClickATableToViewData" = "在数据库树中双击表以查看数据"; "native.download" = "下载"; "native.download.variant2" = "下载 %1$@"; "native.downloadAndInstall" = "下载并安装"; @@ -4919,7 +4826,6 @@ "native.dropThisShelf" = "丢弃此 Shelf 吗?"; "native.droppedLldWriteFailuresLld" = "已丢弃:%1$lld;写入失败:%2$lld"; "native.duplicate" = "复制"; -"native.duplicateConnection" = "复制连接"; "native.duplicateOmitted" = "重复依赖已省略"; "native.durationLldMs" = "耗时:%1$lld 毫秒"; "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1" = "每个 Agent 使用各自的配置文件。关闭某个 Agent 只会移除 Lithe 写入的条目;在 Lithe 之外创建的条目必须先接管所有权才会被移除。"; @@ -4938,7 +4844,6 @@ "native.editCommitMessage.variant2" = "编辑提交消息…"; "native.editConfiguration" = "编辑运行配置…"; "native.editConfigurationText" = "编辑配置文本"; -"native.editConnection" = "编辑连接"; "native.editExceptionBreakpoint" = "编辑异常断点 %1$@"; "native.editExceptionBreakpoint.variant2" = "编辑异常断点"; "native.editFieldBreakpoint" = "编辑字段断点 %1$@"; @@ -5051,14 +4956,12 @@ "native.exitCodeLld" = "退出码 %1$lld"; "native.exitedWithStatusD" = "%1$@ 以状态 %2$d 退出。\n%3$@"; "native.expand" = "展开"; -"native.expandDatabase" = "展开数据库"; "native.expandDirectory" = "展开目录"; "native.expandJSONBlock" = "展开 JSON 段落"; "native.expandLldLines" = "展开中间 %1$lld 行"; "native.expandNotification" = "展开通知"; "native.expandRepository" = "展开仓库"; "native.expandSection" = "展开分组"; -"native.expandTable" = "展开表"; "native.expandTemporaryGitConfigurationOrCommandDetails" = "展开临时 Git 参数或命令详情"; "native.expanded" = "已展开"; "native.explainTheIntentTestingAndAnythingReviewersShouldKnow" = "说明更改目的、测试情况,以及审查者需要了解的内容…"; @@ -5069,8 +4972,6 @@ "native.exportDiagnosticsBundle" = "导出诊断包"; "native.exportDiagnosticsBundle.variant2" = "导出诊断包…"; "native.exportLogs" = "导出日志"; -"native.exportTableAsCSV" = "将表导出为 CSV…"; -"native.exportTableAsJSON" = "将表导出为 JSON…"; "native.exportingConversations" = "正在导出会话…"; "native.exportsTheIndexAgainstHEAD" = "导出暂存区相对 HEAD 的更改。"; "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles" = "导出工作树相对 HEAD 的最终更改,包含所选的未跟踪文件。"; @@ -5082,7 +4983,6 @@ "native.fSupport" = "F# 支持"; "native.failed" = "%1$@(失败 %2$@)"; "native.failed.variant2" = "失败"; -"native.failedLineD" = "失败,第 %1$d 行"; "native.failedToStartCheckTheLanguageServerConfiguration" = "启动失败,请检查语言服务器配置"; "native.failures" = "失败详情"; "native.fast" = "快速"; @@ -5139,7 +5039,6 @@ "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically" = "文件内容保持不变,不会自动暂存或提交。"; "native.filterByChangedPath" = "按更改路径筛选"; "native.filterByTitleAuthorOrLabel" = "按标题、作者或标签筛选"; -"native.filterDatabaseType" = "筛选数据库类型"; "native.filterLogMessages" = "筛选日志内容"; "native.filterProblemsBySeverity" = "按严重级别筛选问题"; "native.filterResults" = "筛选结果"; @@ -5165,7 +5064,6 @@ "native.fixup" = "合并并丢弃消息"; "native.folder" = "分组"; "native.folderName" = "文件夹名称"; -"native.folderNameIsRequired" = "请输入文件夹名称。"; "native.followGitSetting" = "遵循 Git 配置"; "native.followGitTagRules" = "遵循 Git 标签规则"; "native.followTheInstructionsYouDefineBelow" = "遵循你在下方定义的指令"; @@ -5174,10 +5072,8 @@ "native.forExampleNotify" = "例如 notify"; "native.forExampleNotifyMeWhenATurnEnds" = "例如:一轮结束时提醒我"; "native.forceCheckout" = "强制检出"; -"native.forceRefresh" = "强制刷新"; "native.forceRemove" = "强制删除"; "native.forceRemoveWorktree" = "强制删除工作树?"; -"native.forever" = "永久"; "native.forget" = "忘记"; "native.forkConversation" = "分叉会话"; "native.format" = "格式"; @@ -5355,12 +5251,8 @@ "native.imperativeSubject" = "动作式标题"; "native.implementations" = "实现"; "native.importARegularFileOrDirectoryInsteadOfASymbolicLink" = "请导入普通文件或目录,而不是符号链接。"; -"native.importCSV" = "导入 CSV…"; -"native.importConnectionsFromDBX" = "从 DBX 导入连接"; -"native.importDataSources" = "导入数据源…"; "native.importFromClaude" = "从 Claude 导入"; "native.importFromCodex" = "从 Codex 导入"; -"native.importJSON" = "导入 JSON…"; "native.importJson" = "导入 JSON"; "native.importMcpConfiguration" = "导入 MCP 配置"; "native.importSkill" = "导入技能"; @@ -5721,7 +5613,6 @@ "native.lldhLldmLlds" = "%1$lld小时 %2$lld分 %3$lld秒"; "native.lldmLlds" = "%1$lld分 %2$lld秒"; "native.llds" = "%1$lld秒"; -"native.lluRowsAffectedInDMs" = "影响 %1$llu 行,耗时 %2$d 毫秒"; "native.load" = "加载"; "native.loadDiskVersion" = "加载磁盘版本"; "native.loadMore" = "加载更多"; @@ -5987,12 +5878,10 @@ "native.newDirectory" = "新建目录…"; "native.newFile" = "新建文件…"; "native.newFolder" = "新建文件夹"; -"native.newFolderX633810e9b0" = "新建文件夹…"; "native.newHook" = "新 Hook"; "native.newMcpServer" = "新建 MCP 服务器"; "native.newProject" = "新建项目…"; "native.newProjectX6f8d176462" = "新建项目"; -"native.newQuery" = "新建查询"; "native.newRunConfiguration" = "新建运行配置"; "native.newSSHConnection" = "新建 SSH 连接"; "native.newSkill" = "新建技能"; @@ -6013,7 +5902,6 @@ "native.noAIProviderIsConfiguredYet" = "尚未配置 AI 服务商。"; "native.noAPIKeyFoundInClaudeConfiguration" = "Claude 配置中未找到 API 密钥"; "native.noAPIKeyFoundInCodexAuthJson" = "Codex auth.json 中未找到 API 密钥"; -"native.noAccessibleDatabases" = "没有可访问的数据库"; "native.noAgent" = "无 Agent"; "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndChooseALocalOrCustomProvider" = "尚无可用的 Agent,请在设置窗口的 Agent 页面中安装 Agent,并选择本机配置或自定义供应商。"; "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndFetchItsLocalConfiguration" = "还没有可用的 Agent。请打开设置窗口的 Agent 页面,安装 Agent 并获取它的本机配置。"; @@ -6029,7 +5917,6 @@ "native.noChanges" = "没有更改"; "native.noCodexConfigurationWasFound" = "未找到 Codex 配置"; "native.noColumns" = "没有列"; -"native.noColumnsToDisplay" = "没有可显示的列"; "native.noCommand" = "无命令"; "native.noCommitSelected" = "未选择提交"; "native.noCommits" = "没有提交记录"; @@ -6043,7 +5930,6 @@ "native.noCredentialFoundInCodexConfiguration" = "Codex 配置中未找到凭据"; "native.noCurrentBranch" = "没有当前分支"; "native.noCustomProvidersYet" = "尚未添加自定义供应商。"; -"native.noDatabasesLoaded" = "尚未加载数据库"; "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain" = "未检测到系统默认 Shell。请选择已检测到的 Shell,或重新执行检测。"; "native.noDependencies" = "无依赖"; "native.noDependencies.variant2" = "没有依赖。"; @@ -6116,7 +6002,6 @@ "native.noPublishedReleaseIsAvailableYet" = "暂时没有已发布的版本。"; "native.noPullRequests" = "暂无拉取请求"; "native.noPullRequestsMatchTheSelectedState" = "当前状态下没有拉取请求。"; -"native.noQueryOutput" = "暂无查询输出"; "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive" = "暂未收到新的进度,任务仍在进行。"; "native.noRecentProjects" = "没有最近项目"; "native.noReleaseIsAvailableYet" = "暂时没有可用版本"; @@ -6130,14 +6015,12 @@ "native.noRunningDebugProcessAcceptsStandardInput" = "没有正在运行且接受标准输入的调试进程"; "native.noSSHConnectionsYet" = "还没有 SSH 连接。"; "native.noSavedChanges" = "没有已保存的更改"; -"native.noSchemasSelected" = "未选择架构"; "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain" = "在 %1$@ 未找到 Shell。请选择已检测到的 Shell,或重新执行检测。"; "native.noShelves" = "没有 Shelf"; "native.noSkillsInThisScope" = "当前分类暂无技能。"; "native.noSpringBootOrMavenModuleConfigurations" = "没有 Spring Boot 或 Maven Module 配置"; "native.noSubagentOutputYet" = "尚未收到子代理输出"; "native.noSubagentsYet" = "暂无子代理"; -"native.noTableSelected" = "未选择表"; "native.noTasksYet" = "暂无任务"; "native.noTextualDiffAvailable" = "没有可用的文本 Diff"; "native.noTextualDifference" = "没有文本差异"; @@ -6153,7 +6036,6 @@ "native.nodeJsLldOrLaterIsRequiredFound" = "需要 Node.js %1$lld 或更高版本,当前为 %2$@。"; "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain" = "未找到 Node.js %1$lld 或更高版本。请安装后重新检测。"; "native.nodeJsSupport" = "Node.js 支持"; -"native.nonIntrospected" = "未内省"; "native.nonStreaming" = "非流式"; "native.nonStreamingResponses" = "非流式响应"; "native.none" = "无"; @@ -6162,7 +6044,6 @@ "native.notAssigned" = "未分配"; "native.notAvailableForThisLitheOrMac" = "%1$@(当前 Lithe 或这台 Mac 无法使用)"; "native.notAvailableYetDependenciesAreNotIndexed" = "尚不可用:依赖尚未建立索引"; -"native.notConnected" = "未连接"; "native.notDetected" = "未检测"; "native.notFetchedYet" = "尚未获取。"; "native.notFound" = "未找到"; @@ -6231,7 +6112,6 @@ "native.openAProjectToManageItsSkills" = "请先打开项目,再管理项目技能。"; "native.openAProjectToSeeItsLanguageService" = "打开项目后可查看其语言服务。"; "native.openAProjectToUseGit" = "请打开项目以使用 Git"; -"native.openATableToInspectItsStructure" = "打开表以查看其结构"; "native.openAgain" = "重新打开"; "native.openAgentSettings" = "打开 Agent 设置"; "native.openAutoBuildDownloads" = "打开 AutoBuild 下载页"; @@ -6315,7 +6195,6 @@ "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790" = "粘贴 https 插件索引地址或 GitHub 仓库地址。GitHub 仓库的根目录需要包含 lithe-plugin-index.json。"; "native.pastePatch" = "粘贴补丁"; "native.pasteStandardJsonWithAnMcpserversObjectChooseAServerToReviewBeforeSavingOnlyTheEditorSSupportedCommandArgumentsEnvironmentUrlAndHeaderFieldsAreImportedAuthenticationAndClientSpecificOptionsMustBeConfiguredSeparatelyImportingDoesNotTakeOwnershipOfAnExistingEntry" = "粘贴包含 mcpServers 的标准 JSON,选择服务器并检查后再保存。只导入编辑器支持的命令、参数、环境变量、URL 和请求头;认证及客户端专有选项需另行配置。导入不会接管已有条目。"; -"native.pasteTSVFromClipboard" = "从剪贴板粘贴 TSV"; "native.pasteThisIntoYourMCPClientConfiguration" = "将此内容粘贴到 MCP 客户端配置中。"; "native.pastedFiles" = "已粘贴文件"; "native.patchCanBeAppliedToTheSelectedDestination" = "补丁可应用到所选目标。"; @@ -6379,7 +6258,6 @@ "native.preparingConversation" = "正在准备对话…"; "native.preparingDownload" = "正在准备下载…"; "native.preservesEveryCommitAndAddsAMergeCommitToTheBaseBranch" = "保留全部提交,并在目标分支中添加一个合并提交。"; -"native.pressEnterOrClickOutsideTheListToApply" = "按 Enter 或点击列表外应用"; "native.pressShortcut" = "请按下快捷键…"; "native.previewAndExport" = "预览并导出…"; "native.previewAndExportTheSelectedLogsWithEnvironmentInformationCredentialsAndHomeDir65f650979b" = "预览并导出筛选后的日志和环境信息。凭据和用户目录路径均会脱敏,不包含源码、编辑缓冲区或终端历史。"; @@ -6508,11 +6386,8 @@ "native.pushTo" = "推送到 %1$@"; "native.pushXc68c0bf1cd" = "推送“%1$@”?"; "native.pythonSupport" = "Python 支持"; -"native.queryConsole" = "查询控制台"; -"native.queryHistory" = "%1$@ 查询历史"; "native.quickInformation" = "快速信息"; "native.quickResponsesWithBasicReasoning" = "快速响应,基础推理"; -"native.quickStart" = "快速开始"; "native.quit" = "退出"; "native.quitWithSelection" = "按选择退出"; "native.quittingLitheEndsThesePrograms" = "退出 Lithe 会结束这些程序。"; @@ -6552,7 +6427,6 @@ "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase" = "变基需要干净的工作区。选择变基前,请先提交或暂存本地更改。"; "native.recent" = "最近"; "native.recentProjects" = "最近项目"; -"native.recentSQL" = "最近的 SQL"; "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78" = "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。"; "native.reconnect" = "重新连接"; "native.reconnectThisAgentToRefreshItsModelList" = "重新连接此 Agent 即可刷新模型列表。"; @@ -6564,7 +6438,6 @@ "native.refreshAll" = "全部刷新"; "native.refreshChanges" = "刷新更改"; "native.refreshConnectionStructure" = "刷新连接结构"; -"native.refreshDatabases" = "刷新数据库"; "native.refreshDependencies" = "刷新依赖"; "native.refreshDetectedRuntimes" = "刷新运行时检测结果"; "native.refreshFiles" = "刷新文件"; @@ -6577,7 +6450,6 @@ "native.refreshPreview" = "刷新预览"; "native.refreshProjectHistory" = "刷新项目历史"; "native.refreshPullRequests" = "刷新拉取请求"; -"native.refreshSelectedStructure" = "刷新选中对象的结构"; "native.refreshSkills" = "刷新技能"; "native.refreshWorktrees" = "刷新工作树"; "native.refreshed" = "已于 %1$@ 刷新"; @@ -6652,7 +6524,6 @@ "native.renameChangeList" = "重命名更改列表"; "native.renameChat" = "重命名对话"; "native.renameConversation" = "重命名会话"; -"native.renameFolder" = "重命名文件夹"; "native.renamed" = "已重命名"; "native.reopenPullRequest" = "重新打开拉取请求"; "native.reopenPullRequest.variant2" = "重新打开拉取请求"; @@ -6665,7 +6536,6 @@ "native.replace" = "替换"; "native.replaceCurrentCommitMessage" = "替换当前提交信息?"; "native.replaceExistingContent" = "替换现有内容"; -"native.replaceInCurrentPage" = "在当前页替换…"; "native.replaceInFile" = "在文件中替换"; "native.replaceInFile.variant2" = "在文件中替换…"; "native.replaceInFiles" = "在项目中替换"; @@ -6734,10 +6604,8 @@ "native.restoreDefaultTemplate" = "恢复默认模板"; "native.restoreDefaultUILayout" = "恢复默认 UI 布局"; "native.restorePath" = "恢复路径"; -"native.restoreSQLBackup" = "恢复 SQL 备份…"; "native.restoringGitHubConnection" = "正在恢复 GitHub 连接"; "native.result" = "结果"; -"native.resultD" = "结果 %1$d"; "native.results" = "测试结果"; "native.results.variant2" = "个结果"; "native.resume" = "恢复执行"; @@ -6841,10 +6709,7 @@ "native.sSH" = "SSH"; "native.sSHAgent" = "SSH Agent"; "native.sSHAuthentication" = "SSH 认证"; -"native.sSHTLS" = "SSH / TLS"; -"native.safetyAndLabels" = "安全与标记"; "native.sampleInterval" = "采样间隔"; -"native.save" = "保存:"; "native.saveAfter" = "保存延迟"; "native.saveChangedFilesAutomatically" = "自动保存已修改的文件"; "native.saveChanges" = "保存更改"; @@ -6857,7 +6722,6 @@ "native.saveMessage.variant2" = "保存信息"; "native.saveMetadata" = "保存元数据"; "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem" = "删除此项目之前,请先保存或丢弃未保存的文件"; -"native.savePassword" = "保存密码"; "native.savePatch" = "保存补丁…"; "native.saveScope" = "保存范围"; "native.saveSharedInstructions" = "保存共享指令"; @@ -6886,7 +6750,6 @@ "native.searchConnections" = "搜索连接"; "native.searchConversation" = "搜索对话"; "native.searchConversationTitlesOrIDs" = "搜索会话标题或 ID…"; -"native.searchDatabases" = "搜索数据库"; "native.searchDiff" = "搜索 Diff"; "native.searchEverywhere" = "全局搜索"; "native.searchEverywhere.variant2" = "全局搜索…"; @@ -6922,7 +6785,6 @@ "native.select.variant2" = "勾选 %1$@"; "native.selectAChangedFileFirst" = "请先选择一个更改文件"; "native.selectACommit" = "选择提交"; -"native.selectAConnectionInTheDatabaseTree" = "在数据库树中选择连接"; "native.selectAFile" = "选择文件"; "native.selectAFileToPreview" = "选择文件以预览"; "native.selectAFileToReview" = "选择文件以查看"; @@ -6941,7 +6803,6 @@ "native.selectBranch" = "选择分支"; "native.selectConfigurationsForBatchActions" = "勾选配置以批量操作"; "native.selectConversation" = "选择会话"; -"native.selectDatabases" = "选择数据库"; "native.selectDebugInTheEditorContextMenu" = "— 在编辑器右键菜单中选择“调试...”"; "native.selectForCompare" = "选择以进行比较"; "native.selectRepositoryForCommitsAndBranchOperations" = "选择用于提交和分支操作的仓库"; @@ -7114,7 +6975,6 @@ "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying" = "部分 MCP 配置文件未能恢复:%1$@。请刷新并检查这些文件后重试。"; "native.sortByLastUpdated" = "按最近更新排序"; "native.sortByName" = "按名称排序"; -"native.sortConnections" = "连接排序"; "native.sortPlugins" = "插件排序"; "native.source" = "配置来源"; "native.sourceCode" = "源代码"; @@ -7124,7 +6984,6 @@ "native.speed" = "速度"; "native.springBoot" = "Spring Boot"; "native.springEndpoints" = "Spring 接口"; -"native.sqlExecutionMarkers" = "SQL 执行状态标记"; "native.sqlSupport" = "SQL 支持"; "native.squash" = "合并并保留消息"; "native.squash1" = "合并为 1 笔"; @@ -7226,14 +7085,12 @@ "native.subjectMaximumLength" = "标题最大长度"; "native.submittingReview" = "正在提交审查…"; "native.succeeded" = "成功"; -"native.succeededLineD" = "成功,第 %1$d 行"; "native.supplementaryMessageCancelled" = "补充消息已取消。"; "native.supportedPlaceholdersSummaryChangesTestingRisks" = "支持的占位符:{summary}、{changes}、{testing}、{risks}。"; "native.svelteSupport" = "Svelte 支持"; "native.swapDirection" = "交换方向"; "native.swiftSupport" = "Swift 支持"; "native.switchAgent" = "切换 Agent"; -"native.switchCurrentDatabase" = "切换当前数据库"; "native.switchProjectView" = "切换项目视图"; "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain" = "请切换到安装此 CLI 的 Node.js/npm 环境,在该环境中更新后重新检测。"; "native.switched" = "已切换"; @@ -7277,7 +7134,6 @@ "native.testRunTimedOutAfterSeconds" = "%1$@ 测试运行在 %2$@ 秒后超时。"; "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress" = "测试连接后,这里会显示主机的系统、资源和地址。"; "native.testing" = "测试"; -"native.testingConnection" = "正在测试连接…"; "native.tests" = "测试"; "native.testsFailed" = "测试失败"; "native.testsPassed" = "测试通过"; @@ -7328,7 +7184,6 @@ "native.theCredentialStoreVersionIsUnsupportedExistingCredentialsWerePreserved" = "凭据存储版本暂不支持,已保留原有凭据。"; "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "替换当前文件前,会将其保存到本地历史。"; "native.theCurrentWorktreeCannotBeRemovedHere" = "不能在这里删除当前工作树。"; -"native.theDatabaseSelectionIsNoLongerAvailableRefreshAndTryAgain" = "数据库选择已失效,请刷新后重试。"; "native.theDefaultChangeListCannotBeRenamed" = "默认更改列表不能重命名。"; "native.theDownloadedAppDoesNotMatchTheSelectedStableReleaseNothingWasInstalled" = "下载的应用与所选正式版不符,未执行安装。"; "native.theDownloadedDiskImageDoesNotContainLitheApp" = "下载的磁盘映像中没有 Lithe.app。"; @@ -7411,10 +7266,8 @@ "native.theSelectedBackgroundImageIsUnavailableAndWasRemoved" = "所选背景图片不可用,已移除。"; "native.theSelectedBranchDiffIsSentToTheActiveAIProviderWhenYouGenerate" = "生成时,所选分支的差异内容会发送给当前 AI 服务商。"; "native.theSelectedBranchesHaveNoTextualChangesToSummarize" = "所选分支之间没有可供总结的文本更改。"; -"native.theSelectedConnectionFolderNoLongerExists" = "所选连接文件夹已不存在。"; "native.theSelectedFileChangedBeforeGenerationFinished" = "生成完成前,选中的文件已发生变化"; "native.theSelectedFileHasNoTextualDiffToSummarize" = "所选文件没有可用于总结的文本 Diff。"; -"native.theSelectedFolderCannotBeMovedThere" = "无法将所选文件夹移动到该位置。"; "native.theSelectedTableHasNoColumnsToDisplay" = "所选表没有可显示的列。"; "native.theSelectedVersionsMatch" = "所选版本相同"; "native.theServerAskedAgainCheckThePreviousAnswer" = "服务器再次提问,请检查上一次的回答。"; @@ -7546,8 +7399,6 @@ "native.timeoutSeconds" = "超时(秒)"; "native.title" = "标题"; "native.toDebugYourCodeDoOneOfTheFollowing" = "要调试代码,请执行以下操作之一:"; -"native.toFolder" = "移至文件夹…"; -"native.toTopLevel" = "移至顶层"; "native.toTrackChangesToCode" = "要跟踪代码更改:"; "native.today" = "今天"; "native.toggle" = "切换 %1$@"; @@ -7656,7 +7507,6 @@ "native.unstaged" = "未暂存"; "native.unstagedChanges" = "未暂存更改"; "native.unsupportedContent" = "不支持的内容"; -"native.untilRestart" = "直到重启"; "native.untilUTCISO8601" = "结束时间(UTC ISO 8601)"; "native.untitledConversation" = "未命名对话"; "native.untracked" = "未跟踪"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 820d63c3d..c6ac5f0d4 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -74,7 +74,6 @@ final class AppServices { let gitWatchContextProvider: any GitWatchContextProviding let githubService: GitHubService let secureStore: any SecureStore - let databaseSecureStore: any SecureStore let diagnosticsExportService: DiagnosticsExportService let credentialResolver: any AIProviderCredentialResolver /// Detects Node.js and installs ACP adapters for the Agents settings page. @@ -123,7 +122,6 @@ final class AppServices { gitWatchContextProvider: any GitWatchContextProviding, githubService: GitHubService, secureStore: any SecureStore, - databaseSecureStore: any SecureStore, diagnosticsExportService: DiagnosticsExportService, credentialResolver: any AIProviderCredentialResolver, agentManagement: any AgentManagementService = UnavailableAgentManagementService(), @@ -183,7 +181,6 @@ final class AppServices { self.gitWatchContextProvider = gitWatchContextProvider self.githubService = githubService self.secureStore = secureStore - self.databaseSecureStore = databaseSecureStore self.diagnosticsExportService = diagnosticsExportService self.credentialResolver = credentialResolver self.agentManagement = agentManagement diff --git a/macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift b/macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift deleted file mode 100644 index ad4e8a01e..000000000 --- a/macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Combine -import LitheApplicationKernel -import LitheDatabaseModule -import LitheModuleAPI - -/// Owns activation and suspension of the optional database module. -@MainActor -final class DatabaseModuleCoordinator { - private let runtime: ModuleRuntime - private let store: ModuleCapabilityStore - private let onChange: () -> Void - private let onError: (String) -> Void - - init( - runtime: ModuleRuntime, - store: ModuleCapabilityStore, - onChange: @escaping () -> Void, - onError: @escaping (String) -> Void - ) { - self.runtime = runtime - self.store = store - self.onChange = onChange - self.onError = onError - } - - func activate() async -> DatabaseFeatureModel? { - if let capability: DatabaseModuleCapability = store.capability(.databaseWorkspace) { - return capability.feature - } - do { - let value = try await runtime.activateCapability(.databaseWorkspace) - guard let capability = value as? DatabaseModuleCapability else { - throw ModuleRuntimeError.missingCapabilityDependency( - module: .database, capability: .databaseWorkspace - ) - } - store.cache(capability, id: .databaseWorkspace, moduleID: .database) - store.observe(.database, observation: capability.feature.objectWillChange.sink { [weak self] _ in - self?.onChange() - }) - // Route operation failures through the existing notification owner, - // even when the database sidebar is hidden. Store clears on sleep. - store.observe(.database, observation: capability.feature.$errorMessage.dropFirst().compactMap { $0 }.sink { [weak self] message in - self?.onError(message) - }) - return capability.feature - } catch { - if !error.isCancellation { onError(error.localizedDescription) } - return nil - } - } - - func sleep() async { - do { - try await runtime.sleep(.database) - store.clear(for: .database) - } catch { - if !error.isCancellation { onError(error.localizedDescription) } - } - } -} diff --git a/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift b/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift index 7c672ed57..8ce5f514b 100644 --- a/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift +++ b/macos/Sources/Lithe/Application/Composition/ModuleSessionCoordinator.swift @@ -45,9 +45,7 @@ final class ModuleSessionCoordinator { // Shutdown must finish even if the shell is released in the meantime. shutdownTask = Task { @MainActor [weak self] in await shutdownRuntime() - guard let self else { return } - self.shutdownTask = nil - self.clearBindings(for: .database) + self?.shutdownTask = nil } } @@ -73,9 +71,8 @@ final class ModuleSessionCoordinator { private func handle(_ event: ModuleEvent) { if event.name == "module.sleeping" || event.name == "module.shutdown" { // Idle sleep keeps a visible window open: its renderer shows a loading - // placeholder and re-activates the module. Only Database, whose content - // cannot re-activate itself, closes on sleep; shutdown closes every window. - if event.name == "module.shutdown" || event.source == .database { + // placeholder and re-activates the module; shutdown closes every window. + if event.name == "module.shutdown" { workbench.releaseContributions(for: event.source) } clearBindings(for: event.source) diff --git a/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift b/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift index d203385d0..d559085dd 100644 --- a/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift +++ b/macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift @@ -1,7 +1,7 @@ import Foundation import LitheModuleAPI -// Note: 数据库改为插件后的老用户提示与迁移见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +// Note: 数据库改为插件后的老用户提示与迁移见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md /// Lithe no longer contains a database module; the official Database plugin /// replaces it. Someone who saved connections in the old module learns that diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 35db304af..f62479730 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -40,7 +40,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { } func canDrop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { - guard !item.isDatabaseTab, items.contains(item), groupLayout.groups.contains(group) else { return false } + guard items.contains(item), groupLayout.groups.contains(group) else { return false } return placements[item] != group || (side != .center && items(in: group).count > 1) } @@ -71,8 +71,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { let groups = groupLayout.groups let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] placements = placements.filter { items.contains($0.key) } - let databaseGroup = items.first(where: { $0.isDatabaseTab && placements[$0] != nil }).flatMap { placements[$0] } ?? destination - for item in items where placements[item] == nil { placements[item] = item.isDatabaseTab ? databaseGroup : destination } + for item in items where placements[item] == nil { placements[item] = destination } let occupied = Set(placements.values) groupLayout = groupLayout.keeping(occupied) ?? .group(groups[0]) let remaining = groupLayout.groups @@ -86,11 +85,6 @@ final class EditorTabOrderFeatureModel: ObservableObject { if selections != groupSelections { groupSelections = selections } } - @Published var databaseSelected = false - @Published var databaseProfileSelected: UUID? - @Published var databaseOverviewSelected = false - @Published var databaseQuerySelected: UUID? - @Published var repositoryDiffSelected = false var repositoryDiffReturnTab: EditorTabItem? var repositoryDiffRequestID: UUID? @@ -165,7 +159,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { reorderedExistingIDs.indices.contains(existingIndex) else { continue } next.append(.document(reorderedExistingIDs[existingIndex])) existingIndex += 1 - case .terminal, .media, .repositoryDiff, .agentConversation, .database, .databaseQuery, .databaseOverview: + case .terminal, .media, .repositoryDiff, .agentConversation: next.append(item) } } @@ -218,7 +212,6 @@ final class EditorTabOrderFeatureModel: ObservableObject { next.removeAll { $0 == item } guard let targetIndex = next.firstIndex(of: target) else { return false } next.insert(item, at: targetIndex + (insertAfter ? 1 : 0)) - guard !item.isDatabaseTab || placements[item] == placements[target] else { return false } guard next != items || placements[item] != placements[target] else { return false } if let destination = placements[target] { placements[item] = destination } items = next diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServices.swift b/macos/Sources/Lithe/Application/Features/PluginHostServices.swift index 6f65d77b4..7fd1d0ac8 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServices.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServices.swift @@ -1,7 +1,7 @@ import Foundation import LitheCoreContracts -// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md /// Secrets plugin backends store through the `credentials` host service. /// Every entry belongs to one plugin ID: a plugin can never name another diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift index 9d50377f4..e81774a0e 100644 --- a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift @@ -34,8 +34,6 @@ final class WorkbenchFeatureModel: ObservableObject { static let gitLog = Self(rawValue: "git.log") static let terminal = Self(rawValue: "terminal.sessions") static let agent = Self(rawValue: "agent.conversation") - static let database = Self(rawValue: "database.workspace") - static let databaseServices = Self(rawValue: "database.services") static let references = Self(rawValue: "language.references") static let problems = Self(rawValue: "language.problems") static let spring = Self(rawValue: "language.spring") @@ -43,8 +41,8 @@ final class WorkbenchFeatureModel: ObservableObject { static let tests = Self(rawValue: "execution.tests") static let debug = Self(rawValue: "debug.session") static let buildOutput = Self(rawValue: "execution.build.output") - static let allCases: [Self] = [.project, .changes, .pullRequests, .search, .database, - .gitLog, .terminal, .agent, .databaseServices, .references, .problems, .spring, .run, .tests, .debug, + static let allCases: [Self] = [.project, .changes, .pullRequests, .search, + .gitLog, .terminal, .agent, .references, .problems, .spring, .run, .tests, .debug, .buildOutput] // Docking layouts written before contribution registration used enum case names. // Their windows may since have been removed, so the mapping names the @@ -125,12 +123,11 @@ final class WorkbenchFeatureModel: ObservableObject { } /// Tool windows docked beside the editor instead of below it. The Agent - /// conversation and the database tree need the editor to stay visible - /// while they are open. Contributed windows, such as a build system's, + /// conversation needs the editor to stay visible while it is open. Contributed windows, such as a build system's, /// dock where their placement says. static func isRightDocked(_ toolWindow: ToolWindow) -> Bool { switch toolWindow { - case .agent, .database: true + case .agent: true default: false } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift index f6fedf995..1ab62f481 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift @@ -54,7 +54,6 @@ extension AppModel { selectedChange = nil closeBranchComparison() editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false editorTabOrderFeature.focus(.agentConversation) workbenchFeature.setVisibility(.agent, isVisible: true) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 82aed5f4a..6c0c5a374 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -1,4 +1,3 @@ -import LitheDatabaseModule import Foundation import LitheCoreContracts import LitheGitModule @@ -82,7 +81,6 @@ extension AppModel { set { if newValue != nil { editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false } let previousDocumentID = documentFeature.activeDocumentID documentFeature.activeDocumentID = newValue @@ -94,156 +92,13 @@ extension AppModel { var isRepositoryDiffSelected: Bool { // These surfaces take precedence over the editor tabs in EditorAreaView. - !isDatabaseWorkspaceSelected && selectedChange == nil && branchComparison == nil + selectedChange == nil && branchComparison == nil && editorTabOrderFeature.contains(.repositoryDiff) && (editorTabOrderFeature.repositoryDiffSelected || activeDocument == nil && activeEditorTerminalSession == nil && activeMediaDocument == nil) } - var editorTabItems: [EditorTabItem] { - editorTabOrderFeature.items.filter { item in - switch item { - case .database(let profileID?): - return databaseFeatureIfActive?.profiles.contains(where: { $0.id == profileID }) == true - case let .databaseQuery(profileID, queryID): - return databaseFeatureIfActive?.sqlTab(queryID, profileID: profileID) != nil - default: return true - } - } - } - - var activeDatabaseEditorTab: EditorTabItem { - if editorTabOrderFeature.databaseOverviewSelected { return .databaseOverview } - if let queryID = editorTabOrderFeature.databaseQuerySelected, let profileID = editorTabOrderFeature.databaseProfileSelected { - return .databaseQuery(profileID: profileID, queryID: queryID) - } - return .database(editorTabOrderFeature.databaseProfileSelected) - } - - var isDatabaseWorkspaceSelected: Bool { - editorTabOrderFeature.databaseSelected && editorTabOrderFeature.contains(activeDatabaseEditorTab) - && databaseFeatureIfActive != nil && selectedChange == nil && branchComparison == nil - } - - func openDatabaseWorkspace(overview: Bool = false, queryID: UUID? = nil) { - guard let feature = databaseFeatureIfActive else { return } - let item: EditorTabItem - if let queryID { - guard let profileID = feature.selectedProfileID, feature.sqlTab(queryID, profileID: profileID) != nil else { return } - item = .databaseQuery(profileID: profileID, queryID: queryID) - feature.selectedSQLTabID = queryID - } else { - item = overview ? .databaseOverview : .database(feature.selectedProfileID) - } - editorTabOrderFeature.databaseProfileSelected = feature.selectedProfileID - editorTabOrderFeature.databaseQuerySelected = queryID - if queryID != nil { feature.workspaceSection = .sql } - else if !overview && feature.workspaceSection == .sql { feature.workspaceSection = .data } - if !editorTabOrderFeature.contains(item) { editorTabOrderFeature.moveToEnd(item) } - editorTabOrderFeature.databaseOverviewSelected = overview - selectedChange = nil - closeBranchComparison() - editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = true - editorTabOrderFeature.focus(item) - } - - func selectDatabaseWorkspace(profileID: UUID?, refresh: Bool = false, table: String? = nil, queryID: UUID? = nil) { - guard let feature = databaseFeatureIfActive else { return } - if let profileID, let profile = feature.profiles.first(where: { $0.id == profileID }) { - let needsRefresh = refresh || feature.selectedProfileID != profileID - if feature.selectedProfileID != profileID { feature.activateProfile(profile) } - if let table { feature.selectTable(table) } - // Navigation belongs to the click, not the completion of database I/O. - openDatabaseWorkspace(queryID: queryID) - if (needsRefresh || table != nil), profile.kind.supportsDataGrid { - Task { - guard databaseFeatureIfActive === feature, feature.selectedProfileID == profileID else { return } - if needsRefresh { - await feature.refreshTables() - } else if let table, feature.selectedTable == table { - await feature.openTable(table) - } - } - } - } else if profileID == nil { - if feature.selectedProfileID != nil { feature.showGlobalHistory() } - openDatabaseWorkspace() - } - } - - func openDatabaseQuery(profileID: UUID, sql: String = "") { - guard let feature = databaseFeatureIfActive, - let profile = feature.profiles.first(where: { $0.id == profileID }), profile.kind.isSQLDatabase else { return } - let switchedProfile = feature.selectedProfileID != profileID - if switchedProfile { feature.activateProfile(profile) } - if let tab = feature.selectedSQLTab, tab.sql.isEmpty, !tab.isRunning, - !editorTabOrderFeature.contains(.databaseQuery(profileID: profileID, queryID: tab.id)) { - feature.updateSQL(sql, in: tab.id) - } else { - feature.addSQLTab(sql: sql) - } - selectDatabaseWorkspace(profileID: profileID, refresh: switchedProfile, queryID: feature.selectedSQLTabID) - } - - func removeDatabaseConnection(_ profile: DatabaseProfile) { - guard let feature = databaseFeatureIfActive, feature.remove(profile) else { return } - // Remove background tabs first, so fallback cannot reopen another deleted console. - let removed = editorTabOrderFeature.items.filter { - switch $0 { - case .database(let id): return id == profile.id - case .databaseQuery(let id, _): return id == profile.id - default: return false - } - } - for item in removed where item != activeDatabaseEditorTab { closeDatabaseTab(item) } - if removed.contains(activeDatabaseEditorTab) { closeDatabaseTab(activeDatabaseEditorTab) } - } - - func closeDatabaseTab(_ item: EditorTabItem) { - let wasSelected = isDatabaseWorkspaceSelected && activeDatabaseEditorTab == item - editorTabOrderFeature.remove(item) - if case let .databaseQuery(profileID, queryID) = item { - databaseFeatureIfActive?.closeSQLTab(queryID, profileID: profileID) - } - guard wasSelected else { return } - editorTabOrderFeature.databaseSelected = false - editorTabOrderFeature.databaseProfileSelected = nil - editorTabOrderFeature.databaseQuerySelected = nil - editorTabOrderFeature.databaseOverviewSelected = false - if let next = editorTabItems.last(where: { - switch $0 { - case .database, .databaseQuery, .databaseOverview: return true - default: return false - } - }) { - switch next { - case .database(let profileID): selectDatabaseWorkspace(profileID: profileID) - case let .databaseQuery(profileID, queryID): selectDatabaseWorkspace(profileID: profileID, queryID: queryID) - case .databaseOverview: openDatabaseWorkspace(overview: true) - default: break - } - } - } - - func closeDatabaseWorkspace(overview: Bool? = nil) { - guard let overview else { - workbenchFeature.setVisibility(.databaseServices, isVisible: false) - editorTabOrderFeature.databaseSelected = false - editorTabOrderFeature.databaseProfileSelected = nil - editorTabOrderFeature.databaseQuerySelected = nil - for item in editorTabOrderFeature.items { - switch item { - case .database, .databaseQuery: editorTabOrderFeature.remove(item) - default: break - } - } - editorTabOrderFeature.remove(.databaseOverview) - return - } - closeDatabaseTab(overview ? .databaseOverview : .database(editorTabOrderFeature.databaseProfileSelected)) - } + var editorTabItems: [EditorTabItem] { editorTabOrderFeature.items } func selectRepositoryDiffTab() { - editorTabOrderFeature.databaseSelected = false guard editorTabOrderFeature.contains(.repositoryDiff) else { return } editorTabOrderFeature.focus(.repositoryDiff) if !editorTabOrderFeature.repositoryDiffSelected { @@ -290,12 +145,6 @@ extension AppModel { editorTabOrderFeature.focus(item) switch item { case .agentConversation: showAgentChatsInEditor() - case .databaseOverview: - openDatabaseWorkspace(overview: true) - case .database(let profileID): - selectDatabaseWorkspace(profileID: profileID) - case let .databaseQuery(profileID, queryID): - selectDatabaseWorkspace(profileID: profileID, queryID: queryID) case .repositoryDiff: selectRepositoryDiffTab() case .document(let documentID): diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift index 59c9418a4..d16b65a18 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift @@ -44,7 +44,6 @@ extension AppModel { let normalizedURL = url.standardizedFileURL if activateWhenReady { editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false selectedChange = nil closeBranchComparison() editorNavigationTarget = nil @@ -65,7 +64,6 @@ extension AppModel { terminalPlacementFeature.activateDocument() activeDocumentID = nil editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false mediaFeature.select(media) } @@ -92,12 +90,6 @@ extension AppModel { switch fallbackItem { case .agentConversation: showAgentChatsInEditor() - case .databaseOverview: - openDatabaseWorkspace(overview: true) - case .database(let profileID): - selectDatabaseWorkspace(profileID: profileID) - case let .databaseQuery(profileID, queryID): - selectDatabaseWorkspace(profileID: profileID, queryID: queryID) case .repositoryDiff: selectRepositoryDiffTab() case .document(let documentID): diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index 639ee9119..19c162366 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -49,18 +49,6 @@ extension AppModel { } func applyPluginEnabledChanges(_ changes: [PluginID: Bool]) async -> Set { - let snapshotsByID = Dictionary(uniqueKeysWithValues: pluginSnapshots.map { ($0.id, $0) }) - let closesDatabase = changes.contains { pluginID, enabled in - !enabled && snapshotsByID[pluginID]?.manifest.modules.contains { - $0.manifest.id == .database - } == true - } - if closesDatabase { - closeDatabaseWorkspace() - workbenchFeature.setVisibility(.database, isVisible: false) - await Task.yield() - } - var appliedPluginIDs: Set = [] for pluginID in changes.keys.sorted(by: { $0.rawValue < $1.rawValue }) { guard let enabled = changes[pluginID] else { continue } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 727487fe8..95837d188 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -259,7 +259,6 @@ extension AppModel { guard terminalPlacementFeature.editorSessionIDs.contains(session.id), terminalFeature?.selectSession(session) == true else { return } editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false mediaFeature.deactivate() terminalPlacementFeature.activateEditorSession(session.id) } @@ -272,7 +271,6 @@ extension AppModel { func moveTerminalToEditor(_ sessionID: UUID) { guard let session = terminalSessions.first(where: { $0.id == sessionID }) else { return } editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false terminalPlacementFeature.moveToEditor(sessionID) editorTabOrderFeature.moveToEnd(.terminal(sessionID)) terminalPlacementFeature.reorderEditorSessions( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift index fdcb5f5b6..647731d48 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift @@ -20,10 +20,6 @@ extension AppModel { showAgentChatsInToolWindow() return true } - if isDatabaseWorkspaceSelected { - closeDatabaseTab(activeDatabaseEditorTab) - return true - } if branchComparison != nil { closeBranchComparison() return true @@ -47,10 +43,6 @@ extension AppModel { guard let item = editorTabItems.last else { return false } switch item { case .agentConversation: showAgentChatsInToolWindow() - case .databaseOverview: - closeDatabaseWorkspace(overview: true) - case .database, .databaseQuery: - closeDatabaseTab(item) case .repositoryDiff: closeGitCommitDiff() case .document(let documentID): diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchState.swift index de036fd3f..e168ef49d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchState.swift @@ -67,7 +67,6 @@ extension AppModel { case .gitLog: await refreshGitHistory() case .changes: _ = await activateGitModule() case .search: _ = await activateSearchModule() - case .database, .databaseServices: _ = await databaseModuleCoordinator.activate() case .agent: activateAgentConversation() case .run, .tests: _ = await activateExecutionModule() case .buildOutput: diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0c4dc915e..2cee9f6d9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1,7 +1,6 @@ import Combine import Foundation import LitheGitModule -import LitheDatabaseModule import LitheDebugModule import LitheExecutionModule import LitheLocalHistoryModule @@ -259,12 +258,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { notify: { [weak self] in self?.showNotification($0) }, onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) } ) - lazy var databaseModuleCoordinator = DatabaseModuleCoordinator( - runtime: services.moduleRuntime, - store: moduleCapabilityStore, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, - onError: { [weak self] in self?.showNotification($0) } - ) lazy var searchModuleCoordinator = SearchModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, @@ -299,12 +292,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { var javaFeature: JavaFeatureModel { featureGraph.java } var springFeature: SpringFeatureModel { featureGraph.spring } var mybatisFeature: MybatisFeatureModel { featureGraph.mybatis } - // Views retain this instance in their environment so a final SwiftUI update - // remains safe after module shutdown clears the capability cache. - var databaseFeatureIfActive: DatabaseFeatureModel? { - let capability: LitheDatabaseModule.DatabaseModuleCapability? = cachedModuleCapability(.databaseWorkspace) - return capability?.feature - } var moduleSnapshots: [ModuleSnapshot] { services.moduleRuntime.snapshots() } lazy var workbenchUIRegistry: WorkbenchModuleUIRegistry = { do { return try WorkbenchModuleUIComposition.registry(for: services.pluginCatalog, iconResources: services.pluginUIIconResources) } @@ -560,7 +547,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { workbench: workbenchFeature, onModuleReleased: { [weak self] moduleID in self?.unregisterModuleLanguageExtensions(for: moduleID) - if moduleID == .database { self?.closeDatabaseWorkspace() } }, onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() @@ -671,20 +657,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } } - func activateDatabaseModule(toolWindow: WorkbenchFeatureModel.ToolWindow = .database) async { - let workspace = workspaceURL - await awaitModuleRuntimeShutdown() - guard workspaceURL == workspace, await databaseModuleCoordinator.activate() != nil, - workspaceURL == workspace else { return } - workbenchFeature.setVisibility(toolWindow, isVisible: true) - } - - func sleepDatabaseModule() async { - await databaseModuleCoordinator.sleep() - workbenchFeature.setVisibility(.database, isVisible: false) - closeDatabaseWorkspace() - } - deinit { if let remoteLossObserver { NotificationCenter.default.removeObserver(remoteLossObserver) } for task in contributionActivationTasks.values { task.cancel() } @@ -1083,7 +1055,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { allowsOutsideWorkspace: Bool = false ) { editorTabOrderFeature.repositoryDiffSelected = false - editorTabOrderFeature.databaseSelected = false selectedChange = nil closeBranchComparison() editorNavigationTarget = nil @@ -1629,9 +1600,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { if let session = terminalSessions.first(where: { $0.id == id }) { selectEditorTerminalSession(session) } case .media(let id): if let media = openMediaDocuments.first(where: { $0.id == id }) { selectMediaDocument(media) } - case .databaseOverview: openDatabaseWorkspace(overview: true) - case .database(let profileID): selectDatabaseWorkspace(profileID: profileID) - case let .databaseQuery(profileID, queryID): selectDatabaseWorkspace(profileID: profileID, queryID: queryID) case .repositoryDiff, nil: break } } diff --git a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift index 4e0548796..5e63ce352 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift @@ -5,18 +5,8 @@ enum EditorTabItem: Hashable, Identifiable { case document(UUID) case terminal(UUID) case media(UUID) - case database(UUID?) - case databaseQuery(profileID: UUID, queryID: UUID) - case databaseOverview case repositoryDiff case agentConversation var id: Self { self } - - var isDatabaseTab: Bool { - switch self { - case .database, .databaseQuery, .databaseOverview: true - default: false - } - } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 6afbff51a..a3ffac925 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -3,7 +3,6 @@ import LitheAIAssistanceModule import LitheAgentConversationModule import LitheApplicationKernel import LitheCoreContracts -import LitheDatabaseModule import LitheDebugModule import LitheExecutionModule import LitheGitModule @@ -126,10 +125,6 @@ final class MacServiceContainer { ) let processRunner = MacProcessRunner(logging: applicationLogging) let secureStore = MacLocalSecretStore() - let databaseSecureStore = MacKeychainSecureStore( - service: fileStorage.identity.keychainService("database"), - legacyStore: secureStore - ) let githubService = GitHubService( core: RustGitHubCore(bridge: rustCore), transport: MacGitHubHTTPTransport(), @@ -157,13 +152,7 @@ final class MacServiceContainer { ) let ideCapabilityRegistry = IdeCapabilityRegistry() let languageExecutionHost = MacLanguageExecutionHost(processRegistry: processRegistry) - let databaseSidecarURL = MacDatabaseSidecarLocator(fileStorage: fileStorage).executableURL() let moduleStore = providedModuleStore ?? MacModuleConfigurationStore(store: store) - // Database is now an internal on-demand workspace feature, so an old - // plugin-market preference must not leave the workspace inaccessible. - if moduleStore.enabledState(for: .database) == false { - moduleStore.setEnabledState(true, for: .database) - } let moduleRuntime = ModuleRuntime( configurationStore: moduleStore, recoveryStore: moduleStore, @@ -223,19 +212,6 @@ final class MacServiceContainer { statisticsPersistence: MacAgentSessionStatisticsPersistence(store: store) ) }) - try moduleRegistry.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { - DatabaseModule( - processRunner: processRunner, - executableURL: databaseSidecarURL, - preferenceStore: MacDatabasePreferenceStore(store: store), - secureStore: MacKeychainSecureStore( - service: fileStorage.identity.keychainService("database"), - legacyStore: MacLocalSecretStore() - ), - recoveryStore: MacDatabaseRecoveryStore(fileStorage: fileStorage), - fileStorage: fileStorage - ) - }) try moduleRegistry.register(ModuleFactory(manifest: TerminalModule.moduleManifest, contributions: TerminalModule.moduleContributions) { TerminalModule( terminalFactory: { @@ -832,7 +808,6 @@ final class MacServiceContainer { gitWatchContextProvider: gitWatchContextProvider, githubService: githubService, secureStore: secureStore, - databaseSecureStore: databaseSecureStore, diagnosticsExportService: diagnosticsExportService, credentialResolver: credentialResolver, agentManagement: RustAgentManagementService(core: rustCore), diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index d396441da..40b782e25 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -354,7 +354,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func isActiveDocument(_ id: UUID, model: AppModel) -> Bool { model.activeDocumentID == id && model.activeEditorTerminalSession == nil - && model.activeMediaDocument == nil && !model.isDatabaseWorkspaceSelected && !model.isRepositoryDiffSelected + && model.activeMediaDocument == nil && !model.isRepositoryDiffSelected && model.editorTabOrderFeature.groupLayout.groups.contains { model.editorTabOrderFeature.selectedItem(in: $0) == .document(id) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift deleted file mode 100644 index bdbf9bc2b..000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift +++ /dev/null @@ -1,236 +0,0 @@ -import Compression -import CryptoKit -import Foundation -import LitheDatabaseModule - -final class MacDatabaseRecoveryStore: DatabaseRecoveryStoring, @unchecked Sendable { - private static let executionLogLock = NSRecursiveLock() - private let rootURL: URL - private let fileManager: FileManager - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - init(rootURL: URL? = nil, fileManager: FileManager = .default) { - self.fileManager = fileManager - self.rootURL = rootURL ?? Self.defaultRootURL(fileManager: fileManager) - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .millisecondsSince1970 - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .millisecondsSince1970 - } - - convenience init(fileStorage: any FileStorage) { - self.init( - rootURL: fileStorage.applicationSupportDirectory() - .appendingPathComponent("Lithe/DatabaseRecovery", isDirectory: true) - ) - } - - func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint { - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - let id = UUID() - let compressed = DatabaseBackupCompression.compress(data) - let storedData = compressed ?? data - let fileName = "\(id.uuidString).sql\(compressed == nil ? "" : ".lzfse")" - try storedData.write(to: rootURL.appendingPathComponent(fileName), options: [.atomic]) - let point = DatabaseRecoveryPoint(id: id, profileID: profileID, reason: reason, createdAt: Date(), byteCount: storedData.count, fileName: fileName, originalByteCount: data.count, isCompressed: compressed != nil, sha256: Self.sha256(data: data)) - try persist(point) - return point - } - - func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String = "", progress: ((Double) -> Void)? = nil) throws -> DatabaseRecoveryPoint { - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - let attributes = try fileManager.attributesOfItem(atPath: fileURL.path) - let byteCount = (attributes[.size] as? NSNumber)?.intValue ?? 0 - let checksum = try Self.sha256(fileURL: fileURL) - if !expectedSHA256.isEmpty && checksum != expectedSHA256 { - throw CocoaError(.fileReadCorruptFile, userInfo: [NSLocalizedDescriptionKey: "The database backup checksum did not match."]) - } - let id = UUID() - let fileName = "\(id.uuidString).sql" - try copyFile(fileURL, to: rootURL.appendingPathComponent(fileName), progress: progress) - let point = DatabaseRecoveryPoint(id: id, profileID: profileID, reason: reason, createdAt: Date(), byteCount: byteCount, fileName: fileName, originalByteCount: byteCount, isCompressed: false, sha256: checksum) - try persist(point) - return point - } - - private func persist(_ point: DatabaseRecoveryPoint) throws { - var points = try loadRecoveryPoints().filter { $0.id != point.id } - points.insert(point, at: 0) - try encoder.encode(points).write(to: manifestURL, options: [.atomic]) - } - - func recoveryPoints(for profileID: UUID? = nil) -> [DatabaseRecoveryPoint] { - let points = (try? decoder.decode([DatabaseRecoveryPoint].self, from: Data(contentsOf: manifestURL))) ?? [] - guard let profileID else { return points } - return points.filter { $0.profileID == profileID } - } - - func data(for point: DatabaseRecoveryPoint) throws -> Data { - let stored = try Data(contentsOf: rootURL.appendingPathComponent(point.fileName)) - if point.isCompressed { - guard let restored = DatabaseBackupCompression.decompress(stored, originalSize: point.originalByteCount) else { - throw CocoaError(.fileReadCorruptFile) - } - return try validated(restored, for: point) - } - return try validated(stored, for: point) - } - - func fileURL(for point: DatabaseRecoveryPoint) throws -> URL { - let url = rootURL.appendingPathComponent(point.fileName) - guard fileManager.fileExists(atPath: url.path) else { throw CocoaError(.fileNoSuchFile) } - if !point.sha256.isEmpty { - let checksum = try Self.sha256(fileURL: url) - if checksum != point.sha256 { throw CocoaError(.fileReadCorruptFile) } - } - return url - } - - func delete(_ point: DatabaseRecoveryPoint) throws { - let fileURL = rootURL.appendingPathComponent(point.fileName) - if fileManager.fileExists(atPath: fileURL.path) { try fileManager.removeItem(at: fileURL) } - let remaining = recoveryPoints().filter { $0.id != point.id } - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - try encoder.encode(remaining).write(to: manifestURL, options: [.atomic]) - } - - func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int = 500) throws { - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - var entries = auditEntries().filter { $0.id != entry.id } - entries.insert(entry, at: 0) - try encoder.encode(Array(entries.prefix(maximumEntries))).write(to: auditURL, options: [.atomic]) - } - - func auditEntries(for profileID: UUID? = nil) -> [DatabaseAuditEntry] { - let entries = (try? decoder.decode([DatabaseAuditEntry].self, from: Data(contentsOf: auditURL))) ?? [] - guard let profileID else { return entries } - return entries.filter { $0.profileID == profileID } - } - - func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int = 1_000) throws { - Self.executionLogLock.lock() - defer { Self.executionLogLock.unlock() } - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - var entries = executionEvents().filter { $0.id != event.id } - entries.insert(event, at: 0) - try encoder.encode(Array(entries.prefix(maximumEntries))).write(to: executionURL, options: [.atomic]) - } - - func executionEvents(for profileID: UUID? = nil) -> [DatabaseExecutionEvent] { - Self.executionLogLock.lock() - defer { Self.executionLogLock.unlock() } - let entries = (try? decoder.decode([DatabaseExecutionEvent].self, from: Data(contentsOf: executionURL))) ?? [] - guard let profileID else { return entries } - return entries.filter { $0.profileID == profileID } - } - - func deleteExecutionEvents(for profileID: UUID) throws { - Self.executionLogLock.lock() - defer { Self.executionLogLock.unlock() } - let remaining = executionEvents().filter { $0.profileID != profileID } - try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) - try encoder.encode(remaining).write(to: executionURL, options: [.atomic]) - } - - private func loadRecoveryPoints() throws -> [DatabaseRecoveryPoint] { - guard fileManager.fileExists(atPath: manifestURL.path) else { return [] } - return try decoder.decode([DatabaseRecoveryPoint].self, from: Data(contentsOf: manifestURL)) - } - - private var manifestURL: URL { rootURL.appendingPathComponent("recovery-points.json") } - private var auditURL: URL { rootURL.appendingPathComponent("audit.json") } - private var executionURL: URL { rootURL.appendingPathComponent("execution-events.json") } - - private func validated(_ data: Data, for point: DatabaseRecoveryPoint) throws -> Data { - if !point.sha256.isEmpty && Self.sha256(data: data) != point.sha256 { - throw CocoaError(.fileReadCorruptFile) - } - return data - } - - private func copyFile(_ sourceURL: URL, to destinationURL: URL, progress: ((Double) -> Void)?) throws { - guard let input = InputStream(url: sourceURL), let output = OutputStream(url: destinationURL, append: false) else { - throw CocoaError(.fileReadNoPermission) - } - let total: Double - if let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path), let size = attributes[.size] as? NSNumber { - total = size.doubleValue - } else { - total = 0 - } - input.open() - output.open() - defer { input.close(); output.close() } - var buffer = [UInt8](repeating: 0, count: 64 * 1_024) - var copied = 0.0 - while input.hasBytesAvailable { - let count = input.read(&buffer, maxLength: buffer.count) - if count < 0 { throw input.streamError ?? CocoaError(.fileReadUnknown) } - if count == 0 { break } - var written = 0 - while written < count { - let amount = buffer.withUnsafeBufferPointer { pointer in - output.write(pointer.baseAddress!.advanced(by: written), maxLength: count - written) - } - if amount <= 0 { throw output.streamError ?? CocoaError(.fileWriteUnknown) } - written += amount - } - copied += Double(count) - progress?(total > 0 ? min(1, copied / total) : 1) - } - } - - private static func sha256(data: Data) -> String { - SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - } - - private static func sha256(fileURL: URL) throws -> String { - guard let stream = InputStream(url: fileURL) else { throw CocoaError(.fileReadNoPermission) } - stream.open() - defer { stream.close() } - var hasher = SHA256() - var buffer = [UInt8](repeating: 0, count: 64 * 1_024) - while stream.hasBytesAvailable { - let count = stream.read(&buffer, maxLength: buffer.count) - if count < 0 { throw stream.streamError ?? CocoaError(.fileReadUnknown) } - if count == 0 { break } - hasher.update(data: Data(buffer.prefix(count))) - } - return hasher.finalize().map { String(format: "%02x", $0) }.joined() - } - - private static func defaultRootURL(fileManager: FileManager) -> URL { - let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory - return MacAppIdentity().storageRoot(base).appendingPathComponent("Lithe/DatabaseRecovery", isDirectory: true) - } -} - -private enum DatabaseBackupCompression { - static func compress(_ data: Data) -> Data? { - guard data.count >= 64 * 1_024 else { return nil } - let destinationCapacity = data.count + max(64 * 1_024, data.count / 8) - var destination = [UInt8](repeating: 0, count: destinationCapacity) - let count = data.withUnsafeBytes { source in - destination.withUnsafeMutableBytes { output in - guard let sourceAddress = source.baseAddress, let outputAddress = output.baseAddress else { return 0 } - return compression_encode_buffer(outputAddress.assumingMemoryBound(to: UInt8.self), output.count, sourceAddress.assumingMemoryBound(to: UInt8.self), source.count, nil, COMPRESSION_LZFSE) - } - } - guard count > 0, count < data.count else { return nil } - return Data(destination.prefix(count)) - } - - static func decompress(_ data: Data, originalSize: Int) -> Data? { - guard originalSize > 0 else { return Data() } - var destination = [UInt8](repeating: 0, count: originalSize) - let count = data.withUnsafeBytes { source in - destination.withUnsafeMutableBytes { output in - guard let sourceAddress = source.baseAddress, let outputAddress = output.baseAddress else { return 0 } - return compression_decode_buffer(outputAddress.assumingMemoryBound(to: UInt8.self), output.count, sourceAddress.assumingMemoryBound(to: UInt8.self), source.count, nil, COMPRESSION_LZFSE) - } - } - guard count == originalSize else { return nil } - return Data(destination) - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift index 636556ff8..5793676d3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift @@ -1,6 +1,6 @@ import Foundation -// Note: 数据库改为插件后的老用户迁移见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +// Note: 数据库改为插件后的老用户迁移见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md /// Moves what Lithe's former built-in database module saved into the /// database plugin, once, before the plugin's backend first starts: the diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 1a075ac00..0e10d9cd2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -3,7 +3,7 @@ import LitheCoreContracts import LitheLanguageIntelligenceModule import LitheModuleAPI -// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md /// The backends of plugins (`backend` 1): one process per plugin for the /// whole application, shared by every project window, because what a diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift index 2c78c5296..8d89b5100 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift @@ -2,7 +2,7 @@ import Foundation import LocalAuthentication import Security -// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +// Note: 插件后端与宿主凭据服务的决策见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md /// Plugin credentials in the user's login Keychain. Each plugin gets a /// Keychain service of its own (`.plugin-credentials.`), diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift deleted file mode 100644 index 3cd9dd855..000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Process/MacDatabaseSidecarLocator.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -struct MacDatabaseSidecarLocator: Sendable { - private let bundle: Bundle - private let environment: [String: String] - private let fileStorage: any FileStorage - - init( - bundle: Bundle = .main, - environment: [String: String] = ProcessInfo.processInfo.environment, - fileStorage: any FileStorage = MacFileStorage() - ) { - self.bundle = bundle - self.environment = environment - self.fileStorage = fileStorage - } - - func executableURL() -> URL? { - if let override = environment["LITHE_DB_SIDECAR_EXECUTABLE"], !override.isEmpty { - let url = URL(fileURLWithPath: override) - return fileStorage.isExecutable(at: url) ? url : nil - } - let candidates = [ - bundle.bundleURL.appendingPathComponent("Contents/Helpers/lithe-db-sidecar"), - bundle.bundleURL.appendingPathComponent("Contents/Resources/Database/lithe-db-sidecar") - ] - return candidates.first(where: fileStorage.isExecutable(at:)) - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift b/macos/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift deleted file mode 100644 index 2b07527e3..000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import LitheDatabaseModule - -extension MacProcessRunner: DatabaseProcessRunning { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - let result = run(ProcessRequest( - executablePath: request.executablePath, - environment: request.environment, - standardInput: request.standardInput, - timeoutMilliseconds: request.timeoutMilliseconds - )) - return DatabaseProcessResult(output: result.output, exitCode: result.exitCode) - } -} - -extension MacKeychainSecureStore: DatabaseSecureStore {} - -struct MacDatabasePreferenceStore: DatabasePreferenceStore, @unchecked Sendable { - let store: any KeyValueStore - func data(forKey key: String) -> Data? { store.data(forKey: key) } - func set(_ value: Any?, forKey key: String) { store.set(value, forKey: key) } -} - -extension MacFileStorage: DatabaseFileStorage { - func readData(from url: URL) throws -> Data { try readData(from: url, options: []) } -} diff --git a/macos/Sources/Lithe/Theme/DatabaseBrandIcon.swift b/macos/Sources/Lithe/Theme/DatabaseBrandIcon.swift deleted file mode 100644 index f80d9f47d..000000000 --- a/macos/Sources/Lithe/Theme/DatabaseBrandIcon.swift +++ /dev/null @@ -1,75 +0,0 @@ -import AppKit -import SwiftUI -import LitheDatabaseModule - -extension DatabaseKind { - var brandIconFilename: String { - switch self { - case .mysql: "mysql.svg" - case .mariadb: "mariadb.svg" - case .postgresql: "postgres.svg" - case .sqlite: "sqlite.svg" - case .sqlserver: "sqlserver.svg" - case .mongodb: "mongodb.svg" - case .redis: "redis.svg" - case .nacos: "nacos.svg" - } - } -} - -@MainActor -private enum DatabaseBrandIconLoader { - private struct CacheKey: Hashable { - let kind: DatabaseKind - let size: Int - let dark: Bool - } - - private static var cache: [CacheKey: NSImage] = [:] - - static func image(for kind: DatabaseKind, size: CGFloat, dark: Bool) -> NSImage? { - let key = CacheKey(kind: kind, size: max(1, Int(size.rounded())), dark: dark) - if let cached = cache[key] { return cached } - let filename = kind.brandIconFilename as NSString - let darkFilename = LitheIcons.darkIdeaAssetPath(for: kind.brandIconFilename) as NSString - let darkURL = dark ? Bundle.main.url(forResource: darkFilename.deletingPathExtension, - withExtension: darkFilename.pathExtension, subdirectory: "DatabaseIcons") : nil - guard let url = darkURL ?? Bundle.main.url( - forResource: filename.deletingPathExtension, - withExtension: filename.pathExtension, - subdirectory: "DatabaseIcons" - ), let image = NSImage(contentsOf: url) else { - return nil - } - // SwiftUI respects the view frame, but AppKit menu bridging reads the - // NSImage's intrinsic size. Normalize both so vector assets cannot - // expand a system menu to their original SVG canvas dimensions. - image.size = NSSize(width: CGFloat(key.size), height: CGFloat(key.size)) - image.isTemplate = false - cache[key] = image - return image - } -} - -/// Community provider marks; Nacos uses its generic configuration icon. -struct DatabaseBrandIcon: View { - @Environment(\.colorScheme) private var colorScheme - let kind: DatabaseKind - var size: CGFloat = 14 - - var body: some View { - Group { - if let image = DatabaseBrandIconLoader.image(for: kind, size: size, dark: colorScheme == .dark) { - Image(nsImage: image) - .resizable() - .renderingMode(.original) - .interpolation(.high) - .aspectRatio(contentMode: .fit) - } else { - LitheIDEAIcon(resourcePath: "expui/nodes/dataTables.svg", size: size, preservesOriginalColors: true) - } - } - .frame(width: size, height: size) - .accessibilityHidden(true) - } -} diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 6b3dc22f6..8ee37e1b4 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -569,86 +569,6 @@ enum LitheTheme { static var modifiedLine: Color { controlColor(light: 0xE6EFFA, dark: 0x25323E) } } - // Community grid/impl: GridColorSchemeUtil reads editor colors, not Table UI tokens. - enum DatabaseGrid { - // Structural surfaces share the workbench palette; cell states retain their own tokens. - static var background: Color { editor } - // GridEditorPanelBase.updateEditorPrefix -> INLINE_PARAMETER_HINT foreground. - static var clausePlaceholder: Color { activeTheme == .lithe ? controlColor(light: 0x7A7A7A, dark: 0x858A94) : secondaryText } - // SelectionColorLayer -> DataGridUIUtil.softHighlightOf: +/- 0x10 per RGB channel. - static var headerBackground: Color { activeTheme == .lithe ? controlColor(light: 0xEFEFEF, dark: 0x292A2C) : raised } - // SelectionColorLayer.getRowHeaderBackground -> GRID_CURRENT_ROW / CARET_ROW_COLOR. - static var currentRowBackground: Color { activeTheme == .lithe ? controlColor(light: 0xFCFAED, dark: 0x1F2024) : subtleSelection } - // Transparent row headers use editor line-number colors, not the cell foreground. - static var rowNumber: Color { activeTheme == .lithe ? controlColor(light: 0xADADAD, dark: 0x4B5059) : secondaryText } - static var currentRowNumber: Color { activeTheme == .lithe ? controlColor(light: 0xADADAD, dark: 0xA1A3AB) : primaryText } - static var border: Color { activeTheme == .lithe ? controlColor(light: 0xE6E6E6, dark: 0x323438) : guide } - // TableResultView reserves one logical pixel for each enabled grid line. - static let borderWidth: CGFloat = 1 - static func rowHeaderWidth(rowCount: Int) -> CGFloat { - let digits = String(max(1, rowCount)).count - let width = (String(repeating: "0", count: digits) as NSString).size(withAttributes: [.font: editorFont(size: 13)]).width - return max(24, ceil(width) + 16) - } - - static func columnWidths(_ columns: [String], availableWidth: CGFloat, rowHeaderWidth: CGFloat, - values: (String) -> [String]) -> [String: CGFloat] { - let font = editorFont(size: 13) - func textWidth(_ text: String) -> CGFloat { ceil((text as NSString).size(withAttributes: [.font: font]).width) } - let full = columns.map { max(40, max(textWidth($0) + 61, values($0).map { textWidth($0) + 6 }.max() ?? 0)) } - let available = max(40, availableWidth - rowHeaderWidth) - let preferred = full.map { min($0, available * (3 - sqrt(5)) / 2) } - let extra = max(0, available - preferred.reduce(0, +)) - let total = max(1, full.reduce(0, +)) - return Dictionary(columns.indices.map { - (columns[$0], preferred[$0] + extra * full[$0] / total) - }, uniquingKeysWith: max) - } - // MutationsColorLayer -> MessageType.ERROR -> Islands Notification.errorBackground. - static var failedMutation: Color { controlColor(light: 0xFFF6F5, dark: 0x56272B) } - // SelectionColorLayer -> DataGridUIUtil.softHighlightOf, preserving the mutation hue. - static func mutationBackground(_ color: Color, highlighted: Bool) -> Color { - guard highlighted else { return color } - return Color(nsColor: NSColor(name: nil) { appearance in - var rgb = NSColor(color) - appearance.performAsCurrentDrawingAppearance { - rgb = rgb.usingColorSpace(.deviceRGB) ?? rgb - } - let step: CGFloat = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? 16.0 / 255 : -16.0 / 255 - return NSColor(red: min(1, max(0, rgb.redComponent + step)), - green: min(1, max(0, rgb.greenComponent + step)), - blue: min(1, max(0, rgb.blueComponent + step)), alpha: rgb.alphaComponent) - }) - } - static var text: Color { activeTheme == .lithe ? controlColor(light: 0x000000, dark: 0xBCBEC4) : primaryText } - // GRID_NULL_VALUE inherits DEFAULT_LINE_COMMENT in the editor scheme. - static var nullText: Color { activeTheme == .lithe ? controlColor(light: 0x8C8C8C, dark: 0x7A7E85) : secondaryText } - static var selected: Color { activeTheme == .lithe ? controlColor(light: 0xA6D2FF, dark: 0x214283) : selection } - static var inactiveSelected: Color { activeTheme == .lithe ? controlColor(light: 0xB5B7BD, dark: 0x4C4F56) : subtleSelection } - // DarculaUIUtil.paintCellEditorBorder -> Islands Component.focusColor. - static var editingBorder: Color { activeTheme == .lithe ? controlColor(light: 0x3871E1, dark: 0x3871E1) : inputFocusBorder } - static let editingBorderWidth: CGFloat = 2 - // TableResultView: font line height + 1pt row margin + 4pt cell insets. - static var rowHeight: CGFloat { ceil(NSLayoutManager().defaultLineHeight(for: editorFont(size: 13)) * editorLineHeightMultiple) + 5 } - } - - /// Community c7f91397: Islands editor DEFAULT_* attributes, separate from UI semantic colors. - enum DatabaseConsole { - // CONSOLE_RANGE_TO_EXECUTE (Islands Dark); use its verified green as the Lithe light fallback. - // INLAY_TEXT_WITHOUT_BACKGROUND follows Islands Dark / Default light. - static var statementOutline: Color { controlColor(light: 0x3D7A49, dark: 0x3D7A49) } - static var executionInfo: Color { controlColor(light: 0x7A7A7A, dark: 0x727782) } - // EditorNotificationPanel Status.Error -> Islands Banner.errorBorderColor. - static var errorBorder: Color { controlColor(light: 0xFFC4C5, dark: 0x80383E) } - // Success follows greenCheckmark.svg; failure follows ERRORS_ATTRIBUTES.ERROR_STRIPE_COLOR. - static var successStripe: Color { controlColor(light: 0x369650, dark: 0x57965C) } - static var failureStripe: Color { controlColor(light: 0xCF5B56, dark: 0xD64D5B) } - static var comment: Color { activeTheme == .lithe ? controlColor(light: 0x8C8C8C, dark: 0x7A7E85) : secondaryText } - static var keyword: Color { activeTheme == .lithe ? controlColor(light: 0x0033B3, dark: 0xCF8E6D) : skill } - static var number: Color { activeTheme == .lithe ? controlColor(light: 0x1750EB, dark: 0x2AACB8) : warning } - static var string: Color { activeTheme == .lithe ? controlColor(light: 0x067D17, dark: 0x6AAB73) : success } - } - /// Product action buttons own their colors; selection and field focus keep the theme accent. enum ActionButton { static let cornerRadius: CGFloat = 4 @@ -712,15 +632,6 @@ enum LitheTheme { static var infoForeground: Color { controlColor(light: 0x5F6269, dark: 0x9FA2A8) } } - /// Community c7f91397 ManyIslands: dialog-bg, popup-border, text-default, Label.successForeground. - /// A connection result is a balloon over the dialog, not a popup-menu surface. - enum DatabaseConnectionResult { - static var background: Color { activeTheme == .lithe ? controlColor(light: 0xF7F8F9, dark: 0x191A1C) : editor } - static var border: Color { activeTheme == .lithe ? controlColor(light: 0xDDDFE4, dark: 0x33353B) : divider } - static var foreground: Color { activeTheme == .lithe ? controlColor(light: 0x000000, dark: 0xD1D3D9) : primaryText } - static var success: Color { activeTheme == .lithe ? controlColor(light: 0x338555, dark: 0x6DB083) : LitheTheme.success } - } - /// Community 915c3c26: Banner status tokens and InlineBannerBase/EditorNotificationPanel metrics. enum Banner { static let cornerRadius: CGFloat = 8 // fillRoundRect's 16pt arc is a diameter. diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index 9131f664d..9eccb4f7a 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -742,7 +742,6 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { /// Searchable filters share the action-menu window and dismissal lifecycle. func show(contentController: NSViewController, at screenPoint: NSPoint, appearance: NSAppearance?, opensUpward: Bool = false, searchOnTyping: Bool = false, - hasShadow: Bool = true, fadeInDuration: TimeInterval = 0, parentWindow: NSWindow? = nil, trigger: NSView? = nil, onAccept: (() -> Void)? = nil, @@ -764,10 +763,6 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { container = contentController } let panel = makePanel(contentController: container, appearance: appearance) - // AppKit's window shadow also adds a bright perimeter to custom balloon surfaces. - panel.hasShadow = hasShadow - let fadesIn = fadeInDuration > 0 && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - panel.alphaValue = fadesIn ? 0 : 1 customContentController = contentController contentWidth = resizableWidth.flatMap { $0.isFinite && $0 > 0 ? $0 : nil } contentMinimumWidth = minimumWidth @@ -869,13 +864,6 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { panel.orderFrontRegardless() panel.makeKey() if searchOnTyping { panel.makeFirstResponder(panel) } - if fadesIn { - NSAnimationContext.runAnimationGroup { context in - context.duration = fadeInDuration - context.timingFunction = CAMediaTimingFunction(name: .linear) - panel.animator().alphaValue = 1 - } - } } func resize(contentController: NSViewController) { diff --git a/macos/Sources/Lithe/Views/Components/LitheDropdown.swift b/macos/Sources/Lithe/Views/Components/LitheDropdown.swift index 84beff23a..c90ef7567 100644 --- a/macos/Sources/Lithe/Views/Components/LitheDropdown.swift +++ b/macos/Sources/Lithe/Views/Components/LitheDropdown.swift @@ -101,8 +101,6 @@ struct LitheDropdownPopover: NSViewRepresentable { @Environment(\.self) private var environment @Environment(\.colorScheme) private var colorScheme var opensUpward = false - var contentProvidesSurface = false - var fadeInDuration: TimeInterval = 0 var opensToSide = false var centersMenu = false var searchOnTyping = false @@ -137,8 +135,6 @@ struct LitheDropdownPopover: NSViewRepresentable { context.coordinator.onAccept = onAccept context.coordinator.environment = environment context.coordinator.environment.colorScheme = colorScheme - context.coordinator.contentProvidesSurface = contentProvidesSurface - context.coordinator.fadeInDuration = fadeInDuration context.coordinator.opensUpward = opensUpward context.coordinator.opensToSide = opensToSide context.coordinator.centersMenu = centersMenu @@ -167,8 +163,6 @@ struct LitheDropdownPopover: NSViewRepresentable { var content: () -> Content var environment = EnvironmentValues() var opensUpward = false - var contentProvidesSurface = false - var fadeInDuration: TimeInterval = 0 var opensToSide = false var centersMenu = false var searchOnTyping = false @@ -236,8 +230,7 @@ struct LitheDropdownPopover: NSViewRepresentable { } return } - let surface = contentProvidesSurface ? AnyView(content()) : AnyView(content().litheContextMenuSurface()) - let root = AnyView(surface.environment(\.self, environment)) + let root = AnyView(content().litheContextMenuSurface().environment(\.self, environment)) if let hostingController { hostingController.rootView = root presenter.resize(contentController: hostingController) @@ -248,7 +241,7 @@ struct LitheDropdownPopover: NSViewRepresentable { presenter.show( contentController: controller, at: point, appearance: appearance, opensUpward: opensUpward, - searchOnTyping: searchOnTyping, hasShadow: !contentProvidesSurface, fadeInDuration: fadeInDuration, parentWindow: window, trigger: anchor, + searchOnTyping: searchOnTyping, parentWindow: window, trigger: anchor, onAccept: onAccept == nil ? nil : { [weak self] in self?.onAccept?() }, resizableWidth: resizableWidth?.wrappedValue, minimumWidth: minimumWidth, resizableHeight: resizableHeight?.wrappedValue, minimumHeight: minimumHeight, diff --git a/macos/Sources/Lithe/Views/Components/LitheTextViewportLayout.swift b/macos/Sources/Lithe/Views/Components/LitheTextViewportLayout.swift index feddcc2c7..0e544f9d1 100644 --- a/macos/Sources/Lithe/Views/Components/LitheTextViewportLayout.swift +++ b/macos/Sources/Lithe/Views/Components/LitheTextViewportLayout.swift @@ -26,32 +26,6 @@ enum LitheTextViewportLayout { TextLineIndex(source: text as NSString).lineCount } - @MainActor - static func consoleLineHeight(in textView: NSTextView) -> CGFloat { - (textView.layoutManager?.defaultLineHeight(for: textView.font ?? LitheTheme.editorFont(size: 13)) ?? 0) - * LitheTheme.editorLineHeightMultiple - } - - /// One TextKit rectangle for both the console text and its gutter, including the empty final line. - @MainActor - static func consoleCaretLineRect(in textView: NSTextView) -> NSRect { - guard let layout = textView.layoutManager, let container = textView.textContainer else { return .zero } - layout.ensureLayout(for: container) - var rect = NSRect(x: 0, y: textView.textContainerOrigin.y, width: textView.bounds.width, - height: consoleLineHeight(in: textView)) - let length = textView.string.utf16.count - let finalLineIsEmpty = length > 0 && (textView.string as NSString).lineRange(for: NSRange(location: length, length: 0)).location == length - if length > 0, textView.selectedRange().location < length || !finalLineIsEmpty { - let glyph = layout.glyphIndexForCharacter(at: min(textView.selectedRange().location, length - 1)) - let fragment = layout.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil) - rect.origin.y += fragment.minY - rect.size.height = fragment.height - } else if length > 0 { - rect.origin.y += layout.extraLineFragmentRect.minY - } - return rect - } - /// 一次更新中软换行的最终判定。`incomingLineCount` 是待替换内容 /// (外部重载、模型驱动整文替换)的行数:替换发生在 chrome 应用 /// 之后,必须按它而不是旧缓冲区判定,否则大文件会先进入折行布局、 diff --git a/macos/Sources/Lithe/Views/Database/DatabaseLocalization.swift b/macos/Sources/Lithe/Views/Database/DatabaseLocalization.swift deleted file mode 100644 index 512781025..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseLocalization.swift +++ /dev/null @@ -1,118 +0,0 @@ -import SwiftUI -import LitheDatabaseModule - -/// Database operations keep some user-facing status values as strings so they -/// can be persisted in the audit log. Resolve those strings at the view edge: -/// known keys follow the app's injected locale, while database-driver errors -/// that have no translation continue to be shown verbatim. -enum DatabaseLocalization { - /// Native window titles and formatted output must use the app language too. - static func string(_ key: String, locale: Locale, bundle: Bundle = .main) -> String { - let localized = bundle.url(forResource: locale.identifier, withExtension: "lproj").flatMap(Bundle.init(url:)) ?? bundle - return localized.localizedString(forKey: key, value: key, table: nil) - } - - static func text(_ value: String) -> Text { - Text(LocalizedStringKey(value)) - } - - /// Turns failures emitted by our helper process into a localized summary - /// without changing the driver/server supplied detail. The detail can - /// contain SQL, identifiers, error codes, or vendor-specific diagnostics, - /// so it must remain exact for troubleshooting. - static func error(_ value: String) -> Text { - if value == "The Lithe database helper is not installed." { - return Text("The Lithe database helper is not installed.") - } - - let processPrefix = "The database helper exited with code " - if value.hasPrefix(processPrefix) { - let remainder = String(value.dropFirst(processPrefix.count)) - let parts = remainder.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - if parts.count == 2, - let code = Int(parts[0].trimmingCharacters(in: .whitespaces)) { - let detail = String(parts[1]).trimmingCharacters(in: .whitespaces) - return Text("The database helper exited with code \(code). Details: \(detail)") - } - } - - let invalidResponsePrefix = "The database helper returned invalid JSON: " - if value.hasPrefix(invalidResponsePrefix) { - let detail = String(value.dropFirst(invalidResponsePrefix.count)) - return Text("The database helper returned an invalid response. Details: \(detail)") - } - - let requestFailedPrefix = "Database request failed (" - if value.hasPrefix(requestFailedPrefix) { - let remainder = String(value.dropFirst(requestFailedPrefix.count)) - if let separator = remainder.range(of: "): ") { - let code = String(remainder[.. Text { - switch kind { - case .query: Text("Query") - case .mutation: Text("Data change") - case .definition: Text("Schema change") - case .transaction: Text("Transaction") - case .batch: Text("Batch") - case .unknown: Text("Unknown") - case nil: Text("None") - } - } - - static func auditAction(_ action: String) -> Text { - switch action { - case "schemaMigration": Text("Schema migration") - case "schemaChange": Text("Schema change") - case "tableEdit": Text("Table edit") - case "import": Text("Import") - case "sql": Text("SQL") - case "rollback": Text("Restore") - case "backup": Text("Backup") - default: text(action) - } - } - - static func queryTabTitle(_ title: String) -> Text { - let prefix = "Query " - if title.hasPrefix(prefix), let number = Int(title.dropFirst(prefix.count)) { - return Text("Query \(number)") - } - return Text(title) - } - - static func schemaDiffDetail(_ item: DatabaseSchemaDiffItem) -> Text { - switch item.kind { - case .addTable: - Text("Create table \(item.table)") - case .dropTable: - Text("Drop table \(item.table)") - case .addColumn: - Text("Add column \(detailSuffix(item.detail, prefix: "Add column "))") - case .dropColumn: - Text("Drop column \(detailSuffix(item.detail, prefix: "Drop column "))") - case .changeColumn: - Text("Change column \(detailSuffix(item.detail, prefix: "Change column "))") - case .addIndex: - Text("Add index \(detailSuffix(item.detail, prefix: "Add index "))") - case .dropIndex: - Text("Drop index \(detailSuffix(item.detail, prefix: "Drop index "))") - case .addForeignKey: - Text("Add foreign key \(detailSuffix(item.detail, prefix: "Add foreign key "))") - case .dropForeignKey: - Text("Drop foreign key \(detailSuffix(item.detail, prefix: "Drop foreign key "))") - } - } - - private static func detailSuffix(_ detail: String, prefix: String) -> String { - guard detail.hasPrefix(prefix) else { return detail } - return String(detail.dropFirst(prefix.count)) - } -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift deleted file mode 100644 index e87535a91..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift +++ /dev/null @@ -1,1797 +0,0 @@ -import AppKit -import SwiftUI -import LitheDatabaseModule - -/// Observe the feature directly so changing sections updates an already open tab. -struct DatabaseWorkspaceTabTitle: View { - @ObservedObject var feature: DatabaseFeatureModel - - var profileID: UUID? - var queryID: UUID? - - var title: String { - guard let profileID, let profile = feature.profiles.first(where: { $0.id == profileID }) else { - return "SQL History" - } - if let queryID, let query = feature.sqlTab(queryID, profileID: profileID) { - let suffix = query.title == "Query 1" ? "" : " \(query.title)" - return "console\(suffix) [\(profile.name)]" - } - return profile.database.isEmpty ? profile.name : profile.database - } - - var body: some View { Text(verbatim: title) } -} - -struct DatabaseWorkspaceView: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - - var body: some View { - Group { - if feature.selectedProfile == nil || (feature.workspaceSection == .history && feature.selectedProfile?.kind.isSQLDatabase != true) { - DatabaseHistoryView() - } else if feature.selectedProfile?.kind == .redis { - RedisWorkspaceView() - } else if feature.selectedProfile?.kind == .nacos { - NacosWorkspaceView() - } else if feature.selectedProfile?.kind == .mongodb { - mongoWorkspace - } else { - sqlWorkspace - } - } - .litheWorkbenchSurface(LitheTheme.editor) - .id(feature.selectedProfileID) - } - - private var sqlWorkspace: some View { DatabaseTableView() } - - private var mongoWorkspace: some View { - VStack(spacing: 0) { - HStack(spacing: 9) { - if let profile = feature.selectedProfile { - DatabaseBrandIcon(kind: .mongodb, size: 18) - VStack(alignment: .leading, spacing: 1) { - Text("MongoDB Documents").font(LitheTheme.uiFont(size: 12, weight: .semibold)) - Text(profile.database.isEmpty ? "admin" : profile.database) - .font(LitheTheme.uiFont(size: 9.5)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - Spacer() - Text("Collection data can be edited directly in the grid.") - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 12) - .frame(height: 44) - .litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - DatabaseTableView() - } - } -} - -extension DatabaseWorkspaceSection { - var titleKey: LocalizedStringKey { - switch self { - case .data: "Data" - case .sql: "SQL" - case .structure: "Structure" - case .history: "History" - } - } - var iconPath: String { - switch self { - case .data: "database/expui/table.svg" - case .sql: "expui/fileTypes/shell.svg" - case .structure: "expui/toolwindows/structure.svg" - case .history: "expui/general/history.svg" - } - } -} - -struct DatabaseHistoryView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - - private var entries: [DatabaseSQLHistoryEntry] { - guard let profileID = feature.selectedProfileID else { return feature.sqlHistory } - return feature.sqlHistory.filter { $0.profileID == profileID } - } - - var body: some View { - VStack(spacing: 0) { - LitheToolWindowHeader(title: "SQL History") { - Text("\(entries.count) statements") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - } - if entries.isEmpty { - LitheUnavailableView("No query history", systemImage: "clock.arrow.circlepath") - .foregroundStyle(LitheTheme.secondaryText) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(entries) { entry in - Button { - model.openDatabaseQuery(profileID: entry.profileID, sql: entry.sql) - } label: { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(entry.sql.replacingOccurrences(of: "\n", with: " ")) - .font(LitheTheme.codeFont) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(2) - Text(entry.executedAt, format: .dateTime.year().month().day().hour().minute().second()) - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .litheRowHover() - .padding(.horizontal, 4) - .padding(.vertical, 2) - } - } - .padding(12) - } - .litheScrollViewChrome() - } - } - } -} - -private extension DatabaseKind { - var displayName: String { - switch self { - case .mysql: "MySQL" - case .mariadb: "MariaDB" - case .postgresql: "PostgreSQL" - case .sqlite: "SQLite" - case .sqlserver: "SQL Server" - case .mongodb: "MongoDB" - case .redis: "Redis" - case .nacos: "Nacos" - } - } - - var symbolName: String { - switch self { - case .mysql: "cylinder.split.1x2" - case .mariadb: "cylinder.split.1x2" - case .postgresql: "cylinder.split.1x2" - case .sqlite: "externaldrive" - case .sqlserver: "server.rack" - case .mongodb: "leaf" - case .redis: "square.stack.3d.up.fill" - case .nacos: "slider.horizontal.3" - } - } -} - -struct DatabaseSQLWorkspaceView: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @EnvironmentObject private var model: AppModel - @State private var pendingRisk: DatabaseSQLAnalysis? - @State private var pendingScope: DatabaseSQLExecutionScope = .all - @State private var pendingTabID: UUID? - @State private var showsRiskConfirmation = false - @State private var isCheckingRisk = false - @State private var sqlSelection = "" - @State private var sqlSelectionRange = NSRange(location: 0, length: 0) - @State private var showsQueryHistory = false - @State private var historyProfileID: UUID? - @State private var historyTabID: UUID? - @State private var historySelectionRange = NSRange(location: 0, length: 0) - - var body: some View { - VStack(spacing: 0) { - commandBar - LitheToolWindowHeaderDivider() - editor - } - .litheWorkbenchSurface(LitheTheme.editor) - .workbenchHoverTooltipScope() - .alert("Confirm database change", isPresented: $showsRiskConfirmation, presenting: pendingRisk) { analysis in - Button("Cancel", role: .cancel) { - pendingRisk = nil - pendingTabID = nil - } - Button(analysis.statementCount > 1 ? "Run batch" : "Run statement", role: .destructive) { - if let tabID = pendingTabID { - model.showToolWindow(.databaseServices) - Task { await feature.runSQL(in: tabID, scope: pendingScope, confirmedRisk: true, selectionRange: sqlSelectionRange) } - } - pendingRisk = nil - pendingTabID = nil - } - } message: { analysis in - DatabaseLocalization.text(analysis.warning ?? "This statement can change the database.") - } - .background(LitheCenteredPopup(isPresented: $showsQueryHistory, - dialogTitle: feature.profiles.first { $0.id == historyProfileID }.map { - String(format: DatabaseLocalization.string("%@ Query History", locale: model.settings.language.locale), $0.name) - }) { - DatabaseQueryHistoryDialog(profileID: historyProfileID, onClose: { showsQueryHistory = false }) { entry in - guard let id = historyTabID, feature.selectedProfileID == historyProfileID, - let tab = feature.sqlTabs.first(where: { $0.id == id }), - historySelectionRange.location <= tab.sql.utf16.count, - historySelectionRange.length <= tab.sql.utf16.count - historySelectionRange.location else { return } - feature.updateSQL((tab.sql as NSString).replacingCharacters(in: historySelectionRange, with: entry.sql), in: id) - showsQueryHistory = false - } - .environmentObject(feature) - }) - .onChange(of: feature.selectedSQLTabID) { _ in - sqlSelection = "" - sqlSelectionRange = NSRange(location: 0, length: 0) - } - } - - private var commandBar: some View { - HStack(spacing: 5) { - Button(action: runSelectedQuery) { - LitheIDEAIcon(resourcePath: "expui/run/run.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .accessibilityLabel("Run") - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) - .help(hasSQLSelection ? "Run selected SQL (Command-Return)" : "Run all SQL (Command-Return)") - - LitheMenu { - LitheContextMenuItem.action("Run All", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/run/run.svg", size: 16, preservesOriginalColors: true))) { runAllQuery() } - LitheContextMenuItem.action("Run Selection") { runSelectionQuery() } - .disabled(!hasSQLSelection) - } label: { - LitheIDEAIcon(resourcePath: "expui/general/chevronDown.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) - .workbenchHoverHelp(Text("Choose SQL execution scope")) - .accessibilityLabel("Choose SQL execution scope") - - Rectangle().fill(LitheTheme.ActionToolbar.separator).frame(width: 1, height: 17) - .frame(width: 7, height: 24) - - Button { if let id = feature.selectedSQLTabID { feature.formatSQL(in: id) } } label: { - LitheIDEAIcon(resourcePath: "expui/actions/reformatCode.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .disabled(feature.selectedSQLTab?.sql.isEmpty != false) - .workbenchHoverHelp(Text("Format SQL")) - .accessibilityLabel("Format SQL") - - Button { - historyProfileID = feature.selectedProfileID - historyTabID = feature.selectedSQLTabID - historySelectionRange = sqlSelectionRange - showsQueryHistory = true - } label: { - LitheIDEAIcon(resourcePath: "expui/general/history.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .disabled(feature.selectedProfile == nil) - .workbenchHoverHelp(Text("Query history")) - .accessibilityLabel("Query history") - - if let execution = feature.selectedSQLTab?.execution { - executionLabel(execution) - .font(LitheTheme.uiFont(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - if let profile = feature.selectedProfile { - let currentDatabase = profile.database.isEmpty ? profile.name : profile.database - let canSwitchDatabase = profile.kind == .mysql || profile.kind == .mariadb - LitheMenu { - if !profile.database.isEmpty || !canSwitchDatabase { - LitheContextMenuItem.action(currentDatabase, localizesTitle: false, initiallySelected: true) {} - } - if canSwitchDatabase { - let otherDatabases = feature.databaseOptions.filter { $0 != profile.database } - if !otherDatabases.isEmpty { - LitheContextMenuItem.heading("Non-Introspected") - for database in otherDatabases { - LitheContextMenuItem.action(database, localizesTitle: false) { - Task { await feature.selectDatabase(database, for: profile) } - } - } - } else if feature.databaseOptions.isEmpty { - LitheContextMenuItem.heading("No databases loaded") - } - LitheContextMenuItem.separator - LitheContextMenuItem.action("Refresh databases") { Task { await feature.refreshDatabases() } } - } - } label: { - // Community ActionButtonWithText: 2pt icon/text/arrow gaps, 4pt horizontal margins. - HStack(spacing: 2) { - LitheIDEAIcon(resourcePath: "database/expui/schema.svg", size: 16, preservesOriginalColors: true) - Text(verbatim: currentDatabase).lineLimit(1) - LitheIDEAIcon(resourcePath: "expui/general/chevronDown.svg", size: 16, preservesOriginalColors: true) - } - .font(LitheTheme.uiFont(size: 13)) - .padding(.horizontal, 4) - } - .litheToolbarIconButton(isIconOnly: false) - .disabled(feature.sqlTabs.contains(where: { $0.isRunning }) || showsRiskConfirmation) - .workbenchHoverHelp(Text("Switch current database")) - .accessibilityLabel("Switch current database") - .accessibilityValue(Text(verbatim: currentDatabase)) - } else { - Text("No connection") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.warning) - } - } - .padding(.horizontal, 8) - .frame(height: LitheTheme.Metrics.toolWindowHeaderHeight) - .litheWorkbenchSurface(LitheTheme.toolHeader) - } - - @ViewBuilder - private var editor: some View { - if let tab = feature.selectedSQLTab { - SQLSyntaxEditor( - text: Binding( - get: { feature.selectedSQLTab?.sql ?? "" }, - set: { feature.updateSQL($0, in: tab.id) } - ), - completions: feature.sqlCompletionItems, - onRun: runSelectedQuery, - onSelectionChange: { sqlSelection = $0 }, - executions: tab.statementExecutions, - onSelectionRangeChange: { sqlSelectionRange = $0 } - ) - .id(tab.id) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(alignment: .bottomLeading) { - if let error = tab.errorMessage { - HStack { - DatabaseLocalization.error(error) - .font(LitheTheme.uiFont(size: 13)).textSelection(.enabled) - Spacer(minLength: 8) - Button { feature.dismissSQLError(in: tab.id) } label: { - LitheIDEAIcon(resourcePath: "expui/general/closeSmall.svg", size: 16) - }.litheToolbarIconButton().accessibilityLabel("Close") - } - .foregroundStyle(LitheTheme.DatabaseGrid.text) - // Editor.Notification.WithoutStatus.borderInsets.compact (Darcula parent). - .padding(.horizontal, 16).padding(.vertical, 6) - .background(LitheTheme.DatabaseGrid.failedMutation) - .overlay(alignment: .top) { Rectangle().fill(LitheTheme.DatabaseConsole.errorBorder).frame(height: 1) } - - } - } - } else { - LitheUnavailableView("No Query Tab", systemImage: "terminal") - } - } - - private func runSelectedQuery() { - if hasSQLSelection { - run(scope: .selection(sqlSelection)) - } else { - runAllQuery() - } - } - - private func runAllQuery() { - run(scope: .all) - } - - private func runSelectionQuery() { - guard hasSQLSelection else { return } - run(scope: .selection(sqlSelection)) - } - - private func run(scope: DatabaseSQLExecutionScope) { - guard let tabID = feature.selectedSQLTabID, !isCheckingRisk else { return } - let selection = sqlSelectionRange - isCheckingRisk = true - Task { - let risk = await feature.sqlConfirmation(forSQLTab: tabID, scope: scope) - isCheckingRisk = false - if let risk { - pendingRisk = risk - pendingScope = scope - pendingTabID = tabID - showsRiskConfirmation = true - } else { - model.showToolWindow(.databaseServices) - await feature.runSQL(in: tabID, scope: scope, selectionRange: selection) - } - } - } - - private var hasSQLSelection: Bool { - !sqlSelection.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private func executionLabel(_ execution: DatabaseSQLExecution) -> Text { - if let rows = execution.rowsReturned, let affected = execution.rowsAffected { - return Text("\(rows) rows, \(affected) affected \(execution.durationMilliseconds) ms") - } - if let rows = execution.rowsReturned { return Text("\(rows) rows \(execution.durationMilliseconds) ms") } - if let affected = execution.rowsAffected { return Text("\(affected) affected \(execution.durationMilliseconds) ms") } - return Text("\(execution.durationMilliseconds) ms") - } - - -} - -struct DatabaseQueryResultGrid: View { - private struct ResultCell: Hashable { let row: Int; let column: Int } - let columns: [String] - let rows: [DatabaseRow] - @Environment(\.controlActiveState) private var controlActiveState - @State private var gridActive = false - @State private var selectedCell: ResultCell? - @State private var selectedRows: Set = [] - @FocusState private var focusedCell: ResultCell? - - private var rowHeaderWidth: CGFloat { LitheTheme.DatabaseGrid.rowHeaderWidth(rowCount: rows.count) } - private var border: Color { LitheTheme.DatabaseGrid.border } - - var body: some View { - GeometryReader { geometry in - // ponytail: match the table editor's bounded 100-row width sample. - let widths = LitheTheme.DatabaseGrid.columnWidths(columns, availableWidth: geometry.size.width, - rowHeaderWidth: rowHeaderWidth) { column in - rows.prefix(100).map { display($0[column]) } - } - ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - resultRow(index: index, row: row, widths: widths) - } - } header: { - HStack(spacing: 0) { - Button { - selectedRows = selectedRows.count == rows.count ? [] : Set(rows.indices) - } label: { Text("#").frame(width: rowHeaderWidth, height: 25) } - .buttonStyle(.litheNoPress) - .overlay(alignment: .trailing) { Rectangle().fill(border).frame(width: 1) } - ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in - HStack(spacing: 4) { - LitheIDEAIcon(resourcePath: "database/expui/column.svg", size: 16, preservesOriginalColors: true) - Text(column).lineLimit(1) - } - .padding(.leading, 4).padding(.trailing, 3) - .frame(width: widths[column] ?? 40, height: 25, alignment: .leading) - .background(selectedCell?.column == columnIndex || !selectedRows.isEmpty - ? LitheTheme.DatabaseGrid.currentRowBackground : Color.clear) - .overlay(alignment: .trailing) { Rectangle().fill(border).frame(width: 1) } - } - } - .foregroundStyle(LitheTheme.DatabaseGrid.text) - .background(LitheTheme.DatabaseGrid.headerBackground) - .overlay(alignment: .bottom) { Rectangle().fill(border).frame(height: 1) } - } - } - .font(LitheTheme.codeFont) - .frame(minWidth: max(geometry.size.width, columns.reduce(0) { $0 + (widths[$1] ?? 40) } + rowHeaderWidth), - minHeight: geometry.size.height, alignment: .topLeading) - } - .litheScrollViewChrome() - .background(LitheToolWindowActivityTracker(isActive: $gridActive)) - .onChange(of: focusedCell) { cell in if let cell { selectedCell = cell } } - .onMoveCommand { direction in - guard let current = selectedCell, columns.indices.contains(current.column), !rows.isEmpty else { return } - var row = current.row - var nextColumn = current.column - switch direction { - case .up: row = max(0, row - 1) - case .down: row = min(rows.count - 1, row + 1) - case .left: nextColumn = max(0, current.column - 1) - case .right: nextColumn = min(columns.count - 1, current.column + 1) - @unknown default: return - } - selectedRows = [] - focusedCell = ResultCell(row: row, column: nextColumn) - } - .onCopyCommand { - let text: String - if !selectedRows.isEmpty { - text = selectedRows.sorted().filter { rows.indices.contains($0) } - .map { index in columns.map { display(rows[index][$0]) }.joined(separator: "\t") }.joined(separator: "\n") - } else if let selectedCell, rows.indices.contains(selectedCell.row) { - text = display(rows[selectedCell.row][columns[selectedCell.column]]) - } else { return [] } - return [NSItemProvider(object: text as NSString)] - } - } - .background(LitheTheme.DatabaseGrid.background) - .onChange(of: columns) { _ in selectedCell = nil; focusedCell = nil; selectedRows = [] } - .onChange(of: rows) { _ in selectedCell = nil; focusedCell = nil; selectedRows = [] } - } - - private func resultRow(index: Int, row: DatabaseRow, widths: [String: CGFloat]) -> some View { - HStack(spacing: 0) { - Button { - if selectedRows.contains(index) { selectedRows.remove(index) } - else { selectedRows.insert(index) } - } label: { - Text("\(index + 1)") - .foregroundStyle(selectedCell?.row == index || selectedRows.contains(index) - ? LitheTheme.DatabaseGrid.currentRowNumber : LitheTheme.DatabaseGrid.rowNumber) - .frame(width: rowHeaderWidth, height: LitheTheme.DatabaseGrid.rowHeight) - } - .buttonStyle(.litheNoPress) - .overlay(alignment: .trailing) { Rectangle().fill(border).frame(width: 1) } - ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in - resultCell(index: index, row: row, columnIndex: columnIndex, column: column, width: widths[column] ?? 40) - } - } - .background(selectedCell?.row == index || selectedRows.contains(index) - ? LitheTheme.DatabaseGrid.currentRowBackground : Color.clear) - .overlay(alignment: .bottom) { Rectangle().fill(border).frame(height: 1) } - } - - private func resultCell(index: Int, row: DatabaseRow, columnIndex: Int, column: String, width: CGFloat) -> some View { - let key = ResultCell(row: index, column: columnIndex) - return Text(display(row[column])) - .foregroundStyle(row[column] == nil || row[column] == .null - ? LitheTheme.DatabaseGrid.nullText : LitheTheme.DatabaseGrid.text) - .lineLimit(1).truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: alignment(row[column])) - .focusable().litheFocusEffectHidden().focused($focusedCell, equals: key) - .padding(.horizontal, 3) - .frame(width: width, height: LitheTheme.DatabaseGrid.rowHeight) - .background(selectedCell == key || selectedRows.contains(index) - ? (gridActive && controlActiveState == .key ? LitheTheme.DatabaseGrid.selected : LitheTheme.DatabaseGrid.inactiveSelected) : Color.clear) - .overlay { - DatabaseGridCellClickTarget { _ in - selectedRows = [] - selectedCell = key - focusedCell = key - } - } - .overlay(alignment: .trailing) { Rectangle().fill(border).frame(width: 1) } - .litheContextMenu { - [LitheContextMenuItem.action("Copy Cell") { copy(display(row[column])) }, - LitheContextMenuItem.action("Copy Row as TSV") { copy(columns.map { display(row[$0]) }.joined(separator: "\t")) }] - } - } - - private func alignment(_ value: DatabaseValue?) -> Alignment { - switch value { case .integer, .number, .decimal: return .trailing; default: return .leading } - } - private func display(_ value: DatabaseValue?) -> String { value?.displayText ?? "" } - private func copy(_ value: String) { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(value, forType: .string) } -} - -struct DatabaseStructureView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var editor: DatabaseSchemaEditorKind? - @State private var pendingChange: DatabaseSchemaChange? - @State private var showsDestructiveConfirmation = false - - var body: some View { - if let table = feature.selectedTable { - VStack(spacing: 0) { - HStack(spacing: 5) { - Text(table).font(LitheTheme.uiFont(size: 13)) - Spacer() - Button { editor = .column } label: { LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton().help("Add column") - Button { editor = .index } label: { LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton().help("Add index") - Button { editor = .foreignKey } label: { LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton().help("Add foreign key") - Button { Task { await feature.openTable(table) } } label: { LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton().help("Refresh structure") - } - .padding(.horizontal, 8).frame(height: LitheTheme.Metrics.toolWindowHeaderHeight).litheWorkbenchSurface(LitheTheme.editor) - ScrollView { - VStack(alignment: .leading, spacing: 0) { - LitheToolWindowHeader(title: "Columns") - Group { - ForEach(feature.columns, id: \.self) { column in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(column) - Text(feature.columnTypes[column] ?? "") - .font(LitheTheme.codeFont) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - HStack(spacing: 2) { - Button { - editor = .renameColumn(column) - } label: { - LitheIDEAIcon(resourcePath: "expui/general/edit.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .help("Rename Column…") - - Button(role: .destructive) { - pendingChange = DatabaseSchemaChange(operation: "dropColumn", table: table, name: column) - showsDestructiveConfirmation = true - } label: { - LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16, preservesOriginalColors: true) - .foregroundStyle(LitheTheme.error) - } - .litheToolbarIconButton() - .help("Drop Column") - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - } - LitheToolWindowHeader(title: "Indexes") - Group { - if feature.indexes.isEmpty { Text("No indexes").foregroundStyle(LitheTheme.secondaryText) } - ForEach(Array(feature.indexes.enumerated()), id: \.offset) { _, row in - Text(metadata(row)) - } - } - LitheToolWindowHeader(title: "Foreign Keys") - Group { - if feature.foreignKeys.isEmpty { Text("No foreign keys").foregroundStyle(LitheTheme.secondaryText) } - ForEach(Array(feature.foreignKeys.enumerated()), id: \.offset) { _, row in - Text(metadata(row)) - } - } - } - .font(LitheTheme.uiFont(size: 13)) - .padding(16) - } - .litheScrollViewChrome() - } - .background(LitheCenteredPopup(isPresented: Binding( - get: { editor != nil }, set: { if !$0 { editor = nil } } - ), dialogTitle: String(localized: String.LocalizationValue(editor?.title ?? "Structure"))) { - if let editor { DatabaseSchemaEditorView( - kind: editor, - table: table, - columns: feature.columns, - onCancel: { self.editor = nil }, - onSave: { change in - self.editor = nil - Task { _ = await feature.applySchemaChange(change, confirmed: false) } - } - ) - .environment(\.locale, model.settings.language.locale) - .id(editor.id + model.settings.language.rawValue) - } - }) - .alert("Drop database object?", isPresented: $showsDestructiveConfirmation, presenting: pendingChange) { change in - Button("Cancel", role: .cancel) { pendingChange = nil } - Button("Drop", role: .destructive) { - Task { _ = await feature.applySchemaChange(change, confirmed: true) } - pendingChange = nil - } - } message: { change in - Text("Dropping \(change.name) cannot be undone by the table editor. A recovery snapshot should be taken before this operation.") - } - } else { - LitheToolWindowStatusText("Open a table to inspect its structure") - } - } - - private func metadata(_ row: DatabaseRow) -> String { - row.keys.sorted().compactMap { key in - guard let value = row[key] else { return nil } - return "\(key): \(display(value))" - }.joined(separator: " ") - } - - private func display(_ value: DatabaseValue) -> String { - value.displayText - } -} - -private enum DatabaseSchemaEditorKind: Identifiable { - case column - case renameColumn(String) - case index - case foreignKey - - var title: String { - switch self { - case .column: "Add Column" - case .renameColumn: "Rename Column" - case .index: "Add Index" - case .foreignKey: "Add Foreign Key" - } - } - - var id: String { - switch self { - case .column: "column" - case let .renameColumn(name): "rename-\(name)" - case .index: "index" - case .foreignKey: "foreign-key" - } - } -} - -private struct DatabaseSchemaEditorView: View { - let kind: DatabaseSchemaEditorKind - let table: String - let columns: [String] - let onCancel: () -> Void - let onSave: (DatabaseSchemaChange) -> Void - @State private var name = "" - @State private var oldName = "" - @State private var dataType = "TEXT" - @State private var nullable = true - @State private var defaultValue = "" - @State private var indexName = "" - @State private var indexColumns: Set = [] - @State private var constraintName = "" - @State private var referencedTable = "" - @State private var referencedColumns = "" - - @FocusState private var focusedField: String? - - var body: some View { - VStack(spacing: 0) { - ScrollView { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - switch kind { - case .column: - GridRow { Text("Column name").frame(width: 150, alignment: .leading); TextField("", text: $name).accessibilityLabel("Column name").focused($focusedField, equals: "name").modifier(LitheCommitDialogInputStyle(focused: focusedField == "name")) } - GridRow { Text("SQL type"); TextField("", text: $dataType).accessibilityLabel("SQL type").focused($focusedField, equals: "type").modifier(LitheCommitDialogInputStyle(focused: focusedField == "type")) } - GridRow { Text("Default expression"); TextField("Optional", text: $defaultValue).focused($focusedField, equals: "default").modifier(LitheCommitDialogInputStyle(focused: focusedField == "default")) } - GridRow { Color.clear.frame(width: 150, height: 1); Toggle("Allow NULL", isOn: $nullable).toggleStyle(.checkbox) } - case let .renameColumn(column): - GridRow { Text("Current name").frame(width: 150, alignment: .leading); Text(column).font(LitheTheme.codeFont).frame(maxWidth: .infinity, alignment: .leading) } - GridRow { Text("New name"); TextField("", text: $name).accessibilityLabel("New name").focused($focusedField, equals: "name").modifier(LitheCommitDialogInputStyle(focused: focusedField == "name")) } - case .index: - GridRow { Text("Index name").frame(width: 150, alignment: .leading); TextField("", text: $indexName).accessibilityLabel("Index name").focused($focusedField, equals: "name").modifier(LitheCommitDialogInputStyle(focused: focusedField == "name")) } - columnSelection - case .foreignKey: - GridRow { Text("Constraint name").frame(width: 150, alignment: .leading); TextField("", text: $constraintName).accessibilityLabel("Constraint name").focused($focusedField, equals: "name").modifier(LitheCommitDialogInputStyle(focused: focusedField == "name")) } - GridRow { Text("Referenced table"); TextField("", text: $referencedTable).accessibilityLabel("Referenced table").focused($focusedField, equals: "table").modifier(LitheCommitDialogInputStyle(focused: focusedField == "table")) } - GridRow { Text("Referenced columns"); TextField("Comma separated", text: $referencedColumns).focused($focusedField, equals: "columns").modifier(LitheCommitDialogInputStyle(focused: focusedField == "columns")) } - columnSelection - } - }.padding(16) - }.litheScrollViewChrome() - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack(spacing: LitheTheme.ActionButton.spacing) { - Spacer() - Button("Cancel", action: onCancel).buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) - Button("Apply") { onSave(makeChange()) } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)).keyboardShortcut(.defaultAction).disabled(!isValid) - }.padding(16) - } - .font(LitheTheme.uiFont(size: 13)).foregroundStyle(LitheTheme.primaryText) - .frame(width: 480, height: editorHeight).background(LitheCommitDialogStyle.background) - .onAppear { if case let .renameColumn(column) = kind { oldName = column } } - } - - private var columnSelection: some View { - GridRow { - Text("Columns").frame(width: 150, alignment: .leading) - VStack(alignment: .leading, spacing: 8) { - ForEach(columns, id: \.self) { column in - Toggle(column, isOn: Binding(get: { indexColumns.contains(column) }, set: { - if $0 { indexColumns.insert(column) } else { indexColumns.remove(column) } - })).toggleStyle(.checkbox) - } - }.frame(maxWidth: .infinity, alignment: .leading) - } - } - - private var editorHeight: CGFloat { - if case .foreignKey = kind { return 430 } - return 330 - } - - private var isValid: Bool { - switch kind { - case .column: !name.isEmpty && !dataType.isEmpty - case .renameColumn: !oldName.isEmpty && !name.isEmpty - case .index: !indexName.isEmpty && !indexColumns.isEmpty - case .foreignKey: !constraintName.isEmpty && !referencedTable.isEmpty && !indexColumns.isEmpty && !referencedColumns.isEmpty - } - } - - private func makeChange() -> DatabaseSchemaChange { - switch kind { - case .column: - return DatabaseSchemaChange(operation: "addColumn", table: table, name: name, dataType: dataType, nullable: nullable, defaultValue: defaultValue) - case .renameColumn: - return DatabaseSchemaChange(operation: "renameColumn", table: table, name: name, oldName: oldName) - case .index: - return DatabaseSchemaChange(operation: "createIndex", table: table, indexName: indexName, indexColumns: columns.filter { indexColumns.contains($0) }) - case .foreignKey: - return DatabaseSchemaChange(operation: "addForeignKey", table: table, indexColumns: columns.filter { indexColumns.contains($0) }, constraintName: constraintName, referencedTable: referencedTable, referencedColumns: referencedColumns.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }) - } - } -} - -private struct DatabaseDiagnosticsView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var diagnosticKind = "tableSize" - @State private var pendingRollback: DatabaseRecoveryPoint? - @State private var showsRollbackConfirmation = false - @State private var showsBackupSchedule = false - - var body: some View { - let tab = feature.selectedSQLTab - let analysis = tab.map { feature.analysis(forSQLTab: $0.id) } - VStack(spacing: 0) { - HStack(spacing: 6) { - LitheSettingsSelect(selection: $diagnosticKind, options: ["tableSize", "locks", "slowQueries", "indexes", "dataQuality", "schemaImpact"], width: 170, accessibilityLabel: "Diagnostic", title: { ["tableSize": "Table size", "locks": "Locks", "slowQueries": "Slow queries", "indexes": "Indexes", "dataQuality": "Data quality", "schemaImpact": "Schema impact"][$0] ?? $0 }) - .frame(width: 170) - Button { loadDiagnostics() } label: { Image(systemName: "arrow.clockwise") }.litheIconButton().help("Run diagnostic") - Button { if let profileID = feature.selectedProfileID { Task { _ = await feature.createBackup(profileID: profileID) } } } label: { Image(systemName: "archivebox") }.litheIconButton().help("Create database backup") - Button { showsBackupSchedule = true } label: { Image(systemName: "calendar.badge.clock") }.litheIconButton().help("Configure backup schedule") - if let progress = feature.backupProgress { - ProgressView(value: progress) - .frame(width: 90) - .help("Backing up database") - } - Spacer() - } - .padding(.horizontal, 10).frame(height: 36).litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - List { - Section("SQL safety") { - LabeledContent("Selected statement") { DatabaseLocalization.statementKind(analysis?.kind) } - LabeledContent("Statements") { Text("\(analysis?.statementCount ?? 0)") } - if let warning = analysis?.warning { - Label { - DatabaseLocalization.text(warning) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - } - .foregroundStyle(LitheTheme.warning) - } else { - Label("No destructive pattern detected", systemImage: "checkmark.shield") - .foregroundStyle(LitheTheme.success) - } - } - Section("Execution log") { - let events = feature.executionEvents - .filter { $0.profileID == feature.selectedProfileID } - .prefix(20) - if events.isEmpty { - Text("No execution events yet").foregroundStyle(LitheTheme.secondaryText) - } else { - ForEach(events) { event in - HStack(alignment: .top, spacing: 8) { - Image(systemName: event.status == .succeeded ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(event.status == .succeeded ? LitheTheme.success : LitheTheme.error) - VStack(alignment: .leading, spacing: 2) { - HStack { - Text("\(event.source.rawValue.uppercased()) · \(event.operation)") - .font(LitheTheme.uiFont(size: 11, weight: .medium)) - Spacer() - Text("\(event.durationMilliseconds) ms") - .font(LitheTheme.uiFont(size: 10, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - if let error = event.errorMessage { - Text(error) - .font(LitheTheme.uiFont(size: 10)) - .foregroundStyle(LitheTheme.error) - .lineLimit(2) - } else if let rows = event.rowsReturned { - Text("\(rows) rows returned") - .font(LitheTheme.uiFont(size: 10)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - } - } - } - } - Section("Recent executions") { - let entries = feature.sqlHistory - .filter { $0.profileID == feature.selectedProfileID } - .prefix(10) - if entries.isEmpty { - Text("No executed SQL yet").foregroundStyle(LitheTheme.secondaryText) - } else { - ForEach(entries) { entry in - VStack(alignment: .leading, spacing: 3) { - HStack { - DatabaseLocalization.statementKind(entry.kind) - Spacer() - Text("\(entry.durationMilliseconds) ms") - .font(LitheTheme.uiFont(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - Text(entry.sql.replacingOccurrences(of: "\n", with: " ")) - .font(LitheTheme.uiFont(size: 10, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - .textSelection(.enabled) - } - } - } - } - Section("Recovery points") { - let points = feature.recoveryPoints.prefix(10) - if points.isEmpty { Text("No recovery point yet").foregroundStyle(LitheTheme.secondaryText) } - ForEach(points) { point in - HStack { - VStack(alignment: .leading, spacing: 2) { - DatabaseLocalization.text(point.reason) - Text("\(point.byteCount) bytes") - .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Button { pendingRollback = point; showsRollbackConfirmation = true } label: { Image(systemName: "arrow.uturn.backward") } - .litheIconButton().help("Restore recovery point") - } - } - } - Section("Audit") { - let entries = feature.auditEntries - .filter { $0.profileID == feature.selectedProfileID } - .prefix(10) - if entries.isEmpty { - Text("No audit entries yet").foregroundStyle(LitheTheme.secondaryText) - } - ForEach(entries) { entry in - HStack { - Image(systemName: entry.succeeded ? "checkmark.circle" : "xmark.circle") - .foregroundStyle(entry.succeeded ? LitheTheme.success : LitheTheme.error) - DatabaseLocalization.text(entry.summary) - Spacer() - DatabaseLocalization.auditAction(entry.action).foregroundStyle(LitheTheme.secondaryText) - } - } - } - } - .listStyle(.inset) - if let result = feature.lastDiagnostics { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - let columns = result.columns ?? result.rows.reduce(into: [String]()) { columns, row in - for key in row.keys where !columns.contains(key) { columns.append(key) } - } - DatabaseQueryResultGrid(columns: columns, rows: result.rows) - .frame(minHeight: 170) - } - } - .alert("Restore recovery point?", isPresented: $showsRollbackConfirmation, presenting: pendingRollback) { point in - Button("Cancel", role: .cancel) { pendingRollback = nil } - Button("Restore", role: .destructive) { - Task { _ = await feature.rollback(to: point) } - pendingRollback = nil - } - } message: { point in - Text("This will restore the saved SQL snapshot from \(point.reason). Current data may be overwritten.") - } - .sheet(isPresented: $showsBackupSchedule) { - if let profile = feature.selectedProfile { - DatabaseBackupScheduleEditor(profile: profile) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - } - } - } - - private func loadDiagnostics() { - Task { - _ = await feature.loadDiagnostics(DatabaseDiagnosticsRequest(kind: diagnosticKind, schema: "", table: feature.selectedTable ?? "")) - } - } -} - -private struct DatabaseBackupScheduleEditor: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var feature: DatabaseFeatureModel - let profile: DatabaseProfile - @State private var enabled = true - @State private var intervalHours = 24 - @State private var retentionCount = 14 - - var body: some View { - VStack(spacing: 0) { - Text("Backup Schedule").font(LitheTheme.uiFont(size: 15, weight: .semibold)).frame(maxWidth: .infinity, alignment: .leading).padding(16) - Form { - LabeledContent("Connection") { Text(profile.name) } - Toggle("Enable scheduled backups", isOn: $enabled) - Stepper(value: $intervalHours, in: 1...720) { - Text("Backup interval: \(intervalHours) hours") - } - Stepper("Keep \(retentionCount) recovery points", value: $retentionCount, in: 1...365) - } - .formStyle(.grouped) - HStack(spacing: LitheTheme.ActionButton.spacing) { - Spacer() - Button("Cancel") { dismiss() }.buttonStyle(LitheActionButtonStyle()) - Button("Save") { - feature.configureBackupSchedule(profileID: profile.id, isEnabled: enabled, intervalHours: intervalHours, retentionCount: retentionCount) - dismiss() - } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - } - .padding(16) - } - .frame(width: 420, height: 280) - .onAppear { - if let schedule = feature.backupSchedules.first(where: { $0.profileID == profile.id }) { - enabled = schedule.isEnabled - intervalHours = schedule.intervalHours - retentionCount = schedule.retentionCount - } - } - } -} - -struct SQLSyntaxEditor: NSViewRepresentable { - @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject private var settings: AppSettings - @Binding var text: String - let completions: [String] - let onRun: () -> Void - let onSelectionChange: (String) -> Void - var executions: [DatabaseSQLStatementExecution] = [] - var onSelectionRangeChange: (NSRange) -> Void = { _ in } - var isEditable = true - var wrapsLines = false - - func makeCoordinator() -> Coordinator { Coordinator(text: $text, completions: completions, onRun: onRun, onSelectionChange: onSelectionChange) } - - func makeNSView(context: Context) -> EditorContainerView { - let isDark = colorScheme == .dark - let scrollView = SQLScrollView() - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true - scrollView.autohidesScrollers = true - scrollView.verticalScroller = LitheScrollViewChrome.CompactScroller() - scrollView.horizontalScroller = LitheScrollViewChrome.CompactScroller() - (scrollView.verticalScroller as? LitheScrollViewChrome.CompactScroller)?.role = .editor - (scrollView.horizontalScroller as? LitheScrollViewChrome.CompactScroller)?.role = .editor - - let textView = SQLTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 240)) - textView.delegate = context.coordinator - textView.string = text - textView.font = LitheTheme.editorFont(size: settings.editorFontSize) - textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle - applyAppearance(to: textView, in: scrollView, isDark: isDark) - // Community EditorComponentImpl paints from its content origin; avoid stacking AppKit insets. - textView.textContainerInset = .zero - textView.textContainer?.lineFragmentPadding = 0 - textView.isRichText = false - textView.isEditable = isEditable - textView.importsGraphics = false - textView.allowsUndo = true - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isContinuousSpellCheckingEnabled = false - LitheTextViewportLayout.apply(to: textView, in: scrollView, - softWrap: wrapsLines && LitheTextViewportLayout.isSoftWrapSupported(lineCount: LitheTextViewportLayout.lineCount(of: text))) - textView.completionItems = completions - textView.onRun = onRun - context.coordinator.onSelectionChange = onSelectionChange - context.coordinator.onSelectionRangeChange = onSelectionRangeChange - context.coordinator.textView = textView - context.coordinator.isDarkAppearance = isDark - context.coordinator.colorTheme = settings.colorTheme - context.coordinator.fontSize = CGFloat(settings.editorFontSize) - context.coordinator.highlight() - scrollView.documentView = textView - let container = EditorContainerView() - container.displaysTransparentBackground = true - let gutter = LineNumberGutterView() - gutter.usesDatabaseConsoleAppearance = true - gutter.translatesAutoresizingMaskIntoConstraints = false - scrollView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(gutter) - container.addSubview(scrollView) - if isEditable { - let stripe = SQLExecutionStripe() - stripe.textView = textView - stripe.wantsLayer = true - stripe.locale = settings.language.locale - stripe.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(stripe) - NSLayoutConstraint.activate([ - stripe.trailingAnchor.constraint(equalTo: container.trailingAnchor), - stripe.topAnchor.constraint(equalTo: container.topAnchor), - stripe.bottomAnchor.constraint(equalTo: container.bottomAnchor), - stripe.widthAnchor.constraint(equalToConstant: LitheScrollBarStyle.editorThickness) - ]) - textView.executionStripe = stripe - } - let gutterWidth = gutter.widthAnchor.constraint(equalToConstant: EditorLayoutMetrics.standardGutterWidth) - if !isEditable { - gutter.onStandardWidthChange = { [weak gutter, weak gutterWidth] _ in - if let gutter { gutterWidth?.constant = gutter.consoleLineNumberOnlyWidth } - } - } - NSLayoutConstraint.activate([ - gutter.leadingAnchor.constraint(equalTo: container.leadingAnchor), - gutter.topAnchor.constraint(equalTo: container.topAnchor), - gutter.bottomAnchor.constraint(equalTo: container.bottomAnchor), - gutterWidth, - scrollView.leadingAnchor.constraint(equalTo: gutter.trailingAnchor), - scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: container.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) - ]) - gutter.attach(textView: textView, scrollView: scrollView) - if !isEditable { gutterWidth.constant = gutter.consoleLineNumberOnlyWidth } - textView.gutter = gutter - gutter.applyAppearance(CodeEditorPalette(isDark: isDark, theme: settings.colorTheme), isTransparent: true) - context.coordinator.gutter = gutter - context.coordinator.scrollView = scrollView - textView.executions = executions - return container - } - - func updateNSView(_ container: EditorContainerView, context: Context) { - guard let scrollView = context.coordinator.scrollView, - let textView = scrollView.documentView as? SQLTextView else { return } - context.coordinator.gutter?.applyAppearance(CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme), isTransparent: true) - let isDark = colorScheme == .dark - let fontChanged = context.coordinator.fontSize != CGFloat(settings.editorFontSize) - let appearanceChanged = context.coordinator.isDarkAppearance != isDark || context.coordinator.colorTheme != settings.colorTheme || fontChanged - context.coordinator.isDarkAppearance = isDark - context.coordinator.colorTheme = settings.colorTheme - context.coordinator.fontSize = CGFloat(settings.editorFontSize) - if fontChanged { textView.font = LitheTheme.editorFont(size: settings.editorFontSize) } - context.coordinator.completions = completions - context.coordinator.onSelectionChange = onSelectionChange - context.coordinator.onSelectionRangeChange = onSelectionRangeChange - textView.isEditable = isEditable - textView.executions = executions - let wraps = wrapsLines && LitheTextViewportLayout.isSoftWrapSupported(lineCount: LitheTextViewportLayout.lineCount(of: text)) - if textView.textContainer?.widthTracksTextView != wraps { - LitheTextViewportLayout.apply(to: textView, in: scrollView, softWrap: wraps) - } - textView.completionItems = completions - textView.onRun = onRun - if appearanceChanged { - applyAppearance(to: textView, in: scrollView, isDark: isDark) - context.coordinator.highlight() - } - if textView.string != text, !textView.hasMarkedText(), !context.coordinator.isApplyingChange { - let selection = textView.selectedRange() - let mappedSelection = Self.selection(selection, replacing: textView.string, with: text) - textView.string = text - textView.setSelectedRange(mappedSelection) - context.coordinator.highlight() - } - var markers: [Int: String] = [:] - let lines = TextLineIndex(source: text as NSString) - for execution in textView.visibleExecutions { - guard let range = execution.sourceRange else { continue } - let line = lines.lineNumber(at: range.location) + 1 - if execution.errorMessage != nil { markers[line] = "expui/status/error.svg" } - else if markers[line] == nil { markers[line] = "expui/general/greenCheckmark.svg" } - } - context.coordinator.gutter?.consoleExecutionMarkers = markers - textView.executionStripe?.locale = settings.language.locale - textView.executionStripe?.needsDisplay = true - } - - /// Map native UTF-16 selection endpoints across a model-driven replacement, including execution-time formatting. - static func selection(_ selection: NSRange, replacing original: String, with replacement: String) -> NSRange { - let old = Array(original.utf16), new = Array(replacement.utf16) - guard selection.location >= 0, selection.location <= old.count, - selection.length >= 0, selection.length <= old.count - selection.location else { return NSRange(location: 0, length: 0) } - // Formatting changes several whitespace gaps. Scan those edits linearly rather than diffing the whole script quadratically. - var edits: [(range: NSRange, length: Int)] = [] - var oldIndex = 0, newIndex = 0 - func isWhitespace(_ unit: UInt16) -> Bool { UnicodeScalar(unit)?.properties.isWhitespace == true } - while oldIndex < old.count || newIndex < new.count { - if oldIndex < old.count, newIndex < new.count, old[oldIndex] == new[newIndex] { - oldIndex += 1; newIndex += 1 - continue - } - let start = oldIndex, newStart = newIndex - while oldIndex < old.count, isWhitespace(old[oldIndex]) { oldIndex += 1 } - while newIndex < new.count, isWhitespace(new[newIndex]) { newIndex += 1 } - guard oldIndex != start || newIndex != newStart else { break } - edits.append((NSRange(location: start, length: oldIndex - start), newIndex - newStart)) - } - if oldIndex != old.count || newIndex != new.count { - // ponytail: non-format replacements remain one envelope; use provider edit ranges for future disjoint token edits. - var start = 0, suffix = 0 - while start < min(old.count, new.count), old[start] == new[start] { start += 1 } - while suffix < min(old.count, new.count) - start, old[old.count - suffix - 1] == new[new.count - suffix - 1] { suffix += 1 } - edits = [(NSRange(location: start, length: old.count - suffix - start), new.count - suffix - start)] - } - func position(_ value: Int, trailing: Bool) -> Int { - var delta = 0 - for edit in edits { - if value < edit.range.location || (value == edit.range.location && !trailing && selection.length > 0) { break } - if value >= NSMaxRange(edit.range) { delta += edit.length - edit.range.length } - else { return edit.range.location + delta + (trailing ? edit.length : 0) } - } - return min(new.count, value + delta) - } - let location = position(min(selection.location, old.count), trailing: selection.length == 0) - let end = position(min(NSMaxRange(selection), old.count), trailing: true) - return NSRange(location: location, length: max(0, end - location)) - } - - private func applyAppearance(to textView: NSTextView, in scrollView: NSScrollView, isDark: Bool) { - let foreground = NSColor(LitheTheme.DatabaseGrid.text) - // As in CodeEditorView, the SwiftUI workbench surface owns the canvas background. - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - textView.drawsBackground = false - textView.backgroundColor = .clear - textView.textColor = foreground - textView.insertionPointColor = foreground - textView.selectedTextAttributes = [ - .backgroundColor: NSColor(LitheTheme.DatabaseGrid.selected), - .foregroundColor: foreground - ] - } - - @MainActor - final class Coordinator: NSObject, NSTextViewDelegate { - private var text: Binding - var completions: [String] - let onRun: () -> Void - var onSelectionChange: (String) -> Void - var onSelectionRangeChange: (NSRange) -> Void = { _ in } - weak var textView: SQLTextView? - weak var scrollView: NSScrollView? - weak var gutter: LineNumberGutterView? - var isApplyingChange = false - var isDarkAppearance = true - var colorTheme: AppColorTheme = .lithe - var fontSize: CGFloat = 13 - - init(text: Binding, completions: [String], onRun: @escaping () -> Void, onSelectionChange: @escaping (String) -> Void) { - self.text = text - self.completions = completions - self.onRun = onRun - self.onSelectionChange = onSelectionChange - } - - func textDidChange(_ notification: Notification) { - guard let textView else { return } - isApplyingChange = true - text.wrappedValue = textView.string - highlight() - isApplyingChange = false - } - - func textViewDidChangeSelection(_ notification: Notification) { - guard let textView else { return } - textView.synchronizeCaretPresentation() - let range = textView.selectedRange() - onSelectionRangeChange(range) - guard range.length > 0, range.location + range.length <= textView.string.utf16.count else { - onSelectionChange("") - return - } - onSelectionChange((textView.string as NSString).substring(with: range)) - } - - func textView( - _ textView: NSTextView, - completions words: [String], - forPartialWordRange charRange: NSRange, - indexOfSelectedItem index: UnsafeMutablePointer? - ) -> [String] { - let prefix = (textView.string as NSString).substring(with: charRange).lowercased() - let matches = completions.filter { $0.lowercased().hasPrefix(prefix) } - if let index { index.pointee = 0 } - return matches - } - - func highlight() { - guard let storage = textView?.textStorage else { return } - SQLSyntaxHighlighter.apply(to: storage, font: LitheTheme.editorFont(size: fontSize), isDark: isDarkAppearance) - gutter?.refreshLineNumberLayout() - gutter?.needsDisplay = true - } - } -} - -/// Keep even the empty editor's document under the pointer, including after resizing. -private final class SQLScrollView: NSScrollView { - override func layout() { - super.layout() - guard let textView = documentView as? NSTextView else { return } - if textView.frame.origin != .zero { textView.setFrameOrigin(.zero) } - if textView.minSize != contentSize { textView.minSize = contentSize } - if textView.frame.height < contentSize.height { - textView.setFrameSize(NSSize(width: max(textView.frame.width, contentSize.width), height: contentSize.height)) - } - } -} - -final class SQLTextView: NSTextView { - override init(frame frameRect: NSRect) { - // The shared gutter and line decoration use NSLayoutManager, as in CodeTextView. - // Build TextKit 1 directly instead of switching the lazy TextKit 2 viewport after attachment. - let container = NSTextContainer(containerSize: NSSize(width: max(frameRect.width, 1), height: CGFloat.greatestFiniteMagnitude)) - let layout = NSLayoutManager() - layout.addTextContainer(container) - let storage = NSTextStorage() - storage.addLayoutManager(layout) - super.init(frame: frameRect, textContainer: container) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - var completionItems: [String] = [] - var onRun: (() -> Void)? - var executions: [DatabaseSQLStatementExecution] = [] { - didSet { - if executions != oldValue { needsDisplay = true; gutter?.needsDisplay = true; executionStripe?.needsDisplay = true } - } - } - private var pointerTrackingArea: NSTrackingArea? - weak var gutter: LineNumberGutterView? - weak var executionStripe: SQLExecutionStripe? - var visibleExecutions: [DatabaseSQLStatementExecution] { - var seen = Set() - let length = (string as NSString).length - return executions.reversed().filter { - guard $0.sourceSQL == string, let range = $0.sourceRange, range.location != NSNotFound, - range.length > 0, range.location >= 0, range.location <= length, range.length <= length - range.location else { return false } - return seen.insert(range).inserted - } - } - private var caretLineRect = NSRect.zero - - override func setSelectedRange(_ charRange: NSRange) { - let oldLine = LitheTextViewportLayout.consoleCaretLineRect(in: self) - super.setSelectedRange(charRange) - synchronizeCaretPresentation(from: oldLine) - } - - override func setSelectedRange(_ charRange: NSRange, affinity: NSSelectionAffinity, stillSelecting flag: Bool) { - let oldLine = LitheTextViewportLayout.consoleCaretLineRect(in: self) - super.setSelectedRange(charRange, affinity: affinity, stillSelecting: flag) - synchronizeCaretPresentation(from: oldLine) - } - - override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting flag: Bool) { - let oldLine = LitheTextViewportLayout.consoleCaretLineRect(in: self) - super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: flag) - synchronizeCaretPresentation(from: oldLine) - } - - func synchronizeCaretPresentation(from previousLine: NSRect? = nil) { - guard let gutter else { return } - let nextLine = LitheTextViewportLayout.consoleCaretLineRect(in: self) - let oldLine = previousLine ?? caretLineRect - guard nextLine != oldLine else { return } - caretLineRect = nextLine - gutter.currentConsoleLine = TextLineIndex(source: string as NSString).lineNumber(at: selectedRange().location) + 1 - // AppKit withholds selection notifications while tracking. Repaint both rows in this event, before waiting for mouse-up. - for line in [oldLine, nextLine] where !line.isEmpty { - setNeedsDisplay(line.intersection(visibleRect)) - let gutterLine = NSRect(x: 0, y: line.minY - (enclosingScrollView?.documentVisibleRect.minY ?? 0), - width: gutter.bounds.width, height: line.height) - gutter.setNeedsDisplay(gutterLine.intersection(gutter.visibleRect)) - } - displayIfNeeded() - gutter.displayIfNeeded() - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { isEditable } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let pointerTrackingArea { removeTrackingArea(pointerTrackingArea) } - let area = NSTrackingArea(rect: .zero, - options: [.inVisibleRect, .activeAlways, .cursorUpdate, .mouseMoved, .mouseEnteredAndExited], - owner: self, userInfo: nil) - addTrackingArea(area) - pointerTrackingArea = area - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(visibleRect, cursor: isEditable ? .iBeam : .arrow) - } - - override func cursorUpdate(with event: NSEvent) { - guard LitheTextViewportLayout.isPointerHitTarget(self, at: event.locationInWindow) else { return } - (isEditable ? NSCursor.iBeam : .arrow).set() - } - override func mouseEntered(with event: NSEvent) { cursorUpdate(with: event) } - override func mouseMoved(with event: NSEvent) { cursorUpdate(with: event) } - - override func mouseDown(with event: NSEvent) { - super.mouseDown(with: event) - cursorUpdate(with: event) - } - - override func drawBackground(in rect: NSRect) { - super.drawBackground(in: rect) - let line = LitheTextViewportLayout.consoleCaretLineRect(in: self) - NSColor(LitheTheme.DatabaseGrid.currentRowBackground).setFill() - line.intersection(rect).fill() - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - guard let layout = layoutManager else { return } - var labeledLines = Set() - for execution in visibleExecutions { - guard let range = execution.sourceRange else { continue } - let glyphs = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - let outline = statementOutline(range) - guard outline.bounds.intersects(dirtyRect) else { continue } - NSColor(LitheTheme.DatabaseConsole.statementOutline).setStroke() - outline.lineWidth = 1; outline.stroke() - let firstLine = layout.lineFragmentRect(forGlyphAt: glyphs.location, effectiveRange: nil) - // A physical row can contain several statements; their individual times remain in Output. - guard labeledLines.insert(firstLine.minY).inserted else { continue } - let firstBounds = layout.lineFragmentUsedRect(forGlyphAt: glyphs.location, effectiveRange: nil) - let label = " \(execution.durationMilliseconds) ms" as NSString - label.draw(at: NSPoint(x: firstBounds.maxX + textContainerOrigin.x + 4, - y: firstLine.minY + textContainerOrigin.y), - withAttributes: [.font: LitheTheme.uiNSFont(size: 13), .foregroundColor: NSColor(LitheTheme.DatabaseConsole.executionInfo)]) - } - } - - func statementRect(_ range: NSRange) -> NSRect { - let box = statementLineRects(range).reduce(NSRect.null) { $0.union($1) } - return box.isNull ? .zero : box - } - - func statementOutline(_ range: NSRange) -> NSBezierPath { - let lines = statementLineRects(range) - guard let first = lines.first, let last = lines.last else { return NSBezierPath() } - if lines.count == 1 { return NSBezierPath(rect: first.insetBy(dx: 0.5, dy: 0.5)) } - // EditorPainter.paintBorderEffect: the body spans the widest row; the final row ends at its own range endpoint. - // ponytail: LTR range envelope, matching this SQL editor; split visual runs if bidi range painting is introduced. - // Community drawLine truncates X/Y to integers; align AppKit's centered 1pt stroke to the same pixel grid. - let left = max(bounds.minX + 0.5, floor(lines.map(\.minX).min() ?? first.minX) + 0.5) - let right = floor(lines.map(\.maxX).max() ?? first.maxX) + 0.5 - let points = [ - NSPoint(x: floor(first.minX) + 0.5, y: floor(first.minY) + 0.5), - NSPoint(x: right, y: floor(first.minY) + 0.5), - NSPoint(x: right, y: floor(last.minY) - 0.5), - NSPoint(x: floor(last.maxX) + 0.5, y: floor(last.minY) - 0.5), - NSPoint(x: floor(last.maxX) + 0.5, y: floor(last.maxY) - 0.5), - NSPoint(x: floor(last.minX) + 0.5, y: floor(last.maxY) - 0.5), - NSPoint(x: floor(last.minX) + 0.5, y: floor(last.minY) - 0.5), - NSPoint(x: left, y: floor(last.minY) - 0.5), - NSPoint(x: left, y: floor(first.maxY) - 0.5), - NSPoint(x: floor(first.minX) + 0.5, y: floor(first.maxY) - 0.5) - ] - let path = NSBezierPath() - path.move(to: points[0]); points.dropFirst().forEach { path.line(to: $0) }; path.close() - return path - } - - func statementLineRects(_ range: NSRange) -> [NSRect] { - guard let layout = layoutManager, let container = textContainer, range.location != NSNotFound, - range.length > 0, NSMaxRange(range) <= string.utf16.count else { return [] } - let source = string as NSString - layout.ensureLayout(for: container) - let glyphs = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil) - var lines: [NSRect] = [] - layout.enumerateLineFragments(forGlyphRange: glyphs) { lineRect, _, _, fragment, _ in - var characters = layout.characterRange(forGlyphRange: NSIntersectionRange(glyphs, fragment), actualGlyphRange: nil) - // A newline glyph spans the unused viewport; its advance is not SQL content. - while characters.length > 0 { - guard let scalar = UnicodeScalar(source.character(at: NSMaxRange(characters) - 1)), - CharacterSet.newlines.contains(scalar) else { break } - characters.length -= 1 - } - guard characters.length > 0 else { return } - let content = layout.boundingRect(forGlyphRange: layout.glyphRange(forCharacterRange: characters, actualCharacterRange: nil), in: container) - lines.append(NSRect(x: content.minX + self.textContainerOrigin.x, y: lineRect.minY + self.textContainerOrigin.y, - width: content.width, height: lineRect.height)) - } - return lines - } - - override func keyDown(with event: NSEvent) { - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if isEditable, (modifiers == .command && event.keyCode == 36) || (modifiers == .command && event.keyCode == 76) { - onRun?() - return - } - if modifiers == .control, event.keyCode == 49 { - complete(nil) - return - } - super.keyDown(with: event) - } -} - -/// Execution ticks share the editor scrollbar lane; ordinary scroll/drag events pass through. -final class SQLExecutionStripe: NSView { - var locale = Locale.current - weak var textView: SQLTextView? - override var isFlipped: Bool { true } - override func layout() { super.layout(); needsDisplay = true } - - func markerRect(_ range: NSRange) -> NSRect { - guard let textView, let layout = textView.layoutManager, let container = textView.textContainer else { return .zero } - let box = textView.statementRect(range) - guard !box.isEmpty else { return .zero } - // EditorMarkupModelImpl: short documents keep their Y positions; only long documents compress. - let targetHeight = min(bounds.height, textView.enclosingScrollView?.contentSize.height ?? bounds.height) - let scale = min(1, targetHeight / max(1, layout.usedRect(for: container).maxY + textView.textContainerOrigin.y)) - let height = LitheScrollBarStyle.minimumMarkHeight - return NSRect(x: bounds.width - LitheScrollBarStyle.thickness, - y: min(max(0, targetHeight - height), floor(box.maxY * scale)), - width: LitheScrollBarStyle.thickness, height: height) - } - - override func draw(_ dirtyRect: NSRect) { - guard let textView else { return } - let records = textView.visibleExecutions - for failed in [false, true] { - NSColor(failed ? LitheTheme.DatabaseConsole.failureStripe : LitheTheme.DatabaseConsole.successStripe).setFill() - for record in records where (record.errorMessage != nil) == failed { - if let range = record.sourceRange { markerRect(range).fill() } - } - } - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard super.hitTest(point) != nil, let textView else { return nil } - // NSView.hitTest receives coordinates in the superview, not in this trailing overlay. - let local = convert(point, from: superview) - if let scroller = textView.enclosingScrollView?.verticalScroller, scroller.isEnabled, !scroller.isHidden, - scroller.knobProportion < 1, scroller.rect(for: .knob).contains(scroller.convert(local, from: self)) { return nil } - return textView.visibleExecutions.contains { record in - record.sourceRange.map { markerRect($0).insetBy(dx: 0, dy: -3).contains(local) } ?? false - } ? self : nil - } - - override func mouseDown(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - guard let textView, let record = textView.visibleExecutions.min(by: { - abs(markerRect($0.sourceRange!).midY - point.y) < abs(markerRect($1.sourceRange!).midY - point.y) - }), let range = record.sourceRange else { return } - textView.setSelectedRange(NSRange(location: range.location, length: 0)) - textView.scrollRangeToVisible(range) - window?.makeFirstResponder(textView) - } - - override func scrollWheel(with event: NSEvent) { textView?.enclosingScrollView?.scrollWheel(with: event) } - override func isAccessibilityElement() -> Bool { true } - override func accessibilityRole() -> NSAccessibility.Role? { .image } - override func accessibilityLabel() -> String? { DatabaseLocalization.string("SQL execution markers", locale: locale) } - override func accessibilityValue() -> Any? { - guard let textView else { return nil } - let lines = TextLineIndex(source: textView.string as NSString) - return textView.visibleExecutions.map { record in - let line = lines.lineNumber(at: record.sourceRange!.location) + 1 - return String(format: DatabaseLocalization.string(record.errorMessage == nil ? "Succeeded, line %d" : "Failed, line %d", locale: locale), line) - }.joined(separator: "; ") - } -} - -enum SQLSyntaxHighlighter { - // ponytail: lexical colors only; dialect-specific syntax should move to a bundled SQL language engine. - // One token stream keeps comment markers and SQL keywords inside quoted values untouched. - private static let tokens = try? NSRegularExpression(pattern: #"(?--[^\n]*|#[^\n]*|/\*[\s\S]*?\*/)|(?(?:\b[EeNnXxBb])?'(?:''|\\[\s\S]|[^'\\])*'|(?\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$)[\s\S]*?\k)|(?"(?:""|[^"])*"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])|(?(?i:\b(?:SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|DELETE|MERGE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|TABLE|VIEW|INDEX|DATABASE|SCHEMA|TRIGGER|PROCEDURE|FUNCTION|JOIN|LEFT|RIGHT|INNER|OUTER|FULL|CROSS|ON|AS|AND|OR|NOT|NULL|IS|IN|EXISTS|BETWEEN|LIKE|DISTINCT|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|ALL|WITH|RETURNING|SET|SHOW|DESCRIBE|DESC|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|GRANT|REVOKE|CASE|WHEN|THEN|ELSE|END|ASC)\b))|(?\b\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?\b)|(?[+*/%<>=~!@^|&?-]+)"#) - - static func apply(to storage: NSTextStorage, font: NSFont, isDark: Bool) { - let range = NSRange(location: 0, length: storage.length) - storage.beginEditing() - storage.setAttributes([.font: font, .paragraphStyle: LitheTheme.editorParagraphStyle, - .foregroundColor: NSColor(LitheTheme.DatabaseGrid.text)], range: range) - for token in tokens?.matches(in: storage.string, range: range) ?? [] { - let color: Color - if token.range(withName: "comment").location != NSNotFound { - color = LitheTheme.DatabaseConsole.comment - if !isDark { storage.addAttribute(.font, value: LitheTheme.editorFont(size: font.pointSize, italic: true), range: token.range) } - } else if token.range(withName: "string").location != NSNotFound { color = LitheTheme.DatabaseConsole.string } - else if token.range(withName: "identifier").location != NSNotFound { color = LitheTheme.DatabaseGrid.text } - else if token.range(withName: "number").location != NSNotFound { color = LitheTheme.DatabaseConsole.number } - else { color = LitheTheme.DatabaseConsole.keyword } - storage.addAttribute(.foregroundColor, value: NSColor(color), range: token.range) - } - storage.endEditing() - } -} - -/// Overview uses the existing database state; closing its tab does not release connections or drafts. -struct DatabaseOverviewView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var showsConnectionEditor = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 24) { - statistic("Connections", value: feature.profiles.count) - statistic("Connected", value: feature.connectedProfileCount) - statistic("Database types", value: Set(feature.profiles.map(\.kind)).count) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - LitheToolWindowHeaderDivider() - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 20) { - quickStart.frame(minWidth: 300, maxWidth: .infinity) - commonActions.frame(width: 240) - } - VStack(alignment: .leading, spacing: 20) { - quickStart - commonActions - } - } - recentSQL - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - } - .litheScrollViewChrome() - .litheWorkbenchSurface(LitheTheme.editor) - .background(LitheCenteredPopup(isPresented: $showsConnectionEditor, - dialogTitle: String(localized: "Add Database Connection")) { - DatabaseConnectionEditor(isPresented: $showsConnectionEditor) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - }) - } - - private func statistic(_ title: LocalizedStringKey, value: Int) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title).font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - Text(value.formatted()).font(LitheTheme.uiFont(size: 22, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var quickStart: some View { - VStack(alignment: .leading, spacing: 0) { - LitheToolWindowHeader(title: "Quick Start") - if feature.profiles.isEmpty { - Text("No saved connections yet.") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(12) - } else { - ForEach(feature.profiles) { profile in - Button { open(profile, query: false) } label: { - HStack(spacing: 10) { - DatabaseBrandIcon(kind: profile.kind, size: LitheTheme.Metrics.toolbarIconSize) - VStack(alignment: .leading, spacing: 3) { - Text(profile.name).foregroundStyle(LitheTheme.primaryText) - Text(profile.kind == .sqlite ? profile.path : "\(profile.host):\(profile.port)") - .foregroundStyle(LitheTheme.secondaryText) - } - .lineLimit(1) - Spacer() - status(profile) - } - .font(LitheTheme.uiFont(size: 12)) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .litheRowHover() - .padding(.horizontal, 4) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - } - - private var commonActions: some View { - VStack(alignment: .leading, spacing: 0) { - LitheToolWindowHeader(title: "Common Actions") - action("New Connection", icon: "expui/general/add.svg") { showsConnectionEditor = true } - action("New Query", icon: "expui/fileTypes/shell.svg", - enabled: feature.profiles.contains { $0.kind.isSQLDatabase }) { - if let profile = feature.selectedProfile.flatMap({ $0.kind.isSQLDatabase ? $0 : nil }) - ?? feature.profiles.first(where: { $0.kind.isSQLDatabase }) { - open(profile, query: true) - } - } - action("Browse Database", icon: "expui/toolwindows/toolWindowDataView@20x20.svg", - enabled: !feature.profiles.isEmpty) { - if let profile = feature.selectedProfile ?? feature.profiles.first { open(profile, query: false) } - } - action("History", icon: "expui/general/history.svg") { openHistory() } - } - } - - private var recentSQL: some View { - VStack(alignment: .leading, spacing: 0) { - LitheToolWindowHeader(title: "Recent SQL") { - Button { openHistory() } label: { - LitheIDEAIcon(resourcePath: "expui/general/history.svg", size: LitheTheme.Metrics.toolbarIconSize, - preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("SQL History")) - .accessibilityLabel("SQL History") - } - if feature.sqlHistory.isEmpty { - Text("No SQL history yet.") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(12) - } else { - ForEach(Array(feature.sqlHistory.prefix(8))) { entry in - Button { - if let profile = feature.profiles.first(where: { $0.id == entry.profileID }) { - open(profile, query: true, history: entry) - } - } label: { - HStack(spacing: 12) { - Text(entry.sql.replacingOccurrences(of: "\n", with: " ")) - .font(LitheTheme.uiFont(size: 12, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Spacer() - Text(entry.executedAt, style: .relative) - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .litheRowHover(hoverBackground: feature.profiles.first(where: { $0.id == entry.profileID })?.kind.isSQLDatabase == true ? LitheTheme.hoverBackground : .clear) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .disabled(feature.profiles.first(where: { $0.id == entry.profileID })?.kind.isSQLDatabase != true) - } - } - } - .workbenchHoverTooltipScope() - } - - private func action(_ title: LocalizedStringKey, icon: String, enabled: Bool = true, - perform: @escaping () -> Void) -> some View { - Button(action: perform) { - HStack(spacing: 10) { - LitheIDEAIcon(resourcePath: icon, size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - Text(title).font(LitheTheme.uiFont(size: 12)) - } - .foregroundStyle(enabled ? LitheTheme.primaryText : LitheTheme.tertiaryText) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .litheRowHover(hoverBackground: enabled ? LitheTheme.hoverBackground : .clear) - .disabled(!enabled) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private func status(_ profile: DatabaseProfile) -> some View { - switch feature.connectionStatus(for: profile) { - case .idle: Text("Not connected").foregroundStyle(LitheTheme.secondaryText) - case .connecting: Text("Connecting").foregroundStyle(LitheTheme.secondaryText) - case .connected: Text("Connected").foregroundStyle(LitheTheme.success) - case .failed: Text("Connection failed").foregroundStyle(LitheTheme.warning) - } - } - - private func openHistory() { - feature.workspaceSection = .history - model.openDatabaseWorkspace() - } - - private func open(_ profile: DatabaseProfile, query: Bool, history: DatabaseSQLHistoryEntry? = nil) { - if query { - model.openDatabaseQuery(profileID: profile.id, sql: history?.sql ?? "") - } else { - model.selectDatabaseWorkspace(profileID: profile.id) - guard feature.selectedProfileID == profile.id, model.databaseFeatureIfActive === feature else { return } - feature.workspaceSection = .data - } - } -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift deleted file mode 100644 index bc3fcfa53..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift +++ /dev/null @@ -1,151 +0,0 @@ -import AppKit -import SwiftUI -import LitheDatabaseModule - -struct DatabaseSchemaDiffView: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var sourceID: UUID? - @State private var targetID: UUID? - @State private var diff: DatabaseSchemaDiffResult? - @State private var isComparing = false - @State private var showsMigrationConfirmation = false - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 8) { - LitheSettingsSelect(selection: $sourceID, options: [UUID?.none] + feature.profiles.map { Optional($0.id) }, width: 220, accessibilityLabel: "Source", title: { id in feature.profiles.first { $0.id == id }?.name ?? String(localized: "Source connection") }) - .frame(maxWidth: 220) - - Image(systemName: "arrow.right") - .foregroundStyle(LitheTheme.secondaryText) - - LitheSettingsSelect(selection: $targetID, options: [UUID?.none] + feature.profiles.map { Optional($0.id) }, width: 220, accessibilityLabel: "Target", title: { id in feature.profiles.first { $0.id == id }?.name ?? String(localized: "Target connection") }) - .frame(maxWidth: 220) - - Button { compare() } label: { Image(systemName: "arrow.triangle.2.circlepath") } - .litheIconButton() - .help("Compare database schemas") - .disabled(sourceID == nil || targetID == nil || sourceID == targetID || isComparing) - - if isComparing { ProgressView().controlSize(.small) } - Spacer() - } - .padding(.horizontal, 10) - .frame(height: 38) - .litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - if let diff { - diffContent(diff) - } else { - LitheUnavailableView( - "No Schema Comparison", - systemImage: "arrow.left.arrow.right", - description: Text("Choose two connections to inspect structural differences.") - ) - .foregroundStyle(LitheTheme.secondaryText) - } - } - .litheWorkbenchSurface(LitheTheme.editor) - .onAppear(perform: setDefaults) - .alert("Apply schema migration?", isPresented: $showsMigrationConfirmation) { - Button("Cancel", role: .cancel) {} - Button("Create Recovery Point and Apply", role: .destructive) { applyMigration() } - } message: { - Text("The target connection will be backed up first. Destructive changes may remove tables, columns, indexes, or constraints.") - } - } - - @ViewBuilder - private func diffContent(_ diff: DatabaseSchemaDiffResult) -> some View { - VStack(spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("\(diff.source.profileName) -> \(diff.target.profileName)") - .font(LitheTheme.uiFont(size: 12.5, weight: .semibold)) - Text("Changes: \(diff.items.count)") - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Button { copyMigrationSQL(diff) } label: { Image(systemName: "doc.on.doc") } - .litheIconButton() - .help("Copy migration SQL") - .disabled(diff.items.isEmpty) - Button { requestMigration(diff) } label: { Label("Apply", systemImage: "play.fill") } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .disabled(diff.items.isEmpty || feature.isLoading) - } - .padding(.horizontal, 10) - .frame(height: 42) - .litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - if diff.items.isEmpty { - LitheUnavailableView("Schemas Match", systemImage: "checkmark.circle", description: Text("No structural changes are required.")) - .foregroundStyle(LitheTheme.success) - } else { - List(diff.items) { item in - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { - Image(systemName: item.isDestructive ? "exclamationmark.triangle.fill" : "arrow.right.circle") - .foregroundStyle(item.isDestructive ? LitheTheme.warning : LitheTheme.accent) - Text(LocalizedStringKey(item.kind.title)).font(LitheTheme.uiFont(size: 11.5, weight: .semibold)) - Text(item.table).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) - Spacer() - DatabaseLocalization.schemaDiffDetail(item).font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.secondaryText) - } - Text(item.sql) - .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .textSelection(.enabled) - .lineLimit(4) - } - .padding(.vertical, 4) - } - .listStyle(.inset) - } - } - } - - private func setDefaults() { - guard feature.profiles.count >= 2 else { return } - if sourceID == nil { sourceID = feature.profiles[0].id } - if targetID == nil { targetID = feature.profiles[1].id } - } - - private func compare() { - guard let sourceID, let targetID, sourceID != targetID else { return } - isComparing = true - Task { - let source = await feature.loadSchemaSnapshot(profileID: sourceID) - let target = await feature.loadSchemaSnapshot(profileID: targetID) - if let source, let target { diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) } - isComparing = false - } - } - - private func requestMigration(_ diff: DatabaseSchemaDiffResult) { - if diff.requiresConfirmation { - showsMigrationConfirmation = true - } else { - applyMigration() - } - } - - private func applyMigration() { - guard let diff, let targetID else { return } - Task { - if await feature.applySchemaMigration(diff, targetProfileID: targetID, confirmed: true) { - let source = await feature.loadSchemaSnapshot(profileID: diff.source.profileID) - let target = await feature.loadSchemaSnapshot(profileID: diff.target.profileID) - if let source, let target { self.diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) } - } - } - } - - private func copyMigrationSQL(_ diff: DatabaseSchemaDiffResult) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(diff.migrationSQL, forType: .string) - } -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseServicesView.swift b/macos/Sources/Lithe/Views/Database/DatabaseServicesView.swift deleted file mode 100644 index d7dfff75a..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseServicesView.swift +++ /dev/null @@ -1,228 +0,0 @@ -import AppKit -import SwiftUI -import LitheDatabaseModule - -/// Uses the workbench's bottom tool-window and split-pane owners. -struct DatabaseServicesView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @Environment(\.locale) private var locale - @State private var selectedResultID: UUID? - @State private var wrapsOutput = true - @State private var active = false - - private var results: [DatabaseSQLStatementExecution] { - let records = feature.selectedSQLTab?.statementExecutions ?? [] - guard let batch = records.last?.batchStartedAt else { return [] } - return records.filter { $0.batchStartedAt == batch && $0.result != nil } - } - - var body: some View { - VStack(spacing: 0) { - LitheToolWindowHeader(title: "Services", onMinimize: { - model.workbenchFeature.setVisibility(.databaseServices, isVisible: false) - }) { EmptyView() } - GeometryReader { geometry in - LitheSplitPaneView(axis: .horizontal, placement: .leading, - defaultSize: geometry.size.width * 0.3, minimum: 120, - maximum: max(120, geometry.size.width - 200), flexibleMinimum: 200) { - connections - } flexible: { - VStack(spacing: 0) { - resultTabs - LitheToolWindowHeaderDivider() - if let result = results.first(where: { $0.id == selectedResultID }), let data = result.result { - DatabaseQueryResultGrid(columns: data.resolvedColumns, rows: data.rows) - .overlay(alignment: .topTrailing) { - if data.truncated { - Text("Limited to 10,000 rows").font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.warning).padding(8) - } - } - .overlay(alignment: .bottomTrailing) { - Text("\(data.rows.count) rows · \(result.durationMilliseconds) ms") - .font(LitheTheme.uiFont(size: 12)).foregroundStyle(LitheTheme.secondaryText).padding(8) - } - } else { - output - } - } - } - } - } - .litheWorkbenchSurface(LitheTheme.editor) - .background(LitheToolWindowActivityTracker(isActive: $active)) - .workbenchHoverTooltipScope() - .onChange(of: results.map(\.id)) { ids in selectedResultID = ids.last } - .onChange(of: feature.selectedSQLTabID) { _ in selectedResultID = results.last?.id } - .onAppear { selectedResultID = results.last?.id } - } - - private var connections: some View { - LitheTreeList( - nodes: [ - LitheTreeListNode(id: "database:", isExpanded: true, isSelectable: false) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIcon(kind: .folder, size: LitheTheme.Tree.iconSize) - Text("Database") - } - } children: { - guard let profile = feature.selectedProfile else { return [] } - return [ - LitheTreeListNode(id: profile.id.uuidString, isExpanded: true, isSelectable: false) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - DatabaseBrandIcon(kind: profile.kind, size: LitheTheme.Tree.iconSize) - Text(profile.name).lineLimit(1) - } - } children: { - feature.sqlTabs.map { tab in - LitheTreeListNode( - id: tab.id.uuidString, - onSelect: { model.selectDatabaseWorkspace(profileID: profile.id, queryID: tab.id) } - ) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - DatabaseBrandIcon(kind: profile.kind, size: LitheTheme.Tree.iconSize) - Text(tab.title == "Query 1" ? "console" : "console \(tab.title)").lineLimit(1) - if let execution = tab.execution { - Text("\(execution.durationMilliseconds) ms").foregroundStyle( - LitheTheme.DatabaseConsole.executionInfo) - } - } - } - } - } - ] - } - ], selection: Binding(get: { feature.selectedSQLTabID?.uuidString }, set: { _ in })) - } - - private var resultTabs: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - tab(DatabaseLocalization.string("Output", locale: locale), icon: "expui/fileTypes/shell.svg", selected: selectedResultID == nil) { selectedResultID = nil } - ForEach(Array(results.enumerated()), id: \.element.id) { index, result in - tab(String(format: DatabaseLocalization.string("Result %d", locale: locale), index + 1), icon: "database/expui/table.svg", - selected: selectedResultID == result.id) { selectedResultID = result.id } - } - }.padding(.horizontal, 8) - }.frame(height: 34) - } - - private func tab(_ title: String, icon: String, selected: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 6) { - LitheIDEAIcon(resourcePath: icon, size: 16, preservesOriginalColors: true) - Text(verbatim: title).font(LitheTheme.uiFont(size: 13)).lineLimit(1) - }.padding(.horizontal, 8).frame(height: 28) - }.buttonStyle(.litheNoPress).modifier(LitheToolWindowTabStyle(isSelected: selected, isActive: active)) - } - - private var output: some View { - HStack(spacing: 0) { - OutputTextView(output: outputText, searchRoots: [], fileExists: { _ in false }, emptyMessage: "No query output", - wrapsLines: wrapsOutput, decorateOutput: decorateOutput, onToggleWrapsLines: { wrapsOutput.toggle() }, onOpenLocation: { _, _, _ in }) - VStack(spacing: 0) { - Button { wrapsOutput.toggle() } label: { LitheIDEAIcon(resourcePath: "expui/general/softWrap.svg", size: 16) } - .buttonStyle(LitheIconButtonStyle(size: 22, isSelected: wrapsOutput)).workbenchHoverHelp(Text("Use soft wraps")) - .accessibilityLabel("Use soft wraps") - Button { - if let id = feature.selectedSQLTabID { feature.clearSQLOutput(in: id) } - } label: { LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16) } - .litheToolbarIconButton(isEnabled: !outputText.isEmpty && feature.selectedSQLTab?.isRunning != true) - .workbenchHoverHelp(Text("Clear output")) - .accessibilityLabel("Clear output") - Spacer(minLength: 0) - }.padding(.horizontal, 7).padding(.vertical, 5) - } - } - - private var visibleOutput: [DatabaseSQLStatementExecution] { - let tab = feature.selectedSQLTab - let records = tab?.statementExecutions ?? [] - let start = tab?.outputHiddenThroughExecutionID.flatMap { id in records.firstIndex { $0.id == id } }.map { $0 + 1 } ?? 0 - return Array(records.dropFirst(start)) - } - - private var outputDateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss:SSS" - return formatter - } - - private func decorateOutput(_ output: NSMutableAttributedString, isDark: Bool) { - // CONSOLE_NORMAL_OUTPUT inherits the editor text color, not general UI label foreground. - output.addAttribute(.foregroundColor, value: NSColor(LitheTheme.DatabaseGrid.text), range: NSRange(location: 0, length: output.length)) - let source = output.string as NSString - let formatter = outputDateFormatter - var cursor = 0 - for record in visibleOutput { - let prefix = "[\(formatter.string(from: record.startedAt))] " - let range = source.range(of: prefix + record.sql, range: NSRange(location: cursor, length: source.length - cursor)) - guard range.location != NSNotFound else { continue } - let sql = NSTextStorage(string: record.sql) - SQLSyntaxHighlighter.apply(to: sql, font: LitheTheme.editorFont(size: 13), isDark: isDark) - sql.enumerateAttributes(in: NSRange(location: 0, length: sql.length)) { attributes, local, _ in - let colors = attributes.filter { $0.key == .foregroundColor || $0.key == .font } - output.addAttributes(colors, range: NSRange(location: range.location + prefix.utf16.count + local.location, length: local.length)) - } - cursor = NSMaxRange(range) - } - } - - private var outputText: String { - let formatter = outputDateFormatter - return visibleOutput.map { record in - let start = formatter.string(from: record.startedAt) - let end = formatter.string(from: record.startedAt.addingTimeInterval(Double(record.durationMilliseconds) / 1_000)) - let message: String - if let error = record.errorMessage { message = error } - else if let rows = record.rowsReturned { message = String(format: DatabaseLocalization.string("%d rows retrieved in %d ms", locale: locale), rows, record.durationMilliseconds) } - else { message = String(format: DatabaseLocalization.string("%llu rows affected in %d ms", locale: locale), record.rowsAffected ?? 0, record.durationMilliseconds) } - return "[\(start)] \(record.sql)\n[\(end)] \(message)" - }.joined(separator: "\n") - } -} - -/// Community ConsoleHistoryController -> ContentChooser: list, read-only viewer, Close/Paste. -struct DatabaseQueryHistoryDialog: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - let profileID: UUID? - let onClose: () -> Void - let onPaste: (DatabaseSQLHistoryEntry) -> Void - @State private var selectedID: UUID? - - private var entries: [DatabaseSQLHistoryEntry] { feature.sqlHistory.filter { $0.profileID == profileID } } - private var selected: DatabaseSQLHistoryEntry? { entries.first { $0.id == selectedID } ?? entries.first } - - var body: some View { - VStack(spacing: 0) { - GeometryReader { geometry in - LitheSplitPaneView(axis: .horizontal, placement: .leading, - defaultSize: geometry.size.width * 0.3, minimum: 120, - maximum: max(120, geometry.size.width - 180), flexibleMinimum: 180) { - LitheTreeList( - entries, - selection: Binding( - get: { selected?.id }, set: { selectedID = $0 }), - onActivate: { onPaste($0) } - ) { entry in - Text(verbatim: entry.sql.replacingOccurrences(of: "\n", with: "↵ ")) - .font(LitheTheme.codeFont).lineLimit(1) - } - .background(LitheTheme.editor) - } flexible: { - SQLSyntaxEditor(text: .constant(selected?.sql ?? ""), completions: [], onRun: {}, onSelectionChange: { _ in }, - isEditable: false) - .background(LitheTheme.editor) - } - }.padding(12) - HStack(spacing: LitheTheme.ActionButton.spacing) { - Spacer() - Button("Close", action: onClose).buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) - Button("Paste") { if let selected { onPaste(selected) } } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)).disabled(selected == nil).keyboardShortcut(.defaultAction) - }.padding(12) - }.foregroundStyle(LitheTheme.primaryText).frame(width: 500, height: 500).background(LitheCommitDialogStyle.background) - } -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift deleted file mode 100644 index 7000fba6a..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift +++ /dev/null @@ -1,2410 +0,0 @@ -import AppKit -import SwiftUI -import UniformTypeIdentifiers -import LitheDatabaseModule - -private struct DatabaseTableContextAction { - enum Kind { case clear, drop } - let profile: DatabaseProfile - let table: String - let kind: Kind - - var title: LocalizedStringKey { kind == .clear ? "Clear Table" : "Delete Table" } - var message: LocalizedStringKey { - kind == .clear ? "All rows in this table will be deleted." : "This table and all of its data will be permanently deleted." - } -} - -private struct DatabaseTreePath: Hashable { - let profileID: UUID - let database: String - - init(_ profile: DatabaseProfile) { - profileID = profile.id - database = profile.database - } -} - -private enum DatabaseTreeSelection: Hashable { - case folder(UUID) - case connection(UUID) - case database(DatabaseTreePath) - case table(DatabaseTreeTableKey) - case objects(DatabaseTreePath, DatabaseObjectKind) -} - -private enum DatabaseFolderMoveTarget { - case connection(UUID) - case folder(UUID) -} - -struct DatabaseSidebarView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var treeSelection: DatabaseTreeSelection? - @State private var showsConnectionEditor = false - @State private var editingProfile: DatabaseProfile? - @State private var connectionEditorPresentationID = UUID() - @State private var searchText = "" - @State private var showsSearch = false - @FocusState private var searchFocused: Bool - /// Folders start expanded, while this set records the user's explicit - /// collapsed state. Keeping the negative state avoids refreshes reopening - /// folders behind the user's back. - @State private var collapsedFolderIDs: Set = [] - @State private var collapsedObjectKinds: [DatabaseTreePath: Set] = [:] - @State private var expandedProfileIDs: Set = [] - @State private var databaseScopeProfileID: UUID? - @State private var databaseScopeSelection: [String]? - @State private var hoveredScopeProfileID: UUID? - @State private var collapsedDatabaseProfileIDs: Set = [] - @State private var expandedDatabasePaths: Set = [] - @State private var expandedTableKeys: Set = [] - @State private var targetedFolderID: UUID? - @State private var isRootDropTarget = false - @State private var kindFilter: DatabaseKind? - @State private var connectionSort = DatabaseConnectionSort.name - @State private var showsFolderEditor = false - @State private var showsFolderDestination = false - @State private var folderMoveTarget: DatabaseFolderMoveTarget? - @State private var editingFolder: DatabaseConnectionFolder? - @State private var creatingFolderParentID: UUID? - @State private var newConnectionFolderID: UUID? - @State private var newConnectionKind: DatabaseKind = .mysql - @State private var folderPendingDeletion: DatabaseConnectionFolder? - @State private var showsFolderDeletionConfirmation = false - @State private var profilePendingDeletion: DatabaseProfile? - @State private var showsProfileDeletionConfirmation = false - @State private var exportDocument: DatabaseTransferDocument? - @State private var temporaryExportURL: URL? - @State private var exportFormat = DatabaseTransferFormat.csv - @State private var importFormat = DatabaseTransferFormat.csv - @State private var showsExporter = false - @State private var showsImporter = false - @State private var pendingImportData: Data? - @State private var pendingImportURL: URL? - @State private var pendingImportFormat: DatabaseTransferFormat? - @State private var showsProtectedImportConfirmation = false - @State private var pendingTableAction: DatabaseTableContextAction? - @State private var showsTableActionConfirmation = false - @State private var pendingRedisProfile: DatabaseProfile? - @State private var showsRedisFlushConfirmation = false - @State private var showsDBXImporter = false - @State private var showsDBXImportSheet = false - @State private var dbxImportData: Data? - - var body: some View { - VStack(spacing: 0) { - LitheToolWindowHeader(title: "Database") { - LitheSidebarHideButton(title: "Database") { - model.workbenchFeature.setVisibility(.database, isVisible: false) - } - } - LitheToolWindowHeaderDivider() - HStack(spacing: 2) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - LitheMenu { newMenuItems } label: { - LitheIDEAIcon(resourcePath: "expui/general/add.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - .overlay { - // IDEA ActionButtonLook.paintDownArrow offsets the 16pt overlay by (1, 1). - LitheIDEAIcon(resourcePath: "expui/general/dropdown.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - .offset(x: 1, y: 1).allowsHitTesting(false) - } - } - .litheToolbarIconButton().workbenchHoverHelp(Text("New")) - .accessibilityLabel("New") - Button { presentFolderEditor() } label: { - LitheIDEAIcon(resourcePath: "expui/actions/newFolder.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("New Folder")) - .accessibilityLabel("New Folder") - Button { showsDBXImporter = true } label: { - LitheIDEAIcon(resourcePath: "expui/general/import.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("Import connections from DBX")) - .accessibilityLabel("Import connections from DBX") - Button { collapseAll() } label: { - LitheIDEAIcon(resourcePath: "expui/general/collapseAll.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("Collapse all")) - .accessibilityLabel("Collapse all") - Button { refreshSelection() } label: { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton(isEnabled: canRefreshSelection) - .workbenchHoverHelp(Text("Refresh selected structure")) - .accessibilityLabel("Refresh selected structure") - Button { - if let profile = feature.selectedProfile { openSQLQuery(profile) } - } label: { - LitheIDEAIcon(resourcePath: "expui/fileTypes/shell.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton(isEnabled: feature.selectedProfile?.kind.isSQLDatabase == true) - .workbenchHoverHelp(Text("New Query")) - .accessibilityLabel("New Query") - Button { - feature.workspaceSection = .history - model.openDatabaseWorkspace() - } label: { - LitheIDEAIcon(resourcePath: "expui/general/history.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("SQL History")) - .accessibilityLabel("SQL History") - Button { model.openDatabaseWorkspace(overview: true) } label: { - LitheIDEAIcon(resourcePath: "expui/general/previewVertically.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("Database Overview")) - .accessibilityLabel("Database Overview") - Button { searchFocused = false; showsSearch.toggle(); searchText = "" } label: { - LitheIDEAIcon(resourcePath: "expui/general/search.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .buttonStyle(LitheIconButtonStyle( - size: LitheTheme.Metrics.toolbarIconButtonSize, isSelected: showsSearch - )) - .workbenchHoverHelp(Text("Search connections")) - .accessibilityLabel("Search connections") - } - } - HStack(spacing: 2) { - LitheMenu { - LitheContextMenuItem.action("All database types", checked: kindFilter == nil) { kindFilter = nil } - LitheContextMenuItem.separator - for kind in DatabaseKind.allCases { - LitheContextMenuItem.action( - kind.displayName, icon: AnyView(DatabaseBrandIcon(kind: kind, size: 16)), checked: kindFilter == kind - ) { - kindFilter = kind - } - } - } label: { - LitheIDEAIcon(resourcePath: "expui/general/filter.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .buttonStyle(LitheIconButtonStyle( - size: LitheTheme.Metrics.toolbarIconButtonSize, isSelected: kindFilter != nil - )) - .workbenchHoverHelp(Text("Filter database type")) - .accessibilityLabel("Filter database type") - - LitheMenu { - for mode in DatabaseConnectionSort.allCases { - LitheContextMenuItem.action(mode.menuTitle, checked: mode == connectionSort) { - connectionSort = mode - } - } - } label: { - LitheIDEAIcon(resourcePath: "expui/general/sortBy.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Sort connections")) - .accessibilityLabel("Sort connections") - } - } - .padding(.horizontal, 7) - .padding(.vertical, 5) - .frame(height: LitheTheme.Metrics.toolbarIconButtonSize + 10) - - if showsSearch { - LitheSearchField("Search connections", text: $searchText, focus: $searchFocused) - .onExitCommand { showsSearch = false; searchText = "" } - .frame(maxWidth: .infinity) - .padding(.horizontal, 8) - .padding(.vertical, 7) - } - - if feature.profiles.isEmpty && feature.folders.isEmpty { - emptyConnections - .contentShape(Rectangle()) - .litheContextMenu { panelContextMenu } - } else { - LitheTreeList(nodes: connectionNodes, selection: Binding( - get: { treeSelection.map(treeNodeID) }, - set: { if $0 == nil { treeSelection = nil } } - )) - .litheContextMenu { panelContextMenu } - .dropDestination(for: String.self) { items, _ in - moveDroppedProfile(items, to: nil) - } isTargeted: { isRootDropTarget = $0 } - .background(isRootDropTarget ? LitheTheme.accent.opacity(0.18) : .clear) - } - if feature.isLoading { ProgressView().controlSize(.small).padding(8) } - } - .workbenchHoverTooltipScope() - .background(LitheCenteredPopup(isPresented: $showsConnectionEditor, dialogTitle: String(localized: editingProfile == nil ? "Add Database Connection" : "Edit Database Connection"), resizesToContent: true) { - DatabaseConnectionEditor(isPresented: $showsConnectionEditor, profile: editingProfile, defaultFolderID: newConnectionFolderID, defaultKind: newConnectionKind) - .id(connectionEditorPresentationID) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - }) - .background(LitheCenteredPopup(isPresented: $showsFolderEditor, focusFirstTextField: true, resizesToContent: true) { - DatabaseFolderEditor(isPresented: $showsFolderEditor, folder: editingFolder, parentID: creatingFolderParentID, moveTarget: folderMoveTarget) { - if let target = folderMoveTarget { - switch target { - case .connection(let id): revealFolder(feature.profiles.first(where: { $0.id == id })?.folderID) - case .folder(let id): revealFolder(feature.folders.first(where: { $0.id == id })?.parentID) - } - } else { revealFolder(creatingFolderParentID) } - folderMoveTarget = nil - } - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - }) - .background(LitheCenteredPopup(isPresented: $showsDBXImportSheet, dialogTitle: String(localized: "Import Connections from DBX")) { - if let dbxImportData { - DatabaseDBXImportSheet(isPresented: $showsDBXImportSheet, data: dbxImportData) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - } - }) - .onChange(of: showsDBXImportSheet) { shown in - if !shown { dbxImportData = nil } - } - .background(LitheDropdownPopover(centersMenu: true, isPresented: $showsFolderDestination, items: folderDestinationItems) { EmptyView() } - .environment(\.locale, model.settings.language.locale)) - .alert("Remove folder?", isPresented: $showsFolderDeletionConfirmation, presenting: folderPendingDeletion) { folder in - Button("Remove Folder", role: .destructive) { - feature.removeFolder(folder) - folderPendingDeletion = nil - } - Button("Cancel", role: .cancel) { folderPendingDeletion = nil } - } message: { _ in - Text("Removing this folder keeps its contents and moves them to the parent folder or root.") - } - .alert("Remove connection?", isPresented: $showsProfileDeletionConfirmation, presenting: profilePendingDeletion) { profile in - Button("Remove Connection", role: .destructive) { - model.removeDatabaseConnection(profile) - profilePendingDeletion = nil - } - Button("Cancel", role: .cancel) { profilePendingDeletion = nil } - } message: { profile in - Text("Remove \(profile.name)? Its saved password, history, and backup schedule will also be removed.") - } - .confirmationDialog("Confirm table action", isPresented: $showsTableActionConfirmation, presenting: pendingTableAction) { action in - Button(action.title, role: .destructive) { executeTableAction(action) } - Button("Cancel", role: .cancel) { pendingTableAction = nil } - } message: { action in - Text(action.message) - } - .confirmationDialog("Clear Redis database?", isPresented: $showsRedisFlushConfirmation, presenting: pendingRedisProfile) { profile in - Button("Clear Current Redis DB", role: .destructive) { - pendingRedisProfile = nil - Task { - guard await feature.selectDatabaseContext(profile) else { return } - _ = await feature.flushRedisDatabase(confirmed: true) - } - } - Button("Cancel", role: .cancel) { pendingRedisProfile = nil } - } message: { _ in - Text("All keys in the current Redis database will be permanently deleted.") - } - .confirmationDialog("Confirm Database Import", isPresented: $showsProtectedImportConfirmation, titleVisibility: .visible) { - Button("Import and Modify Database", role: .destructive) { performPendingImport(confirmed: true) } - Button("Cancel", role: .cancel) { discardPendingImport() } - } message: { - Text("Restoring a SQL backup or importing into a protected connection can modify database data. A recovery snapshot will be created first.") - } - .fileExporter(isPresented: $showsExporter, document: exportDocument, contentType: exportFormat.contentType, defaultFilename: exportFilename) { result in - if case let .failure(error) = result { feature.errorMessage = error.localizedDescription } - if let temporaryExportURL { feature.removeTemporaryFile(temporaryExportURL) } - temporaryExportURL = nil; exportDocument = nil - } - .fileImporter(isPresented: $showsImporter, allowedContentTypes: [importFormat.contentType]) { result in - importFile(result) - } - .fileImporter(isPresented: $showsDBXImporter, allowedContentTypes: [.json]) { result in - importDBXFile(result) - } - .onChange(of: feature.selectedProfileID) { selectedID in - if let selectedID, treeSelection != .connection(selectedID) { - expandedProfileIDs.insert(selectedID) - } - } - } - - private func importDBXFile(_ result: Result) { - do { - let url = try result.get() - let accessed = url.startAccessingSecurityScopedResource() - defer { if accessed { url.stopAccessingSecurityScopedResource() } } - dbxImportData = try feature.readImportData(from: url) - showsDBXImportSheet = true - } catch { - feature.errorMessage = error.localizedDescription - } - } - - private var emptyConnections: some View { - GeometryReader { geometry in - // IDEA Community c7f91397 StatusText places empty text above center. - Text("No database connections") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.searchFieldPlaceholder) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .position(x: geometry.size.width / 2, y: geometry.size.height / 3) - } - } - - private var newMenuItems: [LitheContextMenuItem] { - let folderID: UUID? = if case .folder(let id) = treeSelection { id } else { nil } - return [ - .action("Query Console", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/fileTypes/shell.svg", - size: LitheDropdownMetrics.iconSize, preservesOriginalColors: true)), - isEnabled: feature.selectedProfile?.kind.isSQLDatabase == true) { - if let profile = feature.selectedProfile { openSQLQuery(profile) } - }, - .submenu("Data Source", icon: AnyView(LitheIDEAIcon(resourcePath: "database/expui/dbms@20x20.svg", - size: LitheDropdownMetrics.iconSize, preservesOriginalColors: true)), items: DatabaseKind.allCases.map { kind in - .action(kind.displayName, localizesTitle: false, - icon: AnyView(DatabaseBrandIcon(kind: kind, size: LitheDropdownMetrics.iconSize))) { - presentConnectionEditor(defaultFolderID: folderID, defaultKind: kind) - } - }), - .action("Folder", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/nodes/folder.svg", - size: LitheDropdownMetrics.iconSize, preservesOriginalColors: true))) { - presentFolderEditor(parentID: folderID) - }, - .separator, - .action("Import Data Sources…", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/general/import.svg", - size: LitheDropdownMetrics.iconSize, preservesOriginalColors: true))) { showsDBXImporter = true } - ] - } - - private var panelContextMenu: [LitheContextMenuItem] { - [ - .submenu("New", items: newMenuItems), - .action("Import connections from DBX") { showsDBXImporter = true }, - .separator, - .action("Refresh selected structure", isEnabled: canRefreshSelection) { refreshSelection() }, - .action("Collapse all", isEnabled: !feature.profiles.isEmpty || !feature.folders.isEmpty) { collapseAll() } - ] - } - - private func treeNodeID(_ selection: DatabaseTreeSelection) -> String { - switch selection { - case .folder(let id): return "folder:\(id)" - case .connection(let id): return "profile:\(id)" - case .database(let path): return "database:\(path.profileID):\(path.database)" - case .table(let key): return "table:\(key.profileID):\(key.database):\(key.table)" - case .objects(let path, let kind): return "objects:\(path.profileID):\(path.database):\(kind.rawValue)" - } - } - - private var connectionNodes: [LitheTreeListNode] { - var nodes = rootFolders.filter(isFolderVisible).map(folderNode) - nodes += unfiledProfiles.map(profileNode) - if nodes.isEmpty && (!searchQuery.isEmpty || kindFilter != nil) { - nodes.append(LitheTreeListNode(id: "no-matches", isSelectable: false) { - Text("No connections match your search.").foregroundStyle(LitheTheme.tertiaryText) - }) - } - return nodes - } - - private func folderNode(_ folder: DatabaseConnectionFolder) -> LitheTreeListNode { - let expanded = !searchQuery.isEmpty || !collapsedFolderIDs.contains(folder.id) - let count = recursiveProfileCount(in: folder) - return LitheTreeListNode( - id: treeNodeID(.folder(folder.id)), isExpanded: expanded, - disclosureLabel: "\(expanded ? String(localized: "Collapse Folder") : String(localized: "Expand Folder")) \(folder.name)", - accessibilityTitle: folder.name, - onExpansion: { value in if value != expanded { toggleFolder(folder) } }, - onSelect: { treeSelection = .folder(folder.id) }, - menu: { - [ - .action(expanded ? "Collapse Folder" : "Expand Folder") { toggleFolder(folder) }, - .action("New Connection") { presentConnectionEditor(defaultFolderID: folder.id) }, - .action("New Subfolder") { presentFolderEditor(parentID: folder.id) }, - .action("Rename Folder") { presentFolderEditor(folder: folder) }, - .submenu("Move", items: [ - .action("To Folder…") { presentFolderMove(.folder(folder.id)) }, - .action("To Top Level", isEnabled: folder.parentID != nil) { - feature.moveFolder(folder.id, toParent: nil) - } - ]), - .action("Remove Folder", role: .destructive) { - folderPendingDeletion = folder - showsFolderDeletionConfirmation = true - } - ] - } - ) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIcon(kind: .folder, size: LitheTheme.Metrics.treeIconSize) - Text(folder.name).lineLimit(1) - Spacer(minLength: 4) - if count > 0 { Text("\(count)").foregroundStyle(LitheTheme.tertiaryText) } - } - .contentShape(Rectangle()) - .background(targetedFolderID == folder.id ? LitheTheme.accent.opacity(0.18) : .clear) - .dropDestination(for: String.self) { items, _ in moveDroppedProfile(items, to: folder.id) } - isTargeted: { targetedFolderID = $0 ? folder.id : nil } - } children: { - visibleProfiles(in: folder).map(profileNode) - + childFolders(of: folder).filter(isFolderVisible).map(folderNode) - } - } - - private func profileNode(_ profile: DatabaseProfile) -> LitheTreeListNode { - let expanded = expandedProfileIDs.contains(profile.id) - let tree = feature.databaseTrees[profile.id] - return LitheTreeListNode( - id: treeNodeID(.connection(profile.id)), isExpanded: expanded, - disclosureLabel: String(localized: expanded ? "Collapse connection" : "Expand connection"), - accessibilityTitle: profile.name, - onExpansion: { value in if value != expanded { toggleProfile(profile) } }, - onSelect: { treeSelection = .connection(profile.id) }, - onClick: { selectProfile(profile, expand: false) }, - menu: { connectionContextMenu(profile) } - ) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - DatabaseBrandIcon(kind: profile.kind, size: LitheTheme.Tree.iconSize) - .overlay(alignment: .bottomTrailing) { connectionStatusIndicator(profile) } - Text(profile.name).lineLimit(1) - if profile.productionProtection { - Image(systemName: "shield.fill") - .font(LitheTheme.uiFont(size: 8)).foregroundStyle(LitheTheme.warning) - } - if (profile.kind == .mysql || profile.kind == .mariadb), let tree, - tree.state == .ready || !tree.names.isEmpty { - scopeButton(profile, names: tree.names) - } - } - .draggable(profile.id.uuidString) - .task(id: expanded) { - if expanded && (profile.kind == .mysql || profile.kind == .mariadb) { - await feature.loadDatabaseTree(for: profile.id) - } - } - } children: { - if profile.kind == .mysql || profile.kind == .mariadb { - return mysqlDatabaseNodes(profile) - } - return feature.selectedProfileID == profile.id ? profileObjectNodes(profile) : [] - } - } - - private func scopeButton(_ profile: DatabaseProfile, names: [String]) -> some View { - let selected = feature.visibleDatabaseNames(for: profile.id).count - return Button { - treeSelection = .connection(profile.id) - if databaseScopeProfileID == profile.id { applyDatabaseScope(for: profile.id) } - else { - databaseScopeSelection = profile.visibleDatabases - databaseScopeProfileID = profile.id - } - } label: { - Text(verbatim: " \(selected) of \(names.count) ") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.Tree.secondaryText) - .frame(height: LitheTheme.Tree.rowHeight) - .background { - let shape = RoundedRectangle(cornerRadius: 3) - let hovered = hoveredScopeProfileID == profile.id - shape.fill(hovered ? LitheTheme.toolbarHoverBackground : .clear) - .overlay(shape.stroke(hovered ? .clear : LitheTheme.toolbarHoverBackground, lineWidth: 1)) - } - } - .buttonStyle(.litheNoPress) - .fixedSize() - .accessibilityLabel("Select databases") - .accessibilityValue(Text(verbatim: "\(selected) of \(names.count)")) - .accessibilityIdentifier("database-schema-scope") - .onHover { hoveredScopeProfileID = $0 ? profile.id : nil } - .lithePointer() - .litheDropdown(isPresented: Binding( - get: { databaseScopeProfileID == profile.id }, - set: { databaseScopeProfileID = $0 ? profile.id : nil } - ), searchOnTyping: true, onAccept: { applyDatabaseScope(for: profile.id) }) { - DatabaseSchemaSelector(profileID: profile.id, names: names, selection: $databaseScopeSelection) { - applyDatabaseScope(for: profile.id) - } - .environmentObject(feature) - .environment(\.locale, model.settings.language.locale) - } - } - - private func applyDatabaseScope(for profileID: UUID) { - if feature.setVisibleDatabases(databaseScopeSelection, for: profileID) { databaseScopeProfileID = nil } - } - - private func mysqlDatabaseNodes(_ profile: DatabaseProfile) -> [LitheTreeListNode] { - let tree = feature.databaseTrees[profile.id] ?? DatabaseConnectionTree() - var nodes = statusNodes(tree.state, id: "profile:\(profile.id):status") { - Task { await feature.loadDatabaseTree(for: profile.id, force: true) } - } - nodes += feature.visibleDatabaseNames(for: profile.id).map { database in - databaseNode(databaseProfile(profile, database: database), snapshot: tree.databases[database]) - } - if tree.state == .ready && tree.names.isEmpty { - nodes.append(LitheTreeListNode(id: "profile:\(profile.id):empty", isSelectable: false) { - Text("No accessible databases").foregroundStyle(LitheTheme.Tree.secondaryText) - }) - } else if tree.state == .ready && feature.visibleDatabaseNames(for: profile.id).isEmpty { - nodes.append(LitheTreeListNode(id: "profile:\(profile.id):none-selected", isSelectable: false) { - Text("No schemas selected") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.Tree.secondaryText) - .accessibilityIdentifier("database-no-schemas-selected") - }) - } - return nodes - } - - private func databaseProfile(_ profile: DatabaseProfile, database: String) -> DatabaseProfile { - var target = profile - target.database = database - return target - } - - private func databaseNode(_ profile: DatabaseProfile, snapshot: DatabaseTreeDatabase? = nil) -> LitheTreeListNode { - let path = DatabaseTreePath(profile) - let expanded = profile.kind == .mysql || profile.kind == .mariadb - ? expandedDatabasePaths.contains(path) : !collapsedDatabaseProfileIDs.contains(profile.id) - return LitheTreeListNode( - id: treeNodeID(.database(path)), isExpanded: expanded, - disclosureLabel: "\(String(localized: expanded ? "Collapse database" : "Expand database")) \(profile.database.isEmpty ? (profile.kind == .mongodb ? "admin" : String(localized: "Default database")) : profile.database)", - accessibilityTitle: profile.database.isEmpty - ? (profile.kind == .mongodb ? "admin" : String(localized: "Default database")) : profile.database, - onExpansion: { value in if value != expanded { toggleDatabase(profile) } }, - onSelect: { treeSelection = .database(path) }, - menu: { profile.kind == .mongodb ? mongoDatabaseContextMenu(profile) : databaseContextMenu(profile) } - ) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIDEAIcon(resourcePath: "database/expui/schema.svg", size: LitheTheme.Tree.iconSize, - preservesOriginalColors: true) - if profile.database.isEmpty { Text(profile.kind == .mongodb ? "admin" : "Default database") } - else { Text(verbatim: profile.database) } - } - .task(id: expanded) { - if expanded && (profile.kind == .mysql || profile.kind == .mariadb) { - await feature.loadDatabaseTree(profile.database, for: profile.id) - } - } - } children: { - if profile.kind == .mysql || profile.kind == .mariadb { - let current = snapshot ?? DatabaseTreeDatabase() - var nodes = statusNodes(current.state, id: "\(treeNodeID(.database(path))):status") { - Task { await feature.loadDatabaseTree(profile.database, for: profile.id, force: true) } - } - if current.state == .ready || !current.tables.isEmpty || !current.objects.isEmpty { - nodes += databaseObjectNodes(profile, snapshot: current) - } - return nodes - } - return databaseObjectNodes(profile) - } - } - - private func profileObjectNodes(_ profile: DatabaseProfile) -> [LitheTreeListNode] { - if profile.kind.supportsDataGrid { return [databaseNode(profile)] } - let prefix = "profile:\(profile.id)" - if profile.kind == .redis { - return [LitheTreeListNode(id: prefix + ":redis", menu: { redisDatabaseContextMenu(profile) }) { - sidebarLeaf(title: "Redis database", detail: profile.database.isEmpty ? "0" : profile.database, - symbol: "", count: feature.redisKeys.count, indent: 0) - }] - } - return [ - LitheTreeListNode(id: prefix + ":configs", menu: { nacosContextMenu(profile) }) { - sidebarLeaf(title: "Configurations", symbol: "", count: feature.nacosConfigs.count, indent: 0) - }, - LitheTreeListNode(id: prefix + ":services", menu: { nacosContextMenu(profile) }) { - sidebarLeaf(title: "Services", symbol: "", count: feature.nacosServices.count, indent: 0) - } - ] - } - - private func databaseObjectNodes(_ profile: DatabaseProfile, snapshot: DatabaseTreeDatabase? = nil) -> [LitheTreeListNode] { - let tables = snapshot?.tables ?? feature.tables - let objects = snapshot?.objects ?? feature.objects - let loading = snapshot.map { $0.state == .loading } ?? feature.isLoading - var nodes = [objectSectionNode(.tables, profile: profile, count: tables.count) { - if tables.isEmpty && !loading { - return [LitheTreeListNode(id: "\(profile.id):\(profile.database):empty-tables", isSelectable: false) { - Text(profile.kind == .mongodb ? "No collections yet" : "No tables yet") - .foregroundStyle(LitheTheme.Tree.secondaryText) - }] - } - return tables.map { tableNode(profile, table: $0) } - }] - if profile.kind != .mongodb { - for kind in DatabaseObjectKind.allCases where kind != .tables { - let entries = objects[kind] ?? [] - if !entries.isEmpty { - nodes.append(objectSectionNode(kind, profile: profile, count: entries.count) { - entries.enumerated().map { index, row in - LitheTreeListNode(id: "\(profile.id):\(profile.database):\(kind.rawValue):\(index)") { - Text(objectLabel(row)).lineLimit(1) - } - } - }) - } - } - } - return nodes - } - - private func objectSectionNode(_ kind: DatabaseObjectKind, profile: DatabaseProfile, count: Int, - children: @escaping () -> [LitheTreeListNode]) -> LitheTreeListNode { - let path = DatabaseTreePath(profile) - let expanded = !(collapsedObjectKinds[path] ?? []).contains(kind) - return LitheTreeListNode( - id: treeNodeID(.objects(path, kind)), isExpanded: expanded, - disclosureLabel: "\(String(localized: expanded ? "Collapse" : "Expand")) \(kind.title)", - accessibilityTitle: kind == .tables && profile.kind == .mongodb - ? String(localized: "Collections") : kind.title, - onExpansion: { value in if value != expanded { toggleObjectSection(kind, profile: profile) } }, - onSelect: { treeSelection = .objects(path, kind) }, - menu: { - if kind == .tables { - return profile.kind == .mongodb - ? [.action("Refresh Collections") { refresh(profile) }] : tableGroupContextMenu(profile) - } - return [] - }, content: { - sidebarLeaf(title: LocalizedStringKey(kind == .tables && profile.kind == .mongodb ? "Collections" : kind.title), - symbol: "database/expui/objectGroup.svg", count: count, indent: 0) - }, children: children - ) - } - - private func tableNode(_ profile: DatabaseProfile, table: String) -> LitheTreeListNode { - let key = DatabaseTreeTableKey(profileID: profile.id, database: profile.database, table: table) - let expanded = expandedTableKeys.contains(key) - return LitheTreeListNode( - id: treeNodeID(.table(key)), isExpanded: expanded, activatesOnDoubleClick: true, - disclosureLabel: "\(String(localized: expanded ? "Collapse table" : "Expand table")) \(table)", - accessibilityTitle: table, - onExpansion: { value in - if value { expandedTableKeys.insert(key) } - else { - expandedTableKeys.remove(key) - feature.cancelTableTreeLoad(key) - } - }, - onSelect: { treeSelection = .table(key) }, - onActivate: { openTable(profile, table: table, section: .data) }, - menu: { - profile.kind == .mongodb - ? mongoCollectionContextMenu(profile, collection: table) : tableContextMenu(profile, table: table) - } - ) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIDEAIcon(resourcePath: profile.kind == .mongodb - ? "database/expui/collection.svg" : "database/expui/table.svg", - size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) - Text(table).lineLimit(1) - } - .accessibilityAction(named: Text("Open")) { openTable(profile, table: table, section: .data) } - .task(id: expanded) { - if expanded { await feature.loadTableTree(key) } - } - } children: { - let metadata = feature.tableTrees[key] ?? DatabaseTreeTable() - var nodes = statusNodes(metadata.state, id: "\(treeNodeID(.table(key))):status") { - Task { await feature.loadTableTree(key, force: true) } - } - if metadata.state == .ready { - nodes.append(LitheTreeListNode(id: "\(treeNodeID(.table(key))):columns") { - sidebarLeaf(title: profile.kind == .mongodb ? "Fields" : "Columns", - symbol: "database/expui/column.svg", count: metadata.columns.count, indent: 0) - }) - nodes.append(LitheTreeListNode(id: "\(treeNodeID(.table(key))):indexes") { - sidebarLeaf(title: "Indexes", symbol: "database/expui/goldKey.svg", - count: metadata.indexes.count, indent: 0) - }) - if profile.kind != .mongodb { - nodes.append(LitheTreeListNode(id: "\(treeNodeID(.table(key))):foreign-keys") { - sidebarLeaf(title: "Foreign Keys", symbol: "database/expui/blueKey.svg", - count: metadata.foreignKeys.count, indent: 0) - }) - } - } - return nodes - } - } - - private func statusNodes(_ state: DatabaseTreeLoadState, id: String, retry: @escaping () -> Void) -> [LitheTreeListNode] { - switch state { - case .ready: return [] - case .idle, .loading: - return [LitheTreeListNode(id: id, isSelectable: false) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - ProgressView().controlSize(.mini) - Text("Loading…") - } - .foregroundStyle(LitheTheme.Tree.secondaryText) - .accessibilityLabel("Loading…") - }] - case .failed(let message): - return [LitheTreeListNode(id: id, onClick: retry) { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: LitheTheme.Tree.iconSize) - Text("Retry") - } - .help(message) - .accessibilityLabel(Text(verbatim: message)) - }] - } - } - - private func toggleFolder(_ folder: DatabaseConnectionFolder) { - collapsedFolderIDs.formSymmetricDifference([folder.id]) - } - - private func toggleDatabase(_ profile: DatabaseProfile) { - if profile.kind == .mysql || profile.kind == .mariadb { - let path = DatabaseTreePath(profile) - expandedDatabasePaths.formSymmetricDifference([path]) - if !expandedDatabasePaths.contains(path) { - feature.cancelDatabaseTreeLoad(for: profile.id, database: profile.database) - } - } else { - collapsedDatabaseProfileIDs.formSymmetricDifference([profile.id]) - } - } - - private func toggleObjectSection(_ kind: DatabaseObjectKind, profile: DatabaseProfile) { - let path = DatabaseTreePath(profile) - collapsedObjectKinds[path, default: []].formSymmetricDifference([kind]) - if kind == .tables && collapsedObjectKinds[path]?.contains(kind) == true { - for key in expandedTableKeys where key.profileID == profile.id && key.database == profile.database { - feature.cancelTableTreeLoad(key) - } - } - } - - private func sidebarLeaf( - title: LocalizedStringKey, - detail: String? = nil, - symbol: String, - count: Int, - indent: CGFloat, - tint: Color = LitheTheme.secondaryText - ) -> some View { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - if symbol.hasSuffix(".svg") { - LitheIDEAIcon(resourcePath: symbol, size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) - } else { - Color.clear.frame(width: LitheTheme.Tree.iconSize, height: LitheTheme.Tree.iconSize) - } - Text(title) - .font(LitheTheme.uiFont(size: 13)) - .lineLimit(1) - if let detail { - Text(verbatim: detail) - .font(LitheTheme.uiFont(size: 13)) - .lineLimit(1) - } - Spacer() - Text("\(count)") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.tertiaryText) - } - .padding(.leading, indent) - .padding(.trailing, 9) - .frame(maxWidth: .infinity, minHeight: LitheTheme.Tree.rowHeight, alignment: .leading) - .contentShape(Rectangle()) - .foregroundStyle(LitheTheme.secondaryText) - } - - private func moveDroppedProfile(_ items: [String], to folderID: UUID?) -> Bool { - guard let rawID = items.first, let profileID = UUID(uuidString: rawID), - let profile = feature.profiles.first(where: { $0.id == profileID }) else { - return false - } - guard feature.move(profile, toFolder: folderID) else { return false } - targetedFolderID = nil - isRootDropTarget = false - return true - } - - private var rootFolders: [DatabaseConnectionFolder] { - feature.folders.filter { $0.parentID == nil } - } - - private func childFolders(of folder: DatabaseConnectionFolder) -> [DatabaseConnectionFolder] { - feature.folders.filter { $0.parentID == folder.id } - } - - private func connectionContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - let status = feature.connectionStatus(for: profile) - items.append(.action(status == .connected ? "Disconnect" : "Connect", isEnabled: !(status == .connecting), action: { - if status == .connected { - expandedProfileIDs.remove(profile.id) - expandedDatabasePaths = expandedDatabasePaths.filter { $0.profileID != profile.id } - feature.disconnect(profile) - } else { - selectProfile(profile, expand: true) - } - })) - if profile.kind.isSQLDatabase { - items.append(.action("New Query", action: { openSQLQuery(profile) })) - } - items.append(.action("Refresh", action: { refresh(profile) })) - if profile.kind == .mysql || profile.kind == .mariadb { - items.append(.submenu("Diagnostics", items: [ - .action("Force Refresh") { - Task { await feature.refreshDatabaseTree(for: profile.id, force: true) } - } - ])) - } - items.append(.separator) - items.append(.action("Copy Name", action: { copyToPasteboard(profile.name) })) - items.append(.action("Edit Connection", action: { - presentConnectionEditor(profile: profile) - })) - items.append(.submenu("Move", items: [ - .action("To Folder…") { presentFolderMove(.connection(profile.id)) }, - .action("To Top Level", isEnabled: profile.folderID != nil) { feature.move(profile, toFolder: nil) } - ])) - items.append(.action("Duplicate Connection", action: { _ = feature.duplicate(profile) })) - items.append(.separator) - items.append(.action("Remove Connection", role: .destructive, action: { - profilePendingDeletion = profile - showsProfileDeletionConfirmation = true - })) - return items - } - - private func presentFolderEditor(folder: DatabaseConnectionFolder? = nil, parentID: UUID? = nil, moving target: DatabaseFolderMoveTarget? = nil) { - editingFolder = folder - creatingFolderParentID = parentID - folderMoveTarget = target - showsFolderEditor = true - } - - private func presentFolderMove(_ target: DatabaseFolderMoveTarget) { - folderMoveTarget = target - if feature.folders.isEmpty { presentFolderEditor(moving: target) } - else { showsFolderDestination = true } - } - - private func folderPath(_ folder: DatabaseConnectionFolder) -> String { - // ponytail: Ancestors scan the small saved folder list; index IDs if large trees make menu construction slow. - var names = [folder.name], parent = folder.parentID, visited: Set = [folder.id] - while let id = parent, visited.insert(id).inserted, let ancestor = feature.folders.first(where: { $0.id == id }) { - names.insert(ancestor.name, at: 0) - parent = ancestor.parentID - } - return names.joined(separator: "/") - } - - private func revealFolder(_ id: UUID?) { - var cursor = id, visited: Set = [] - while let id = cursor, visited.insert(id).inserted { - collapsedFolderIDs.remove(id) - cursor = feature.folders.first(where: { $0.id == id })?.parentID - } - } - - private func moveToFolder(_ destination: UUID?) { - let moved: Bool - switch folderMoveTarget { - case .connection(let id): - guard let profile = feature.profiles.first(where: { $0.id == id }) else { return } - moved = feature.move(profile, toFolder: destination) - case .folder(let id): moved = feature.moveFolder(id, toParent: destination) - case nil: return - } - if moved { - revealFolder(destination) - showsFolderDestination = false - folderMoveTarget = nil - } - } - - private var folderDestinationItems: [LitheContextMenuItem] { - let destinations = feature.folders.filter { folder in - if case .folder(let id) = folderMoveTarget { return feature.canMoveFolder(id, toParent: folder.id) } - return true - }.sorted { folderPath($0).localizedStandardCompare(folderPath($1)) == .orderedAscending } - let canMoveToRoot: Bool - if case .folder(let id) = folderMoveTarget { canMoveToRoot = feature.canMoveFolder(id, toParent: nil) } - else { canMoveToRoot = true } - return [.heading("Move to Folder", centered: true), - .action("To Top Level", isEnabled: canMoveToRoot) { moveToFolder(nil) }, .separator] - + destinations.map { folder in - .action(folderPath(folder), localizesTitle: false) { moveToFolder(folder.id) } - } - + [.separator, .action("New Folder…") { - let target = folderMoveTarget - showsFolderDestination = false - presentFolderEditor(moving: target) - }] - } - - private func refresh(_ profile: DatabaseProfile) { - Task { - if profile.kind == .mysql || profile.kind == .mariadb { - await feature.refreshDatabaseTree(for: profile.id) - } else { - await feature.refresh(profile) - } - } - } - - private func refreshDatabase(_ profile: DatabaseProfile) { - Task { - if profile.kind == .mysql || profile.kind == .mariadb { - await feature.refreshDatabaseTree(for: profile.id, database: profile.database) - } else { - await feature.refresh(profile) - } - } - } - - private var canRefreshSelection: Bool { - if case .folder = treeSelection { return false } - return treeSelection != nil || feature.selectedProfile != nil - } - - private func refreshSelection() { - switch treeSelection { - case .connection(let id): - if let profile = feature.profiles.first(where: { $0.id == id }) { refresh(profile) } - case .database(let path), .objects(let path, _): - if let profile = feature.profiles.first(where: { $0.id == path.profileID }) { - refreshDatabase(databaseProfile(profile, database: path.database)) - } - case .table(let key): - Task { await feature.loadTableTree(key, force: true) } - case .folder: break - case nil: - if let profile = feature.selectedProfile { refresh(profile) } - } - } - - private func openSQLQuery(_ profile: DatabaseProfile, sql: String = "") { - Task { - guard await feature.selectDatabaseContext(profile), model.databaseFeatureIfActive === feature else { return } - model.openDatabaseQuery(profileID: profile.id, sql: sql) - } - } - - private func copyToPasteboard(_ value: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) - } - - private func databaseContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("New Query", action: { openSQLQuery(profile) })) - items.append(.action("New Table", isEnabled: !(profile.readOnly), action: { - openSQLQuery(profile, sql: "CREATE TABLE new_table (\n id INTEGER PRIMARY KEY\n);\n") - })) - if profile.kind != .sqlserver { - items.append(.action("Import SQL Backup…", isEnabled: !(profile.readOnly), action: { - Task { - guard await feature.selectDatabaseContext(profile) else { return } - importFormat = .sql; pendingImportFormat = .sql; showsImporter = true - } - })) - items.append(.action("Export Database as SQL…", action: { exportDatabase(profile) })) - } - items.append(.separator) - items.append(.action("Copy Name", action: { copyToPasteboard(profile.database.isEmpty ? "Default database" : profile.database) })) - items.append(.action("Refresh", action: { refreshDatabase(profile) })) - return items - } - - private func mongoDatabaseContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("Refresh Collections", action: { refreshDatabase(profile) })) - items.append(.action("Copy Name", action: { copyToPasteboard(profile.database.isEmpty ? "admin" : profile.database) })) - return items - } - - private func mongoCollectionContextMenu(_ profile: DatabaseProfile, collection: String) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("View Documents", action: { openTable(profile, table: collection, section: .data) })) - items.append(.action("Copy Name", action: { copyToPasteboard(collection) })) - items.append(.separator) - items.append(.action("Refresh", action: { - Task { - guard await feature.selectDatabaseContext(profile) else { return } - await feature.openTable(collection) - } - })) - return items - } - - private func tableGroupContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("New Table", isEnabled: !(profile.readOnly), action: { openSQLQuery(profile, sql: "CREATE TABLE new_table (\n id INTEGER PRIMARY KEY\n);\n") })) - items.append(.action("Refresh", action: { refreshDatabase(profile) })) - return items - } - - private func redisDatabaseContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("Refresh Keys", action: { refresh(profile) })) - items.append(.action("Set Database Alias", action: { - presentConnectionEditor(profile: profile) - })) - items.append(.action("Clear Current Redis DB", role: .destructive, isEnabled: !(profile.readOnly), action: { - pendingRedisProfile = profile - showsRedisFlushConfirmation = true - })) - return items - } - - private func nacosContextMenu(_ profile: DatabaseProfile) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("Refresh", action: { refresh(profile) })) - items.append(.action("Copy Name", action: { copyToPasteboard(profile.name) })) - items.append(.action("Edit Namespace", action: { - presentConnectionEditor(profile: profile) - })) - return items - } - - private func presentConnectionEditor( - profile: DatabaseProfile? = nil, - defaultFolderID: UUID? = nil, - defaultKind: DatabaseKind = .mysql - ) { - editingProfile = profile - newConnectionKind = defaultKind - newConnectionFolderID = defaultFolderID - connectionEditorPresentationID = UUID() - showsConnectionEditor = true - } - - private func tableContextMenu(_ profile: DatabaseProfile, table: String) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [ - .action("View Data") { openTable(profile, table: table, section: .data) }, - .action("New Query") { - openTable(profile, table: table, section: .sql, sql: "SELECT * FROM \(quotedIdentifier(table, kind: profile.kind));\n") - }, - .action("View Structure") { openTable(profile, table: table, section: .structure) }, - .action("Copy Name") { copyToPasteboard(table) }, - .separator, - .submenu("Export Data…", items: [ - .action("CSV") { exportTable(profile, table: table, format: .csv) }, - .action("JSON") { exportTable(profile, table: table, format: .json) } - ]) - ] - items.append(.action("Import Data…", isEnabled: !(profile.readOnly), action: { beginImport(.csv, profile: profile, table: table) })) - items.append(.separator) - items.append(.action("Clear Table", role: .destructive, isEnabled: !(profile.readOnly), action: { - pendingTableAction = DatabaseTableContextAction(profile: profile, table: table, kind: .clear) - showsTableActionConfirmation = true - })) - items.append(.action("Delete Table", role: .destructive, isEnabled: !(profile.readOnly), action: { - pendingTableAction = DatabaseTableContextAction(profile: profile, table: table, kind: .drop) - showsTableActionConfirmation = true - })) - items.append(.separator) - items.append(.action("Refresh", action: { - Task { await feature.loadTableTree(DatabaseTreeTableKey(profileID: profile.id, database: profile.database, table: table), force: true) } - })) - return items - } - - private func openTable(_ profile: DatabaseProfile, table: String, section: DatabaseWorkspaceSection, sql: String = "") { - treeSelection = .table(DatabaseTreeTableKey(profileID: profile.id, database: profile.database, table: table)) - Task { - guard await feature.selectDatabaseContext(profile), model.databaseFeatureIfActive === feature else { return } - if section == .sql { - model.openDatabaseQuery(profileID: profile.id, sql: sql) - } else { - model.selectDatabaseWorkspace(profileID: profile.id, table: table) - guard feature.selectedProfileID == profile.id else { return } - feature.workspaceSection = section - } - } - } - - private func executeTableAction(_ action: DatabaseTableContextAction) { - pendingTableAction = nil - let sql = action.kind == .clear - ? "DELETE FROM \(quotedIdentifier(action.table, kind: action.profile.kind));" - : "DROP TABLE \(quotedIdentifier(action.table, kind: action.profile.kind));" - Task { - guard await feature.selectDatabaseContext(action.profile), model.databaseFeatureIfActive === feature else { return } - model.openDatabaseQuery(profileID: action.profile.id, sql: sql) - guard feature.selectedProfileID == action.profile.id, let tabID = feature.selectedSQLTabID else { return } - model.showToolWindow(.databaseServices) - await feature.runSQL(in: tabID, confirmedRisk: true) - guard model.databaseFeatureIfActive === feature, feature.selectedProfileID == action.profile.id else { return } - if action.kind == .clear { await feature.openTable(action.table) } - else { await feature.refreshTables() } - } - } - - private func beginImport(_ format: DatabaseTransferFormat, profile: DatabaseProfile, table: String?) { - Task { - guard await feature.selectDatabaseContext(profile) else { return } - if let table { await feature.openTable(table) } - importFormat = format - pendingImportFormat = format - showsImporter = true - } - } - - private func exportTable(_ profile: DatabaseProfile, table: String, format: DatabaseTransferFormat) { - Task { - guard await feature.selectDatabaseContext(profile) else { return } - await feature.openTable(table) - exportFormat = format - guard let data = await feature.exportData(format: format) else { return } - exportDocument = DatabaseTransferDocument(data: data) - showsExporter = true - } - } - - private func exportDatabase(_ profile: DatabaseProfile) { - Task { - guard await feature.selectDatabaseContext(profile) else { return } - exportFormat = .sql - guard let url = await feature.exportDataFile(format: .sql) else { return } - temporaryExportURL = url - exportDocument = DatabaseTransferDocument(fileURL: url) - showsExporter = true - } - } - - private var exportFilename: String { - let base = feature.selectedProfile?.database.isEmpty == false ? feature.selectedProfile?.database ?? "database" : "database" - return "\(base).\(exportFormat.rawValue)" - } - - private func importFile(_ result: Result) { - do { - let url = try result.get() - let accessed = url.startAccessingSecurityScopedResource() - defer { if accessed { url.stopAccessingSecurityScopedResource() } } - let format = pendingImportFormat ?? importFormat - if format == .sql { - let temporaryURL = try feature.prepareImportFile(from: url) - pendingImportURL = temporaryURL - } else { - pendingImportData = try feature.readImportData(from: url) - } - pendingImportFormat = format - if format == .sql || feature.selectedProfile?.productionProtection == true { - showsProtectedImportConfirmation = true - } else { - performPendingImport(confirmed: false) - } - } catch { feature.errorMessage = error.localizedDescription } - } - - private func performPendingImport(confirmed: Bool) { - guard let format = pendingImportFormat else { return } - let data = pendingImportData - let fileURL = pendingImportURL - pendingImportData = nil; pendingImportURL = nil; pendingImportFormat = nil - Task { - if let fileURL { - defer { feature.removeTemporaryFile(fileURL) } - _ = await feature.importDataFile(fileURL, format: format, confirmed: confirmed) - } else if let data { - _ = await feature.importData(data, format: format, confirmed: confirmed) - } - } - } - - private func discardPendingImport() { - if let pendingImportURL { feature.removeTemporaryFile(pendingImportURL) } - pendingImportData = nil; pendingImportURL = nil; pendingImportFormat = nil - } - - private func quotedIdentifier(_ value: String, kind: DatabaseKind) -> String { - if kind == .sqlserver { return "[\(value.replacingOccurrences(of: "]", with: "]]"))]" } - let quote = (kind == .mysql || kind == .mariadb) ? "`" : "\"" - return "\(quote)\(value.replacingOccurrences(of: quote, with: quote + quote))\(quote)" - } - - @ViewBuilder - private func connectionStatusIndicator(_ profile: DatabaseProfile) -> some View { - let status = feature.connectionStatus(for: profile) - switch status { - case .idle: - EmptyView() - case .connecting: - ProgressView() - .controlSize(.mini) - .tint(LitheTheme.accent) - .frame(width: 5, height: 5) - .help("Connecting") - .accessibilityLabel("Connecting") - case .connected: - Circle() - .fill(LitheTheme.success) - .saturation(1.25) - .frame(width: 5, height: 5) - .help("Connected") - .accessibilityLabel("Connected") - case .failed: - Circle() - .fill(LitheTheme.error) - .saturation(1.25) - .frame(width: 5, height: 5) - .help("Connection failed") - .accessibilityLabel("Connection failed") - } - } - - private func selectProfile(_ profile: DatabaseProfile, expand: Bool) { - treeSelection = .connection(profile.id) - if expand { - expandedProfileIDs.insert(profile.id) - } - model.selectDatabaseWorkspace(profileID: profile.id, refresh: feature.selectedProfileID != profile.id) - } - - private func toggleProfile(_ profile: DatabaseProfile) { - if expandedProfileIDs.contains(profile.id) { - expandedProfileIDs.remove(profile.id) - feature.cancelDatabaseTreeLoad(for: profile.id) - } else { - expandedProfileIDs.insert(profile.id) - if profile.kind != .mysql && profile.kind != .mariadb { - selectProfile(profile, expand: true) - } - } - } - - private func collapseAll() { - for id in expandedProfileIDs { feature.cancelDatabaseTreeLoad(for: id) } - collapsedFolderIDs = Set(feature.folders.map(\.id)) - expandedProfileIDs.removeAll() - collapsedObjectKinds.removeAll() - expandedDatabasePaths.removeAll() - expandedTableKeys.removeAll() - } - - private var searchQuery: String { - searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } - - private var unfiledProfiles: [DatabaseProfile] { - sorted(feature.profiles.filter { profile in - guard profile.folderID == nil || !feature.folders.contains(where: { $0.id == profile.folderID }) else { - return false - } - return profileMatchesSearch(profile) - }) - } - - private func allProfiles(in folder: DatabaseConnectionFolder) -> [DatabaseProfile] { - feature.profiles.filter { $0.folderID == folder.id } - } - - private func recursiveProfileCount(in folder: DatabaseConnectionFolder) -> Int { - allProfiles(in: folder).count + childFolders(of: folder).reduce(0) { count, child in - count + recursiveProfileCount(in: child) - } - } - - private func visibleProfiles(in folder: DatabaseConnectionFolder) -> [DatabaseProfile] { - let profiles = allProfiles(in: folder) - if searchQuery.isEmpty { return sorted(profiles.filter(profileMatchesSearch)) } - if folder.name.lowercased().contains(searchQuery) { return sorted(profiles.filter(matchesKindFilter)) } - return sorted(profiles.filter(profileMatchesSearch)) - } - - private func isFolderVisible(_ folder: DatabaseConnectionFolder) -> Bool { - (searchQuery.isEmpty && kindFilter == nil) - || folder.name.lowercased().contains(searchQuery) - || !visibleProfiles(in: folder).isEmpty - || childFolders(of: folder).contains(where: isFolderVisible) - } - - private func profileMatchesSearch(_ profile: DatabaseProfile) -> Bool { - guard matchesKindFilter(profile) else { return false } - guard !searchQuery.isEmpty else { return true } - let searchableText = [profile.name, profile.kind.displayName, profile.host, profile.database, profile.path] - .joined(separator: " ") - .lowercased() - return searchableText.contains(searchQuery) - } - - private func matchesKindFilter(_ profile: DatabaseProfile) -> Bool { - kindFilter == nil || profile.kind == kindFilter - } - - private func sorted(_ profiles: [DatabaseProfile]) -> [DatabaseProfile] { - profiles.sorted { lhs, rhs in - switch connectionSort { - case .name: - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - case .databaseType: - if lhs.kind.displayName == rhs.kind.displayName { - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } else { - lhs.kind.displayName < rhs.kind.displayName - } - case .host: - if lhs.host == rhs.host { - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } else { - lhs.host.localizedCaseInsensitiveCompare(rhs.host) == .orderedAscending - } - } - } - } - - private func objectLabel(_ row: DatabaseRow) -> String { - for key in ["table_name", "routine_name", "trigger_name", "sequence_name", "name", "TABLE_NAME", "ROUTINE_NAME", "TRIGGER_NAME", "SEQUENCE_NAME"] { - if let value = row[key], case let .string(name) = value, !name.isEmpty { return name } - } - return row.keys.sorted().compactMap { key in - guard let value = row[key] else { return nil } - return "\(key): \(value.displayText)" - }.joined(separator: " ") - } -} - -private struct DatabaseDBXImportSheet: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @Binding var isPresented: Bool - @FocusState private var passphraseFocused: Bool - - let data: Data - @State private var passphrase = "" - @State private var plan: DatabaseDBXImportPlan? - @State private var selectedIDs: Set = [] - @State private var errorMessage: String? - @State private var isReading = false - - private let service = DatabaseDBXImportService() - private var isEncrypted: Bool { service.isEncrypted(data) } - - var body: some View { - VStack(spacing: 0) { - Text("Connections are saved without testing them. Passwords are stored in Keychain.") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - Group { - if let plan { - preview(plan) - } else { - unlockView - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack(spacing: LitheTheme.ActionButton.spacing) { - if plan != nil { - Text("\(selectedIDs.count) connections selected") - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .monospacedDigit() - } - Spacer() - Button("Cancel") { isPresented = false } - .buttonStyle(LitheActionButtonStyle()) - .keyboardShortcut(.cancelAction) - if let plan { - Button("Import Selected") { - let count = feature.importDBXConnections(plan: plan, selectedIDs: selectedIDs) - if count > 0 { isPresented = false } - } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .disabled(selectedIDs.isEmpty) - } - } - .padding(.horizontal, 16) - .frame(height: 54) - .background(LitheCommitDialogStyle.background) - } - .frame(width: 680, height: 560) - .background(LitheCommitDialogStyle.background) - .foregroundStyle(LitheTheme.primaryText) - .onAppear { - if !isEncrypted { readFile() } - } - .onDisappear { passphrase = "" } - } - - private var unlockView: some View { - VStack(spacing: 14) { - Spacer() - LitheIDEAIcon(resourcePath: isEncrypted ? "expui/general/locked.svg" : "expui/general/import.svg", size: 16, preservesOriginalColors: true) - Text(isEncrypted ? "Encrypted DBX Export" : "Reading DBX Export") - .font(LitheTheme.uiFont(size: 13, weight: .semibold)) - if isEncrypted { - Text("Enter the password used when this connection file was exported from DBX.") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - .multilineTextAlignment(.center) - .frame(maxWidth: 360) - SecureField("DBX export password", text: $passphrase) - .focused($passphraseFocused) - .modifier(LitheCommitDialogInputStyle(focused: passphraseFocused)) - .frame(width: 320) - .onSubmit(readFile) - } - if let errorMessage { - DatabaseLocalization.text(errorMessage) - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.error) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - } - if isReading { - ProgressView("Reading connection configuration…") - .controlSize(.small) - } else if isEncrypted { - Button("Read DBX Export") { readFile() } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .disabled(passphrase.isEmpty) - } - Spacer() - } - .padding(24) - } - - private func preview(_ plan: DatabaseDBXImportPlan) -> some View { - VStack(spacing: 0) { - HStack(spacing: 18) { - summaryValue(plan.importableCount, label: "Ready") - summaryValue(plan.duplicateCount, label: "Duplicates") - summaryValue(plan.unsupportedCount, label: "Unsupported") - summaryValue(plan.folders.count, label: "Folders") - Spacer() - if plan.wasEncrypted { - Text("Passwords decrypted") - .font(LitheTheme.uiFont(size: 10, weight: .medium)) - .foregroundStyle(LitheTheme.success) - } - } - .padding(.horizontal, 16) - .frame(height: 54) - .background(LitheTheme.inputBackground.opacity(0.55)) - - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - ScrollView { - LazyVStack(spacing: 1) { - ForEach(plan.candidates) { candidate in - candidateRow(candidate) - } - if !plan.unsupportedTypes.isEmpty { - LitheBanner(status: .warning) { - Text("Unsupported DBX database types").litheBannerTitle() - Text(plan.unsupportedTypes.sorted(by: { $0.key < $1.key }).map { "\($0.key) × \($0.value)" }.joined(separator: ", ")) - .font(LitheTheme.uiFont(size: 13, design: .monospaced)) - } - } - } - .padding(10) - } - } - } - - private func summaryValue(_ value: Int, label: LocalizedStringKey) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text("\(value)").font(LitheTheme.uiFont(size: 13, weight: .semibold, design: .monospaced)) - Text(label).font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.tertiaryText) - } - } - - private func candidateRow(_ candidate: DatabaseDBXImportCandidate) -> some View { - let isSelected = selectedIDs.contains(candidate.id) - return Button { - guard !candidate.isDuplicate else { return } - if isSelected { selectedIDs.remove(candidate.id) } - else { selectedIDs.insert(candidate.id) } - } label: { - HStack(spacing: 10) { - LitheIDEAIcon(resourcePath: candidate.isDuplicate ? "commit/checkBoxDisabled.svg" : (isSelected ? "commit/checkBoxSelected.svg" : "commit/checkBox.svg"), size: 16, preservesOriginalColors: true) - .frame(width: 18) - DatabaseBrandIcon(kind: candidate.profile.kind, size: 18) - .frame(width: 22) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 7) { - Text(candidate.profile.name) - .font(LitheTheme.uiFont(size: 13)) - .lineLimit(1) - if candidate.isDuplicate { - Text("Duplicate") - .font(LitheTheme.uiFont(size: 9, weight: .medium)) - .foregroundStyle(LitheTheme.tertiaryText) - } else if !candidate.warnings.isEmpty { - Text("Review") - .font(LitheTheme.uiFont(size: 9, weight: .medium)) - .foregroundStyle(LitheTheme.warning) - } - } - Text(candidateSubtitle(candidate.profile)) - .font(LitheTheme.uiFont(size: 10, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - ForEach(candidate.warnings, id: \.self) { warning in - DatabaseLocalization.text(warning) - .font(LitheTheme.uiFont(size: 9.5)) - .foregroundStyle(LitheTheme.warning) - .lineLimit(1) - } - } - Spacer() - Text(candidate.profile.kind.displayName) - .font(LitheTheme.uiFont(size: 9.5, weight: .medium)) - .foregroundStyle(LitheTheme.tertiaryText) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .disabled(candidate.isDuplicate) - .litheRowHover(isActive: isSelected, activeBackground: LitheTheme.subtleSelection, - hoverBackground: candidate.isDuplicate ? .clear : LitheTheme.hoverBackground) - } - - private func candidateSubtitle(_ profile: DatabaseProfile) -> String { - if profile.kind == .sqlite { return profile.path } - let endpoint = profile.port > 0 ? "\(profile.host):\(profile.port)" : profile.host - return profile.username.isEmpty ? endpoint : "\(profile.username) @ \(endpoint)" - } - - private func readFile() { - guard !isReading else { return } - isReading = true - errorMessage = nil - let data = data - let passphrase = isEncrypted ? passphrase : nil - let profiles = feature.profiles - Task { - do { - let parsed = try await Task.detached { - try service.parse(data: data, passphrase: passphrase, existingProfiles: profiles) - }.value - plan = parsed - selectedIDs = Set(parsed.candidates.filter { !$0.isDuplicate }.map(\.id)) - self.passphrase = "" - } catch { - errorMessage = error.localizedDescription - } - isReading = false - } - } -} - -private enum DatabaseConnectionSort: String, CaseIterable, Identifiable { - case name - case databaseType - case host - - var id: String { rawValue } - - var title: LocalizedStringKey { LocalizedStringKey(menuTitle) } - var menuTitle: String { - switch self { - case .name: "Name" - case .databaseType: "Database type" - case .host: "Host" - } - } -} - -private extension DatabaseObjectKind { - var title: String { - switch self { - case .tables: "Tables" - case .views: "Views" - case .routines: "Routines" - case .triggers: "Triggers" - case .sequences: "Sequences" - } - } - - var symbol: String { - switch self { - case .tables: "tablecells" - case .views: "eye" - case .routines: "function" - case .triggers: "bolt" - case .sequences: "list.number" - } - } -} - -private extension DatabaseKind { - var displayName: String { - switch self { - case .mysql: "MySQL" - case .mariadb: "MariaDB" - case .postgresql: "PostgreSQL" - case .sqlite: "SQLite" - case .sqlserver: "SQL Server" - case .mongodb: "MongoDB" - case .redis: "Redis" - case .nacos: "Nacos" - } - } - - var symbolName: String { - switch self { - case .mysql: "cylinder.fill" - case .mariadb: "cylinder.fill" - case .postgresql: "cylinder.split.1x2.fill" - case .sqlite: "externaldrive.fill" - case .sqlserver: "server.rack" - case .mongodb: "leaf.fill" - case .redis: "square.stack.3d.up.fill" - case .nacos: "slider.horizontal.3" - } - } -} - -extension Color { - init(hex: String) { - let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) - var value: UInt64 = 0 - Scanner(string: cleaned).scanHexInt64(&value) - let red = Double((value >> 16) & 0xff) / 255 - let green = Double((value >> 8) & 0xff) / 255 - let blue = Double(value & 0xff) / 255 - self.init(red: red, green: green, blue: blue) - } -} - -private struct DatabaseSchemaSelector: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - let profileID: UUID - let names: [String] - @Binding var selection: [String]? - let onApply: () -> Void - @State private var query = "" - @State private var selectedRow: String? - @FocusState private var searchFocused: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Spacer() - Button { - Task { await feature.loadDatabaseTree(for: profileID, force: true) } - } label: { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .accessibilityLabel("Refresh") - .workbenchHoverHelp(Text("Refresh")) - } - .padding(.horizontal, LitheDropdownMetrics.popupPadding) - .frame(height: LitheTheme.Metrics.toolbarHeight) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - // Keep the native editor mounted for the shared host's type-to-search/IME routing. - LitheSearchField("Search databases", text: $query, focus: $searchFocused) - .padding(.horizontal, LitheTheme.Tree.horizontalInset) - .frame(height: searchFocused || !query.isEmpty ? LitheTheme.Tree.rowHeight : 0) - .clipped() - ScrollView { - VStack(alignment: .leading, spacing: 0) { - schemaRow(title: Text("All schemas"), name: nil, isOn: selection == nil) { - selection = selection == nil ? [] : nil - } - Rectangle().fill(LitheTheme.divider).frame(height: 1) - .padding(.leading, LitheTheme.Tree.indent) - ForEach(names.filter { query.isEmpty || $0.localizedStandardContains(query) }, id: \.self) { name in - schemaRow(title: Text(verbatim: name), name: name, isOn: selection?.contains(name) ?? true) { - toggleSchema(name) - } - } - } - .padding(.horizontal, LitheTheme.Tree.horizontalInset) - .padding(.vertical, LitheTheme.Tree.verticalInset) - } - .litheScrollViewChrome() - // Use the shared tall-list viewport; the popup host bounds it to the available screen. - .frame(height: LitheDropdownMetrics.historyViewportHeight) - Text("Press Enter or click outside the list to apply") - // Community Popup.Advertiser: regular UI font minus 1pt, 8/12 insets. - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.Tree.secondaryText) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, LitheTheme.Tree.horizontalInset) - .padding(.vertical, LitheDropdownMetrics.itemHorizontalPadding) - .background(LitheTheme.popupBackground) - } - .background(LitheTheme.sidebar) - .frame(width: LitheDropdownMetrics.maximumWidth) - .environment(\.lithePointingHandCursorEnabled, false) - .onSubmit(onApply) - .litheDropdownKeyHandler { event in - guard event.modifierFlags.intersection([.command, .control, .option]).isEmpty, - event.keyCode == 36 || event.keyCode == 76 else { return false } - onApply() - return true - } - } - - private func schemaRow(title: Text, name: String?, isOn: Bool, action: @escaping () -> Void) -> some View { - LitheTreeCheckbox(state: isOn ? .on : .off, accessibilityTitle: "", title: title) { - selectedRow = name ?? "" - action() - } - .padding(.leading, LitheTheme.Tree.indent) - .background(selectedRow == (name ?? "") ? LitheTheme.Tree.focusedSelection : .clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius)) - } - - private func toggleSchema(_ name: String) { - var selected = Set(selection ?? names) - if selected.contains(name) { selected.remove(name) } else { selected.insert(name) } - selection = names.filter { selected.contains($0) } - } -} - -private struct DatabaseFolderEditor: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @Binding var isPresented: Bool - let folder: DatabaseConnectionFolder? - let parentID: UUID? - let moveTarget: DatabaseFolderMoveTarget? - let onSaved: () -> Void - @State private var validationMessage: String? - - var body: some View { - LitheNamePopup(title: folder == nil ? "New Folder" : "Rename Folder", initialName: folder?.name ?? "", - validationMessage: validationMessage, onSubmit: save, onCancel: { isPresented = false }) - } - - private func save(_ name: String) { - let didSave: Bool - if let folder { didSave = feature.renameFolder(folder, to: name) } - else { - switch moveTarget { - case .connection(let id): didSave = feature.createFolder(name: name, parentID: parentID, movingProfileID: id) - case .folder(let id): didSave = feature.createFolder(name: name, parentID: parentID, movingFolderID: id) - case nil: didSave = feature.createFolder(name: name, parentID: parentID) - } - } - if didSave { isPresented = false; onSaved() } - else { validationMessage = feature.errorMessage } - } -} - -/// Content layout follows the supplied Database Tools reference; that plugin is not in Community. -/// Community c7f91397 supplies BalloonImpl geometry and LafManagerImpl's locale-aware fonts. -struct DatabaseConnectionResultView: View { - @Environment(\.locale) private var locale - let summary: String - let pointerX: CGFloat - var onCopy: () -> Void = {} - @State private var copyHovered = false - - private func font(bold: Bool = false) -> Font { - // LafManagerImpl.shouldFallbackToSystemFont: CJK uses the macOS LaF (Helvetica Neue, 13). - let cjk = ["zh", "ja", "ko"].contains(locale.language.languageCode?.identifier ?? "") - return .custom(cjk ? (bold ? "HelveticaNeue-Bold" : "HelveticaNeue") : (bold ? "Inter-Bold" : "Inter-Regular"), size: 13) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - Text("Connection successful") - .font(font(bold: true)) - .foregroundStyle(LitheTheme.DatabaseConnectionResult.success) - Spacer() - Button("Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(summary, forType: .string) - onCopy() - } - .buttonStyle(.litheNoPress) - .foregroundStyle(LitheTheme.accent) - .lithePointer() - .underline(copyHovered) - .onHover { copyHovered = $0 } - } - Text(summary).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) - } - .font(font()) - .foregroundStyle(LitheTheme.DatabaseConnectionResult.foreground) - .padding(16) - .frame(width: 728, alignment: .leading) - .padding(.bottom, 10) - .background(DatabaseConnectionResultOutline(pointerX: pointerX).fill(LitheTheme.DatabaseConnectionResult.background)) - .overlay(DatabaseConnectionResultOutline(pointerX: pointerX).stroke(LitheTheme.DatabaseConnectionResult.border, lineWidth: 1).allowsHitTesting(false)) - } -} - -/// BalloonImpl: non-dialog ARC=4, TOPBOTTOM_POINTER_WIDTH=14, TOPBOTTOM_POINTER_LENGTH=10. -private struct DatabaseConnectionResultOutline: Shape { - let pointerX: CGFloat - - func path(in bounds: CGRect) -> Path { - // BalloonImpl.Shaper uses integer edges, with the far edge at size - 1. - // A half-point inset instead makes the outer stroke heavier on the pixel grid. - let rect = CGRect(x: bounds.minX, y: bounds.minY, width: bounds.width - 1, height: bounds.height - 1) - let bottom = rect.maxY - 10 - let x = min(max(pointerX, rect.minX + 11), rect.maxX - 11) - return Path { path in - path.move(to: CGPoint(x: rect.minX + 4, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX - 4, y: rect.minY)) - path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + 4), control: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: bottom - 4)) - path.addQuadCurve(to: CGPoint(x: rect.maxX - 4, y: bottom), control: CGPoint(x: rect.maxX, y: bottom)) - path.addLine(to: CGPoint(x: x + 7, y: bottom)) - path.addLine(to: CGPoint(x: x, y: rect.maxY)) - path.addLine(to: CGPoint(x: x - 7, y: bottom)) - path.addLine(to: CGPoint(x: rect.minX + 4, y: bottom)) - path.addQuadCurve(to: CGPoint(x: rect.minX, y: bottom - 4), control: CGPoint(x: rect.minX, y: bottom)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + 4)) - path.addQuadCurve(to: CGPoint(x: rect.minX + 4, y: rect.minY), control: CGPoint(x: rect.minX, y: rect.minY)) - path.closeSubpath() - } - } -} - -struct DatabaseConnectionEditor: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @Environment(\.locale) private var locale - @Binding var isPresented: Bool - @State private var name = "" - @State private var usesDefaultName = true - @State private var kind = DatabaseKind.mysql - @State private var host = "127.0.0.1" - @State private var port = "3306" - @State private var username = "" - @State private var password = "" - @State private var passwordPersistence: DatabasePasswordPersistence = .forever - @State private var database = "" - @State private var path = "" - @State private var ssl = false - @State private var folderID: UUID? - @State private var colorHex = "" - @State private var readOnly = false - @State private var productionProtection = false - @State private var maskSensitiveFields = false - @State private var sensitiveColumnPatterns = "password, secret, token, api_key" - @State private var caCertificatePath = "" - @State private var serverName = "" - @State private var sshHost = "" - @State private var sshPort = "22" - @State private var sshUsername = "" - @State private var sshKeyPath = "" - @State private var sshLocalPort = "" - @State private var proxyURL = "" - private enum Section: String, CaseIterable { case general = "General", network = "SSH / TLS", safety = "Safety and Labels" } - @State private var section: Section = .general - @State private var draftID = UUID() - @State private var testTask: Task? - @State private var isTesting = false - @State private var testSucceeded = false - @State private var showsTestResult = false - @State private var testDurationMilliseconds = 0 - @State private var testError: String? - @State private var usesSSHTunnel = false - @State private var hasSavedPassword = false - @State private var isConnecting = false - @FocusState private var nameFocused: Bool - - private let profile: DatabaseProfile? - - init(isPresented: Binding, profile: DatabaseProfile? = nil, defaultFolderID: UUID? = nil, defaultKind: DatabaseKind = .mysql) { - _isPresented = isPresented - self.profile = profile - _name = State(initialValue: profile?.name ?? "") - _usesDefaultName = State(initialValue: profile == nil) - _kind = State(initialValue: profile?.kind ?? defaultKind) - _host = State(initialValue: profile?.host ?? "127.0.0.1") - _port = State(initialValue: String(profile?.port ?? 3306)) - _passwordPersistence = State(initialValue: profile?.passwordPersistence ?? .forever) - _username = State(initialValue: profile?.username ?? "") - _database = State(initialValue: profile?.database ?? "") - _path = State(initialValue: profile?.path ?? "") - _ssl = State(initialValue: profile?.ssl ?? false) - _folderID = State(initialValue: profile?.folderID ?? defaultFolderID) - _colorHex = State(initialValue: profile?.colorHex ?? "") - _readOnly = State(initialValue: profile?.readOnly ?? false) - _productionProtection = State(initialValue: profile?.productionProtection ?? false) - _maskSensitiveFields = State(initialValue: profile?.maskSensitiveFields ?? false) - _sensitiveColumnPatterns = State(initialValue: profile?.sensitiveColumnPatterns.joined(separator: ", ") ?? "password, secret, token, api_key") - _caCertificatePath = State(initialValue: profile?.caCertificatePath ?? "") - _serverName = State(initialValue: profile?.serverName ?? "") - _sshHost = State(initialValue: profile?.sshHost ?? "") - _sshPort = State(initialValue: String(profile?.sshPort ?? 22)) - _sshUsername = State(initialValue: profile?.sshUsername ?? "") - _sshKeyPath = State(initialValue: profile?.sshKeyPath ?? "") - _sshLocalPort = State(initialValue: profile?.sshLocalPort == 0 ? "" : String(profile?.sshLocalPort ?? 0)) - _proxyURL = State(initialValue: profile?.proxyURL ?? "") - _usesSSHTunnel = State(initialValue: !(profile?.sshHost ?? "").isEmpty) - } - - var body: some View { - VStack(spacing: 0) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - GridRow { - Text("Name") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: Binding(get: { connectionName }, set: { name = $0; usesDefaultName = false }), focus: $nameFocused) - .accessibilityLabel("Name") - .frame(maxWidth: .infinity) - } - }.padding(16) - HStack(spacing: 6) { - ForEach(Section.allCases, id: \.self) { item in - Button { nameFocused = false; section = item } label: { - Text(LocalizedStringKey(item.rawValue)).font(LitheTheme.uiFont(size: 13)).padding(.horizontal, 10) - } - .buttonStyle(.litheNoPress) - .modifier(LitheToolWindowTabStyle(isSelected: section == item, isActive: true)) - .accessibilityAddTraits(section == item ? .isSelected : []) - } - Spacer() - }.padding(.horizontal, 16).padding(.bottom, 8) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - ScrollView { - Group { - switch section { - case .general: - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - GridRow { - Text("Engine") - .frame(width: 140, alignment: .leading) - LitheSettingsSelect(selection: $kind, options: DatabaseKind.allCases, width: 576, accessibilityLabel: "Engine", title: { $0.displayName }) - } - - if kind == .sqlite { - GridRow { - Text("Database file path") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $path) - .accessibilityLabel("Database file path") - .frame(maxWidth: .infinity) - } - } else { - GridRow { - Text(kind == .mongodb ? "Host or MongoDB URI" : "Host") - .frame(width: 140, alignment: .leading) - HStack(spacing: 12) { - LitheTextField("", text: $host) - .accessibilityLabel(kind == .mongodb ? "Host or MongoDB URI" : "Host") - .frame(maxWidth: .infinity) - Text("Port").frame(width: 42, alignment: .trailing) - LitheTextField("", text: $port) - .accessibilityLabel("Port") - .frame(width: 140) - } - } - GridRow { - Text(kind == .nacos || kind == .mongodb ? "Username (optional)" : (kind == .redis ? "Username / ACL user (optional)" : "Username")) - .frame(width: 140, alignment: .leading) - HStack(spacing: 12) { - LitheTextField("", text: $username) - .accessibilityLabel(kind == .nacos || kind == .mongodb ? "Username (optional)" : (kind == .redis ? "Username / ACL user (optional)" : "Username")) - .frame(maxWidth: .infinity) - HStack(spacing: 12) { - Text("Port").frame(width: 42, alignment: .trailing) - Color.clear.frame(width: 140, height: SettingsSelectMetrics.controlHeight) - }.hidden().accessibilityHidden(true) - } - } - GridRow { - Text(profile == nil ? "Password" : (hasSavedPassword ? "Password (leave blank to keep current)" : "Password (no saved password)")) - .frame(width: 140, alignment: .leading) - HStack(spacing: 12) { - LitheTextField("", text: $password, secure: true) - .accessibilityLabel("Password") - .frame(maxWidth: .infinity) - HStack(spacing: 12) { - Text("Save:").frame(width: 42, alignment: .trailing) - LitheSettingsSelect(selection: $passwordPersistence, options: DatabasePasswordPersistence.allCases, width: 140, accessibilityLabel: "Save password", title: { - let key = $0 == .forever ? "Forever" : "Until restart" - return AppLanguage(rawValue: locale.identifier)?.localizedString(key) ?? key - }) - } - } - } - if kind == .redis { - GridRow { - Text("Redis database index") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $database) - .accessibilityLabel("Redis database index") - .frame(maxWidth: .infinity) - } - .onChange(of: database) { value in - let digitsOnly = value.filter(\.isNumber) - if digitsOnly != value { database = digitsOnly } - } - if let redisDatabaseValidationMessage { - GridRow { - Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) - Text(redisDatabaseValidationMessage) - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.error) - .gridCellUnsizedAxes(.horizontal) - } - } - GridRow { - Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) - Text("Leave the database index at 0 for the standard Redis database.") - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .gridCellUnsizedAxes(.horizontal) - } - } else if kind == .nacos { - GridRow { - Text("Namespace ID (optional)") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $database) - .accessibilityLabel("Namespace ID (optional)") - .frame(maxWidth: .infinity) - } - GridRow { - Text("API context path") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $path) - .accessibilityLabel("API context path") - .frame(maxWidth: .infinity) - } - GridRow { - Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) - Text("Use /nacos for a standard Nacos server, or / for a reverse proxy at its root.") - .font(LitheTheme.uiFont(size: 10.5)) - .foregroundStyle(LitheTheme.secondaryText) - .gridCellUnsizedAxes(.horizontal) - } - } else { - GridRow { - Text("Database name") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $database) - .accessibilityLabel("Database name") - .frame(maxWidth: .infinity) - } - } - } - } - case .network: - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - if kind != .sqlite { - GridRow { - Text("Use TLS") - .frame(width: 140, alignment: .leading) - Toggle("Use TLS", isOn: $ssl) - .labelsHidden() - .toggleStyle(.switch) - } - } - if ssl { - GridRow { - Text("TLS CA certificate path") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $caCertificatePath) - .accessibilityLabel("TLS CA certificate path") - .frame(maxWidth: .infinity) - } - GridRow { - Text("TLS server name") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $serverName) - .accessibilityLabel("TLS server name") - .frame(maxWidth: .infinity) - } - } - GridRow { - Text("Use SSH tunnel") - .frame(width: 140, alignment: .leading) - Toggle("Use SSH tunnel", isOn: $usesSSHTunnel) - .labelsHidden() - .toggleStyle(.switch) - } - if usesSSHTunnel { - GridRow { - Text("SSH host") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sshHost) - .accessibilityLabel("SSH host") - .frame(maxWidth: .infinity) - } - GridRow { - Text("SSH port") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sshPort) - .accessibilityLabel("SSH port") - .frame(maxWidth: .infinity) - } - GridRow { - Text("SSH username") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sshUsername) - .accessibilityLabel("SSH username") - .frame(maxWidth: .infinity) - } - GridRow { - Text("SSH key path") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sshKeyPath) - .accessibilityLabel("SSH key path") - .frame(maxWidth: .infinity) - } - GridRow { - Text("SSH local port") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sshLocalPort) - .accessibilityLabel("SSH local port") - .frame(maxWidth: .infinity) - } - GridRow { - Text("Proxy URL (optional)") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $proxyURL) - .accessibilityLabel("Proxy URL (optional)") - .frame(maxWidth: .infinity) - } - } - } - case .safety: - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - GridRow { - Text("Folder") - .frame(width: 140, alignment: .leading) - LitheSettingsSelect(selection: $folderID, options: [UUID?.none] + feature.folders.map { Optional($0.id) }, width: 576, accessibilityLabel: "Folder", title: { id in feature.folders.first { $0.id == id }?.name ?? String(localized: "No folder") }) - } - GridRow { - Text("Color hex (optional)") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $colorHex) - .accessibilityLabel("Color hex (optional)") - .frame(maxWidth: .infinity) - } - GridRow { - Text("Read-only connection") - .frame(width: 140, alignment: .leading) - Toggle("Read-only connection", isOn: $readOnly) - .labelsHidden() - .toggleStyle(.switch) - } - GridRow { - Text("Production protection") - .frame(width: 140, alignment: .leading) - Toggle("Production protection", isOn: $productionProtection) - .labelsHidden() - .toggleStyle(.switch) - } - if kind.supportsDataGrid { - GridRow { - Text("Mask sensitive fields") - .frame(width: 140, alignment: .leading) - Toggle("Mask sensitive fields", isOn: $maskSensitiveFields) - .labelsHidden() - .toggleStyle(.switch) - } - GridRow { - Text("Sensitive column patterns") - .frame(width: 140, alignment: .leading) - LitheTextField("", text: $sensitiveColumnPatterns) - .accessibilityLabel("Sensitive column patterns") - .frame(maxWidth: .infinity) - } - } - } - } - }.frame(maxWidth: .infinity, alignment: .leading).padding(16) - } - if let error = feature.errorMessage { DatabaseLocalization.error(error).font(LitheTheme.uiFont(size: 11)).foregroundStyle(LitheTheme.error).padding(.horizontal, 16) } - if isTesting || testError != nil { - HStack { - if let testError { DatabaseLocalization.error(testError).foregroundStyle(LitheTheme.error) } - else { Text("Testing connection…").foregroundStyle(LitheTheme.secondaryText) } - Spacer(minLength: 0) - }.font(LitheTheme.uiFont(size: 11)).padding(.horizontal, 16).padding(.bottom, 8) - } - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack(spacing: LitheTheme.ActionButton.spacing) { - HStack(spacing: LitheTheme.ActionButton.spacing) { - Button("Test Connection") { testConnection() } - .buttonStyle(LitheBannerActionStyle(linkColor: LitheTheme.accent)) - .disabled(!canTestConnection || isTesting || isConnecting) - if testSucceeded { - Button { showsTestResult.toggle() } label: { - HStack(spacing: 6) { - LitheIDEAIcon(resourcePath: "expui/general/greenCheckmark.svg", size: 16, preservesOriginalColors: true) - Text(kind.displayName).foregroundStyle(LitheTheme.secondaryText) - } - } - .buttonStyle(.litheNoPress) - .accessibilityLabel("Connection test result") - } - } - .overlay { - if showsTestResult { - GeometryReader { anchor in - // Community BalloonPopupBuilderImpl defaults to a 500ms alpha animation. - LitheDropdownPopover(opensUpward: true, contentProvidesSurface: true, fadeInDuration: 0.5, isPresented: $showsTestResult) { - DatabaseConnectionResultView(summary: connectionTestSummary, pointerX: anchor.size.width / 2, onCopy: { showsTestResult = false }) - } - } - } - } - Spacer() - Button("Cancel") { cancel() } - .buttonStyle(LitheActionButtonStyle()) - .keyboardShortcut(.cancelAction) - Button { - connect() - } label: { - HStack(spacing: 6) { - if isConnecting { ProgressView().controlSize(.small) } - Text(isConnecting ? "Connecting…" : "Connect") - } - } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .keyboardShortcut(.defaultAction) - .disabled(connectionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !canTestConnection || isConnecting || isTesting) - } - .padding(.horizontal, 16) - .frame(height: 58) - .background(LitheCommitDialogStyle.background) - } - .onChange(of: kind) { newKind in - guard profile == nil else { return } - applyNewConnectionDefaults(kind) - } - .task { nameFocused = true } - .onAppear { - feature.errorMessage = nil - if let profile { - hasSavedPassword = feature.hasSavedPassword(for: profile) - } else { - applyNewConnectionDefaults(kind) - } - } - .onChange(of: candidate) { _ in cancelConnectionTest() } - .onChange(of: password) { _ in cancelConnectionTest() } - .onDisappear { cancelConnectionTest(); feature.errorMessage = nil } - .frame(width: 760, height: 580).background(LitheCommitDialogStyle.background) - } - - private func applyNewConnectionDefaults(_ kind: DatabaseKind) { - switch kind { - case .mysql: port = "3306"; username = ""; database = ""; path = "" - case .mariadb: port = "3306"; username = ""; database = ""; path = "" - case .postgresql: port = "5432"; username = ""; database = ""; path = "" - case .sqlite: path = ""; database = "" - case .sqlserver: port = "1433"; username = ""; database = "master"; path = "" - case .mongodb: port = "27017"; username = ""; database = "admin"; path = "" - case .redis: port = "6379"; username = ""; database = "0"; path = "" - case .nacos: port = "8848"; username = ""; database = ""; path = "/nacos" - } - } - - private var connectionName: String { - usesDefaultName ? Self.defaultConnectionName(kind: kind, host: host, database: database, path: path) : name - } - - static func defaultConnectionName(kind: DatabaseKind, host: String, database: String, path: String) -> String { - if kind == .sqlite { - let file = (path.trimmingCharacters(in: .whitespacesAndNewlines) as NSString).lastPathComponent - return file.isEmpty ? kind.displayName : file - } - var hostname = host.trimmingCharacters(in: .whitespacesAndNewlines) - // MongoDB accepts a URI in Host. Never copy URI credentials or query parameters into a display name. - if hostname.contains("://") { hostname = URLComponents(string: hostname)?.host ?? "" } - guard !hostname.isEmpty else { return kind.displayName } - return "\(database.trimmingCharacters(in: .whitespacesAndNewlines))@\(hostname)" - } - - private var candidate: DatabaseProfile { - var candidate = DatabaseProfile(id: profile?.id ?? draftID, name: connectionName, kind: kind, host: host, port: UInt16(port) ?? 0, username: username, database: database, path: path, ssl: ssl, group: "", folderID: folderID, colorHex: colorHex, readOnly: readOnly, productionProtection: productionProtection, maskSensitiveFields: maskSensitiveFields, sensitiveColumnPatterns: sensitiveColumnPatterns.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, caCertificatePath: ssl ? caCertificatePath : "", serverName: ssl ? serverName : "", sshHost: usesSSHTunnel ? sshHost : "", sshPort: usesSSHTunnel ? (UInt16(sshPort) ?? 22) : 0, sshUsername: usesSSHTunnel ? sshUsername : "", sshKeyPath: usesSSHTunnel ? sshKeyPath : "", sshLocalPort: usesSSHTunnel ? (UInt16(sshLocalPort) ?? 0) : 0, proxyURL: usesSSHTunnel ? proxyURL : "") - candidate.passwordPersistence = passwordPersistence - candidate.visibleDatabases = profile?.visibleDatabases - return candidate - } - - private var canTestConnection: Bool { - (kind == .sqlite ? !path.isEmpty : !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - && redisDatabaseValidationMessage == nil - } - - private var connectionTestSummary: String { - "DBMS: \(kind.displayName)\n\n" + String(format: AppLanguage(rawValue: locale.identifier)?.localizedString("Connection test duration: %lld ms") ?? String(localized: "Connection test duration: %lld ms", locale: locale), locale: locale, Int64(testDurationMilliseconds)) - } - - private func cancelConnectionTest() { - testTask?.cancel() - testTask = nil - isTesting = false - testSucceeded = false - showsTestResult = false - testError = nil - } - - private func testConnection() { - guard canTestConnection, !isTesting, !isConnecting else { return } - cancelConnectionTest() - isTesting = true - let candidate = candidate - let testPassword: String? = profile != nil && password.isEmpty ? nil : password - let started = ContinuousClock.now - testTask = Task { - do { - try await feature.testConnection(candidate, password: testPassword) - guard !Task.isCancelled else { return } - let elapsed = started.duration(to: .now).components - testDurationMilliseconds = Int(elapsed.seconds * 1_000 + elapsed.attoseconds / 1_000_000_000_000_000) - testSucceeded = true - showsTestResult = true - } catch { - guard !Task.isCancelled else { return } - testError = error.localizedDescription - } - isTesting = false - testTask = nil - } - } - - private func cancel() { - feature.errorMessage = nil - isPresented = false - } - - private func connect() { - guard !isConnecting, !isTesting else { return } - if let redisDatabaseValidationMessage { - feature.errorMessage = redisDatabaseValidationMessage - return - } - isConnecting = true - let candidate = candidate - let password = password - Task { - defer { isConnecting = false } - let saved = if profile == nil { - await feature.add(candidate, password: password) - } else { - await feature.update(candidate, password: password.isEmpty ? nil : password) - } - if saved { isPresented = false } - } - } - - private var redisDatabaseValidationMessage: String? { - guard kind == .redis else { return nil } - let value = database.trimmingCharacters(in: .whitespacesAndNewlines) - guard value.isEmpty || UInt32(value) != nil else { - return "Redis database index must be between 0 and 4294967295." - } - return nil - } -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift b/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift deleted file mode 100644 index deefea500..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift +++ /dev/null @@ -1,658 +0,0 @@ -import SwiftUI -import LitheDatabaseModule - -struct RedisWorkspaceView: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - @State private var pattern = "*" - @FocusState private var patternFocused: Bool - @FocusState private var detailField: String? - @State private var stringDraft = "" - @State private var hashDraft = "{}" - @State private var ttlDraft = "" - @State private var renameDraft = "" - @State private var pendingAction: RedisPendingAction? - @State private var showsWriteConfirmation = false - - private var profile: DatabaseProfile? { feature.selectedProfile } - - var body: some View { - VStack(spacing: 0) { - workspaceHeader - HStack(spacing: 0) { - keyBrowser - .frame(minWidth: 250, idealWidth: 300, maxWidth: 350) - Rectangle().fill(LitheTheme.divider).frame(width: 1) - keyDetail - } - } - .litheWorkbenchSurface(LitheTheme.editor) - .workbenchHoverTooltipScope() - .task(id: feature.selectedProfileID) { - await feature.loadRedisKeys(pattern: pattern) - } - .onChange(of: feature.redisIncludeSize) { _ in - Task { await feature.loadRedisKeys(pattern: pattern) } - } - .onChange(of: feature.redisSelectedKey) { detail in - guard let detail else { - stringDraft = ""; hashDraft = "{}"; ttlDraft = ""; renameDraft = "" - return - } - stringDraft = detail.stringValue ?? "" - ttlDraft = detail.ttl >= 0 ? String(detail.ttl) : "-1" - renameDraft = detail.key - let dictionary = Dictionary(uniqueKeysWithValues: detail.hashEntries.map { ($0.field, $0.value) }) - if let data = try? JSONSerialization.data(withJSONObject: dictionary, options: [.prettyPrinted, .sortedKeys]), - let text = String(data: data, encoding: .utf8) { - hashDraft = text - } else { - hashDraft = "{}" - } - } - .alert("Confirm Redis change", isPresented: $showsWriteConfirmation, presenting: pendingAction) { _ in - Button("Cancel", role: .cancel) { pendingAction = nil } - Button("Continue", role: .destructive) { - if let pendingAction { execute(pendingAction, confirmed: true) } - pendingAction = nil - } - } message: { action in - if profile?.productionProtection == true { - Text("This connection has production protection enabled. Confirm the Redis change before it is sent.") - } else if case .deleteKey = action { - Text("Deleting a Redis key cannot be undone.") - } - } - } - - private var workspaceHeader: some View { - LitheToolWindowHeader(title: "Redis Key Browser") { - if let profile { - Text("DB \(profile.database.isEmpty ? "0" : profile.database)") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - } - Toggle("Estimate size", isOn: Binding( - get: { feature.redisIncludeSize }, - set: { feature.redisIncludeSize = $0 } - )) - .toggleStyle(.checkbox) - .controlSize(.regular) - .font(LitheTheme.uiFont(size: 13)) - .workbenchHoverHelp(Text("Estimate key size during SCAN")) - Button { Task { await feature.loadRedisKeys(pattern: pattern) } } label: { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Rescan from the beginning")) - .accessibilityLabel("Rescan from the beginning") - .disabled(feature.isLoading || profile == nil) - } - .help("Incremental SCAN only — no full keyspace load") - } - - private var keyBrowser: some View { - VStack(spacing: 0) { - HStack(spacing: 6) { - LitheSearchField("Key pattern, e.g. user:*", text: $pattern, focus: $patternFocused) - .onSubmit { Task { await feature.loadRedisKeys(pattern: pattern) } } - Button { Task { await feature.loadRedisKeys(pattern: pattern) } } label: { - LitheIDEAIcon(resourcePath: "expui/general/search.svg", - size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Search keys with SCAN")) - .accessibilityLabel("Search keys with SCAN") - .disabled(feature.isLoading || profile == nil) - } - .padding(10) - if feature.redisKeys.isEmpty && feature.isLoading { - VStack(spacing: 9) { - ProgressView().controlSize(.small) - Text("Scanning keys…") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if feature.redisKeys.isEmpty { - LitheUnavailableView( - feature.errorMessage == nil ? "No matching keys" : "No keys loaded", - systemImage: "magnifyingglass", - description: Text(feature.errorMessage == nil - ? "No keys matched this pattern. Try a broader pattern to search again." - : "Retry the scan after fixing the connection or authentication settings.") - ) - .foregroundStyle(LitheTheme.secondaryText) - } else { - LitheTreeList( - feature.redisKeys, - selection: Binding( - get: { feature.redisSelectedKey?.key }, - set: { key in if let key { Task { await feature.loadRedisKey(key) } } }), multiline: true - ) { key in - HStack(spacing: 8) { - Image(systemName: redisTypeSymbol(key.type)) - .font(LitheTheme.uiFont(size: 10, weight: .medium)) - .foregroundStyle(redisTypeColor(key.type)) - .frame(width: 22, height: 22) - .background(redisTypeColor(key.type).opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - VStack(alignment: .leading, spacing: 2) { - Text(key.key).font(LitheTheme.uiFont(size: 11.5, weight: .medium)).lineLimit(1) - Text("\(key.type.uppercased()) · \(redisTTLText(key.ttl)) · \(redisSizeText(key.size))") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText).lineLimit( - 1) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8).frame(height: 40).frame(maxWidth: .infinity, alignment: .leading) - } - if feature.redisNextCursor != "0" { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - Button { - Task { await feature.loadRedisKeys(pattern: pattern, reset: false) } - } label: { - Label("Load next scan page", systemImage: "arrow.down.circle") - .font(LitheTheme.uiFont(size: 10.5, weight: .medium)) - .frame(maxWidth: .infinity).padding(.vertical, 9) - } - .buttonStyle(.litheNoPress).lithePointer() - .foregroundStyle(LitheTheme.accent) - .disabled(feature.isLoading) - } - } - } - .litheWorkbenchSurface(LitheTheme.sidebar) - } - - @ViewBuilder - private var keyDetail: some View { - if let detail = feature.redisSelectedKey { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 10) { - Image(systemName: redisTypeSymbol(detail.type)) - .foregroundStyle(redisTypeColor(detail.type)) - .frame(width: 28, height: 28) - .background(redisTypeColor(detail.type).opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 7)) - VStack(alignment: .leading, spacing: 3) { - Text(detail.key).font(LitheTheme.uiFont(size: 12.5, weight: .semibold)).textSelection(.enabled) - Text("\(detail.type.uppercased()) · \(redisSizeText(detail.size)) · \(redisTTLText(detail.ttl))") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Button { request(.deleteKey(detail.key)) } label: { LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton().help("Delete key") - .foregroundStyle(LitheTheme.error) - } - .padding(12) - .litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - - ScrollView { - VStack(alignment: .leading, spacing: 16) { - renameAndTTL(detail) - if detail.type == "string" { - stringEditor(detail) - } else if detail.type == "hash" { - hashEditor(detail) - } else { - LitheUnavailableView("Read-only in this first release", systemImage: "eye", description: Text("This key is a \(detail.type). String and Hash editing are available now; other Redis types remain safely inspectable from the key list.")) - .foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, minHeight: 170) - } - } - .padding(14) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - LitheUnavailableView("Select a Redis key", systemImage: "square.stack.3d.up", description: Text("Key values load only after you select one, keeping large Redis instances responsive.")) - .foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - private func renameAndTTL(_ detail: RedisKeyDetail) -> some View { - VStack(alignment: .leading, spacing: 8) { - specializedSectionTitle("Key settings") - HStack(spacing: 7) { - TextField("Key name", text: $renameDraft).focused($detailField, equals: "key").modifier(LitheCommitDialogInputStyle(focused: detailField == "key")) - Button("Rename") { request(.rename(detail.key, renameDraft)) } - .buttonStyle(LitheActionButtonStyle()) - .disabled(renameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameDraft == detail.key) - } - HStack(spacing: 7) { - TextField("TTL seconds (-1 = persistent)", text: $ttlDraft).focused($detailField, equals: "ttl").modifier(LitheCommitDialogInputStyle(focused: detailField == "ttl")).frame(maxWidth: 240) - Button("Set TTL") { - guard let ttl = Int64(ttlDraft.trimmingCharacters(in: .whitespacesAndNewlines)), ttl >= -1 else { - feature.errorMessage = "Redis TTL must be -1 or a non-negative number of seconds." - return - } - request(.setTTL(detail.key, ttl)) - } - .buttonStyle(LitheActionButtonStyle()) - } - } - } - - private func stringEditor(_ detail: RedisKeyDetail) -> some View { - VStack(alignment: .leading, spacing: 8) { - specializedSectionTitle("String value") - TextEditor(text: $stringDraft) - .font(LitheTheme.codeFont) - .scrollContentBackground(.hidden) - .padding(7).frame(minHeight: 230) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius).stroke(LitheTheme.panelBorder, lineWidth: 1) } - HStack { - Text("Saving preserves the existing TTL unless you set one above.") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - Spacer() - Button("Save value") { request(.saveString(detail.key, stringDraft, ttlForSave)) } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - } - } - } - - private func hashEditor(_ detail: RedisKeyDetail) -> some View { - VStack(alignment: .leading, spacing: 8) { - specializedSectionTitle("Hash fields") - Text("Edit the field/value mapping as a JSON object. Saving replaces this Hash while preserving its TTL.") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - TextEditor(text: $hashDraft) - .font(LitheTheme.codeFont) - .scrollContentBackground(.hidden) - .padding(7).frame(minHeight: 230) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius).stroke(LitheTheme.panelBorder, lineWidth: 1) } - HStack { - Spacer() - Button("Save hash") { request(.saveHash(detail.key, hashEntries)) } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - } - } - } - - private var ttlForSave: Int64? { - let value = ttlDraft.trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : Int64(value) - } - - private var hashEntries: [RedisHashEntry] { - guard let data = hashDraft.data(using: .utf8), - let values = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [] - } - return values.map { RedisHashEntry(field: $0.key, value: $0.value) }.sorted { $0.field < $1.field } - } - - private func request(_ action: RedisPendingAction) { - if case .saveHash = action, hashEntries.isEmpty, - hashDraft.trimmingCharacters(in: .whitespacesAndNewlines) != "{}" { - feature.errorMessage = "Redis Hash content must be a JSON object whose values are strings." - return - } - if profile?.productionProtection == true || isDestructive(action) { - pendingAction = action - showsWriteConfirmation = true - } else { - execute(action, confirmed: true) - } - } - - private func execute(_ action: RedisPendingAction, confirmed: Bool) { - Task { - switch action { - case let .saveString(key, value, ttl): - _ = await feature.saveRedisString(key: key, value: value, ttl: ttl, confirmed: confirmed) - case let .saveHash(key, entries): - _ = await feature.replaceRedisHash(key: key, entries: entries, confirmed: confirmed) - case let .setTTL(key, ttl): - _ = await feature.setRedisTTL(key: key, ttl: ttl, confirmed: confirmed) - case let .rename(key, newKey): - _ = await feature.renameRedisKey(key: key, newKey: newKey, confirmed: confirmed) - case let .deleteKey(key): - _ = await feature.deleteRedisKey(key: key, confirmed: confirmed) - } - } - } - - private func isDestructive(_ action: RedisPendingAction) -> Bool { - if case .deleteKey = action { return true } - return false - } -} - -private enum RedisPendingAction { - case saveString(String, String, Int64?) - case saveHash(String, [RedisHashEntry]) - case setTTL(String, Int64) - case rename(String, String) - case deleteKey(String) -} - -struct NacosWorkspaceView: View { - @EnvironmentObject private var feature: DatabaseFeatureModel - private enum SearchField: Hashable { case dataID, configGroup, service, serviceGroup } - @FocusState private var focusedSearch: SearchField? - @FocusState private var detailField: String? - @State private var section: NacosSection = .configs - @State private var dataIDSearch = "" - @State private var groupSearch = "" - @State private var serviceSearch = "" - @State private var serviceGroupSearch = "" - @State private var draftDataID = "" - @State private var draftGroup = "DEFAULT_GROUP" - @State private var draftType = "" - @State private var draftContent = "" - @State private var pendingAction: NacosPendingAction? - @State private var showsWriteConfirmation = false - - private var profile: DatabaseProfile? { feature.selectedProfile } - - var body: some View { - VStack(spacing: 0) { - workspaceHeader - Rectangle().fill(LitheTheme.divider).frame(height: 1) - Picker("Nacos workspace", selection: $section) { - Label("Configurations", systemImage: "doc.text").tag(NacosSection.configs) - Label("Services", systemImage: "network").tag(NacosSection.services) - } - .labelsHidden().pickerStyle(.segmented).frame(width: 250).padding(10) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - if let profile, feature.connectionStatuses[profile.id] == .failed, let error = feature.errorMessage { - specializedErrorBanner(message: error) { - Task { - await feature.loadNacosConfigs(dataId: dataIDSearch, group: groupSearch) - await feature.loadNacosServices(serviceName: serviceSearch, group: serviceGroupSearch) - } - } - } - if section == .configs { configurations } else { services } - } - .litheWorkbenchSurface(LitheTheme.editor) - .task(id: feature.selectedProfileID) { - await feature.loadNacosConfigs(dataId: dataIDSearch, group: groupSearch) - await feature.loadNacosServices(serviceName: serviceSearch, group: serviceGroupSearch) - } - .onChange(of: feature.nacosSelectedConfig) { detail in - guard let detail else { - draftDataID = "" - draftGroup = "DEFAULT_GROUP" - draftType = "" - draftContent = "" - return - } - draftDataID = detail.dataId; draftGroup = detail.group; draftType = detail.type ?? ""; draftContent = detail.content - } - .alert("Confirm Nacos configuration change", isPresented: $showsWriteConfirmation, presenting: pendingAction) { _ in - Button("Cancel", role: .cancel) { pendingAction = nil } - Button("Continue", role: .destructive) { - if let pendingAction { execute(pendingAction, confirmed: true) } - pendingAction = nil - } - } message: { action in - if profile?.productionProtection == true { - Text("This connection has production protection enabled. Confirm the configuration change before publishing it to Nacos.") - } else if case .delete = action { - Text("Deleting a Nacos configuration cannot be undone.") - } - } - } - - private var workspaceHeader: some View { - HStack(spacing: 10) { - DatabaseBrandIcon(kind: .nacos, size: 16) - .frame(width: 28, height: 28).background(Color.teal.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 7)) - VStack(alignment: .leading, spacing: 1) { - Text("Nacos Configuration & Services").font(LitheTheme.uiFont(size: 13)) - Text("Namespace \(profile?.database.isEmpty == false ? profile!.database : "public")") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Button { Task { await feature.loadNacosConfigs(dataId: dataIDSearch, group: groupSearch); await feature.loadNacosServices(serviceName: serviceSearch, group: serviceGroupSearch) } } label: { LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton().help("Refresh Nacos workspace") - .disabled(feature.isLoading || profile == nil) - } - .padding(.horizontal, 6).frame(height: LitheTheme.Metrics.toolWindowHeaderHeight).foregroundStyle(LitheTheme.primaryText).litheWorkbenchSurface(LitheTheme.toolHeader) - } - - private var configurations: some View { - HStack(spacing: 0) { - VStack(spacing: 0) { - HStack(spacing: 6) { - LitheSearchField("Data ID", text: $dataIDSearch, focus: $focusedSearch, equals: .dataID) - LitheSearchField("Group", text: $groupSearch, focus: $focusedSearch, equals: .configGroup) - Button { Task { await feature.loadNacosConfigs(dataId: dataIDSearch, group: groupSearch) } } label: { LitheIDEAIcon(resourcePath: "expui/general/search.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton() - } - .padding(10) - HStack { - Text("\(feature.nacosConfigTotalCount) configurations") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - Spacer() - Button("New") { - feature.nacosSelectedConfig = nil - draftDataID = ""; draftGroup = "DEFAULT_GROUP"; draftType = ""; draftContent = "" - } - .buttonStyle(LitheActionButtonStyle()) - } - .padding(.horizontal, 10).padding(.bottom, 8) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - LitheTreeList( - feature.nacosConfigs, - selection: Binding( - get: { - feature.nacosSelectedConfig.flatMap { selected in - feature.nacosConfigs.first { - $0.dataId == selected.dataId && $0.group == selected.group - }?.id - } - }, - set: { id in - if let config = feature.nacosConfigs.first(where: { $0.id == id }) { - Task { await feature.loadNacosConfig(dataId: config.dataId, group: config.group) } - } - }), multiline: true - ) { config in - VStack(alignment: .leading, spacing: 2) { - Text(config.dataId).font(LitheTheme.uiFont(size: 11.5, weight: .medium)).lineLimit(1) - Text("\(config.group)\(config.type.map { " · \($0)" } ?? "")") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) - } - .padding(.horizontal, 9).frame(height: 42).frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(minWidth: 285, idealWidth: 340, maxWidth: 390).litheWorkbenchSurface(LitheTheme.sidebar) - Rectangle().fill(LitheTheme.divider).frame(width: 1) - configEditor - } - } - - private var configEditor: some View { - VStack(spacing: 0) { - HStack(spacing: 8) { - TextField("Data ID", text: $draftDataID).focused($detailField, equals: "id").modifier(LitheCommitDialogInputStyle(focused: detailField == "id")) - TextField("Group", text: $draftGroup).focused($detailField, equals: "group").modifier(LitheCommitDialogInputStyle(focused: detailField == "group")).frame(width: 150) - TextField("Type (optional)", text: $draftType).focused($detailField, equals: "type").modifier(LitheCommitDialogInputStyle(focused: detailField == "type")).frame(width: 135) - Spacer() - if feature.nacosSelectedConfig != nil { - Button { request(.delete(draftDataID, draftGroup)) } label: { LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton().foregroundStyle(LitheTheme.error).help("Delete configuration") - } - } - .padding(12).litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - TextEditor(text: $draftContent) - .font(LitheTheme.codeFont).scrollContentBackground(.hidden) - .padding(12).litheWorkbenchSurface(LitheTheme.editor) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack { - Text(feature.nacosSelectedConfig == nil ? "Create a configuration" : "Edit configuration") - .font(LitheTheme.uiFont(size: 10)).foregroundStyle(LitheTheme.secondaryText) - Spacer() - Button("Publish configuration") { request(.publish(draftDataID, draftGroup, draftContent, draftType)) } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .disabled(draftDataID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || draftGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - .padding(.horizontal, 12).frame(height: 48).litheWorkbenchSurface(LitheTheme.toolHeader) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var services: some View { - HStack(spacing: 0) { - VStack(spacing: 0) { - HStack(spacing: 6) { - LitheSearchField("Service", text: $serviceSearch, focus: $focusedSearch, equals: .service) - LitheSearchField("Group", text: $serviceGroupSearch, focus: $focusedSearch, equals: .serviceGroup) - Button { Task { await feature.loadNacosServices(serviceName: serviceSearch, group: serviceGroupSearch) } } label: { LitheIDEAIcon(resourcePath: "expui/general/search.svg", size: 16, preservesOriginalColors: true) }.litheToolbarIconButton() - }.padding(10) - Text("\(feature.nacosServiceTotalCount) services") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 10).padding(.bottom, 8) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(feature.nacosServices) { service in - Button { Task { await feature.loadNacosInstances(serviceName: service.name, group: service.group) } } label: { - VStack(alignment: .leading, spacing: 2) { - Text(service.name).font(LitheTheme.uiFont(size: 11.5, weight: .medium)).lineLimit(1) - Text("\(service.group) · \(service.clusterCount) clusters").font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 9).frame(height: 42).frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.litheNoPress).lithePointer() - .litheRowHover(isActive: false, activeBackground: LitheTheme.subtleSelection) - } - }.padding(.vertical, 6) - } - } - .frame(minWidth: 285, idealWidth: 340, maxWidth: 390).litheWorkbenchSurface(LitheTheme.sidebar) - Rectangle().fill(LitheTheme.divider).frame(width: 1) - VStack(alignment: .leading, spacing: 0) { - HStack { - Label("Service instances", systemImage: "server.rack") - .font(LitheTheme.uiFont(size: 12, weight: .semibold)) - Spacer() - Text("\(feature.nacosInstances.count) instances").font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - }.padding(12).litheWorkbenchSurface(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - if feature.nacosInstances.isEmpty { - specializedEmptyState(symbol: "network", title: "Select a service", detail: "Choose a Nacos service to inspect its registered instances and health state.") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 5) { - ForEach(feature.nacosInstances) { instance in - HStack(spacing: 10) { - Circle().fill(instance.healthy ? Color.green : LitheTheme.error).frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 2) { - Text("\(instance.ip):\(instance.port)").font(LitheTheme.uiFont(size: 11.5, weight: .medium)).textSelection(.enabled) - Text("\(instance.clusterName ?? "DEFAULT") · \(instance.enabled ? "enabled" : "disabled") · \(instance.ephemeral ? "ephemeral" : "persistent")") - .font(LitheTheme.uiFont(size: 9.5)).foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Text(instance.healthy ? "Healthy" : "Unhealthy").font(LitheTheme.uiFont(size: 9.5, weight: .medium)).foregroundStyle(instance.healthy ? Color.green : LitheTheme.error) - } - .padding(10).background(LitheTheme.inputBackground.opacity(0.72)).clipShape(RoundedRectangle(cornerRadius: 7)) - } - }.padding(14) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - private func request(_ action: NacosPendingAction) { - if profile?.productionProtection == true || isDestructive(action) { - pendingAction = action; showsWriteConfirmation = true - } else { - execute(action, confirmed: true) - } - } - - private func execute(_ action: NacosPendingAction, confirmed: Bool) { - Task { - switch action { - case let .publish(dataID, group, content, type): - _ = await feature.publishNacosConfig(dataId: dataID, group: group, content: content, type: type.isEmpty ? nil : type, confirmed: confirmed) - case let .delete(dataID, group): - _ = await feature.deleteNacosConfig(dataId: dataID, group: group, confirmed: confirmed) - } - } - } - - private func isDestructive(_ action: NacosPendingAction) -> Bool { - if case .delete = action { return true } - return false - } -} - -private enum NacosSection: Hashable { case configs, services } -private enum NacosPendingAction { case publish(String, String, String, String), delete(String, String) } - -@ViewBuilder -private func specializedSectionTitle(_ title: LocalizedStringKey) -> some View { - Text(title).font(LitheTheme.uiFont(size: 13)).foregroundStyle(LitheTheme.primaryText) -} - -private func specializedEmptyState(symbol: String, title: LocalizedStringKey, detail: LocalizedStringKey) -> some View { - VStack(spacing: 9) { - Image(systemName: symbol).font(LitheTheme.uiFont(size: 25, weight: .light)).foregroundStyle(LitheTheme.tertiaryText) - Text(title).font(LitheTheme.uiFont(size: 12, weight: .semibold)).foregroundStyle(LitheTheme.secondaryText) - Text(detail).font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText).multilineTextAlignment(.center).frame(maxWidth: 340) - } - .padding(22) -} - -private func specializedErrorBanner(message: String, retry: @escaping () -> Void) -> some View { - LitheBanner(status: .error, layout: .editor) { - HStack(spacing: LitheTheme.Banner.actionGap) { - Text(message).lineLimit(3).textSelection(.enabled) - Spacer(minLength: 4) - Button(action: retry) { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .help("Retry") - } - } -} - -private func redisTypeSymbol(_ type: String) -> String { - switch type.lowercased() { - case "string": "text.alignleft" - case "hash": "curlybraces.square" - case "list": "list.number" - case "set": "circle.grid.2x2" - case "zset": "chart.bar" - case "stream": "water.waves" - default: "questionmark.square" - } -} - -private func redisTypeColor(_ type: String) -> Color { - switch type.lowercased() { - case "string": .blue - case "hash": .orange - case "list": .purple - case "set": .green - case "zset": .pink - case "stream": .teal - default: LitheTheme.secondaryText - } -} - -private func redisTTLText(_ ttl: Int64) -> String { - switch ttl { - case -1: "persistent" - case -2: "expired" - case 0...: "TTL \(ttl)s" - default: "TTL unknown" - } -} - -private func redisSizeText(_ size: Int64) -> String { - size < 0 ? "size not estimated" : "size \(size)" -} diff --git a/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift b/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift deleted file mode 100644 index e3e682408..000000000 --- a/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift +++ /dev/null @@ -1,1330 +0,0 @@ -import AppKit -import SwiftUI -import UniformTypeIdentifiers -import LitheDatabaseModule - -struct DatabaseTableView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var feature: DatabaseFeatureModel - @Environment(\.colorScheme) private var colorScheme - @Environment(\.controlActiveState) private var controlActiveState - @State private var gridActive = false - @State private var editingCell: CellKey? - @FocusState private var focusedFilter: UUID? - @FocusState private var focusedCell: CellKey? - private var drafts: [CellKey: DatabaseValue] { - get { feature.tableCellDrafts } - nonmutating set { feature.tableCellDrafts = newValue } - } - private var insertedRows: [DatabaseRow] { - get { feature.tableInsertedRows } - nonmutating set { feature.tableInsertedRows = newValue } - } - @State private var selectedRows: Set = [] - private var deletedRows: Set { - get { feature.tableDeletedRows } - nonmutating set { feature.tableDeletedRows = newValue } - } - private var applyFailed: Bool { - get { feature.tableApplyFailed } - nonmutating set { feature.tableApplyFailed = newValue } - } - @State private var exportDocument: DatabaseTransferDocument? - @State private var temporaryExportURL: URL? - @State private var exportFormat = DatabaseTransferFormat.csv - @State private var showsExporter = false - @State private var showsImporter = false - @State private var filterConditions: [DatabaseTableFilterDraft] = [.init()] - @State private var filterJoin = DatabaseFilterJoin.and - @State private var appliedFilters: [DatabaseFilter] = [] - @State private var showsFilterPopover = false - @State private var sortConditions: [DatabaseTableSortDraft] = [] - @State private var appliedSort: [DatabaseSort] = [] - @State private var showsSortPopover = false - @State private var jumpTargetColumn: String? - @State private var rowDetailsIndex: Int? - @State private var pasteAnchor: CellKey? - @State private var showsReplaceSheet = false - @State private var showsBatchUpdateSheet = false - @State private var showsBatchDeleteConfirmation = false - @State private var replaceColumn = "" - @State private var replaceText = "" - @State private var replacementText = "" - @State private var pendingImportData: Data? - @State private var pendingImportURL: URL? - @State private var pendingImportFormat: DatabaseTransferFormat? - @State private var showsProtectedImportConfirmation = false - @State private var showsProtectedTableChangeConfirmation = false - - var body: some View { - VStack(spacing: 0) { - if feature.selectedProfile?.kind.isSQLDatabase == true || feature.selectedTable != nil { - dataActionBar - } - switch feature.workspaceSection { - case .structure where feature.selectedProfile?.kind.isSQLDatabase == true: - DatabaseStructureView() - case .history where feature.selectedProfile?.kind.isSQLDatabase == true: - DatabaseHistoryView() - default: - if feature.selectedTable != nil { queryClauseBar } - if feature.selectedTable == nil { - DatabaseTableEmptyState(hasConnection: feature.selectedProfile != nil) - } else if feature.columns.isEmpty && !feature.isLoading { - DatabaseTableEmptyState(hasConnection: true, hasColumns: false) - } else { - grid - } - } - } - .disabled(feature.isApplyingTableEdits) - .onChange(of: feature.isApplyingTableEdits) { applying in - if applying { editingCell = nil; focusedCell = nil } - } - .litheWorkbenchSurface(LitheTheme.DatabaseGrid.background) - .workbenchHoverTooltipScope() - .onChange(of: focusedCell) { cell in - if let cell { pasteAnchor = cell } - } - .onChange(of: gridActive) { active in - if !active { editingCell = nil } - } - .onChange(of: feature.selectedTable) { _ in - discard() - focusedCell = nil - editingCell = nil - appliedFilters = [] - appliedSort = [] - sortConditions = [] - filterJoin = .and - filterConditions = [.init()] - } - .onChange(of: feature.columns) { columns in - if filterConditions.count == 1, filterConditions[0].column.isEmpty { - filterConditions[0].column = columns.first ?? "" - } - } - .background(LitheCenteredPopup(isPresented: $showsReplaceSheet, dialogTitle: String(localized: "Replace Values in Current Page")) { - DatabaseReplaceSheet( - columns: feature.columns, - column: $replaceColumn, - searchText: $replaceText, - replacementText: $replacementText, - onReplace: replaceCurrentPage, - onCancel: { showsReplaceSheet = false } - ) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - }) - .background(LitheCenteredPopup(isPresented: $showsBatchUpdateSheet, dialogTitle: String(localized: "Batch Edit Rows")) { - DatabaseBatchUpdateSheet( - columns: feature.columns, - selectedCount: selectedRows.count, - onApply: applyBatchUpdate, - onCancel: { showsBatchUpdateSheet = false } - ) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - }) - .background(LitheCenteredPopup(isPresented: Binding( - get: { rowDetailsIndex != nil }, - set: { if !$0 { rowDetailsIndex = nil } } - ), dialogTitle: String(localized: "Row Details")) { - if let rowDetailsIndex, feature.rows.indices.contains(rowDetailsIndex) { - DatabaseRowDetailsSheet( - rowNumber: rowDetailsIndex + 1, - columns: feature.columns, - row: feature.rows[rowDetailsIndex], - onCancel: { self.rowDetailsIndex = nil } - ) - .environment(\.locale, model.settings.language.locale) - .id(model.settings.language) - } - }) - .fileExporter(isPresented: $showsExporter, document: exportDocument, contentType: exportFormat.contentType, defaultFilename: exportFilename) { result in - if case let .failure(error) = result { feature.errorMessage = error.localizedDescription } - if let temporaryExportURL { feature.removeTemporaryFile(temporaryExportURL) } - temporaryExportURL = nil - exportDocument = nil - } - .fileImporter(isPresented: $showsImporter, allowedContentTypes: [.commaSeparatedText, .json, .sql]) { result in - importFile(result) - } - .confirmationDialog( - "Delete selected rows?", - isPresented: $showsBatchDeleteConfirmation, - titleVisibility: .visible - ) { - Button("Mark rows for deletion", role: .destructive) { - markSelectedForDeletion() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("The selected rows will be marked for deletion. Choose Apply to commit this change.") - } - .confirmationDialog( - "Confirm Database Import", - isPresented: $showsProtectedImportConfirmation, - titleVisibility: .visible - ) { - Button("Import and Modify Database", role: .destructive) { - startPendingImport(confirmed: true) - } - Button("Cancel", role: .cancel) { - discardPendingImport() - } - } message: { - DatabaseLocalization.text(importConfirmationMessage) - } - .confirmationDialog( - "Confirm Table Changes", - isPresented: $showsProtectedTableChangeConfirmation, - titleVisibility: .visible - ) { - Button("Apply Changes and Delete Rows", role: .destructive) { - performApply(confirmed: true) - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This protected connection will delete selected rows. The other pending table edits will be applied in the same transaction.") - } - } - - private var queryClauseBar: some View { - HStack(spacing: 0) { - clausePill( - title: Text(verbatim: appliedFilters.isEmpty ? "WHERE" : "WHERE · \(appliedFilters.count)"), - icon: "expui/general/filter.svg", - active: !appliedFilters.isEmpty - ) { showsFilterPopover.toggle() } - .frame(maxWidth: .infinity, alignment: .leading) - .litheDropdown(isPresented: $showsFilterPopover) { filterPopover } - Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth) - clausePill(title: Text(verbatim: sortClauseTitle), icon: "expui/general/sortBy.svg", - active: !appliedSort.isEmpty) { showsSortPopover.toggle() } - .frame(maxWidth: .infinity, alignment: .leading) - .litheDropdown(isPresented: $showsSortPopover) { sortPopover } - if !appliedFilters.isEmpty || !appliedSort.isEmpty { - Button("Clear query") { clearQuery() } - .buttonStyle(.litheNoPress) - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 8) - } - } - .frame(height: 30) - .litheWorkbenchSurface(LitheTheme.editor) - .overlay(alignment: .bottom) { Rectangle().fill(gridBorder).frame(height: LitheTheme.DatabaseGrid.borderWidth) } - } - - private var dataActionBar: some View { - HStack(spacing: 2) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - if feature.selectedProfile?.kind.isSQLDatabase == true { - ForEach(DatabaseWorkspaceSection.allCases) { section in - Button { - if section == .sql, let profileID = feature.selectedProfileID { - if let queryID = feature.selectedSQLTabID, - model.editorTabItems.contains(.databaseQuery(profileID: profileID, queryID: queryID)) { - model.selectDatabaseWorkspace(profileID: profileID, queryID: queryID) - } else { - model.openDatabaseQuery(profileID: profileID) - } - } else { - feature.workspaceSection = section - } - } label: { - LitheIDEAIcon(resourcePath: section.iconPath, size: 16, preservesOriginalColors: true) - } - .buttonStyle(LitheIconButtonStyle( - size: LitheTheme.Metrics.toolbarIconButtonSize, - isSelected: feature.workspaceSection == section - )) - .workbenchHoverHelp(Text(section.titleKey)) - .accessibilityLabel(Text(section.titleKey)) - .accessibilityValue(feature.workspaceSection == section ? Text("Selected") : Text("Not selected")) - } - toolbarDivider - } - HStack(spacing: 2) { - Button { refreshTable() } label: { - LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Refresh table data")) - .accessibilityLabel("Refresh table data") - toolbarDivider - Button { insertedRows.append([:]) } label: { - LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Add Row")) - .accessibilityLabel("Add Row") - .disabled(feature.isLoading || feature.columns.isEmpty || feature.selectedProfile?.readOnly == true) - Button { apply() } label: { - LitheIDEAIcon(resourcePath: "expui/general/upload.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Apply")) - .accessibilityLabel("Apply") - .disabled(!hasChanges || feature.isLoading || feature.selectedProfile?.readOnly == true) - Button { discard() } label: { - LitheIDEAIcon(resourcePath: "expui/general/reset.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Discard")) - .accessibilityLabel("Discard") - .disabled(!hasChanges) - toolbarDivider - LitheMenu { - LitheContextMenuItem.submenu("Jump to Column", items: feature.columns.map { column in - .action(column) { jumpTargetColumn = column } - }) - LitheContextMenuItem.action("Row details") { rowDetailsIndex = selectedRows.first } - .disabled(selectedRows.count != 1) - LitheContextMenuItem.separator - LitheContextMenuItem.action("Import CSV…") { importFormat = .csv; showsImporter = true } - .disabled(feature.selectedProfile?.readOnly == true || feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.action("Import JSON…") { importFormat = .json; showsImporter = true } - .disabled(feature.selectedProfile?.readOnly == true || feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.action("Restore SQL Backup…") { importFormat = .sql; showsImporter = true } - .disabled(feature.selectedProfile?.readOnly == true || feature.selectedProfile?.kind == .sqlserver || feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.separator - LitheContextMenuItem.action("Export Table as CSV…") { export(.csv) } - .disabled(feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.action("Export Table as JSON…") { export(.json) } - .disabled(feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.action("Back Up Database as SQL…") { export(.sql) } - .disabled(feature.selectedProfile?.kind == .sqlserver || feature.selectedProfile?.kind == .mongodb) - LitheContextMenuItem.separator - LitheContextMenuItem.action("Replace in Current Page…") { showsReplaceSheet = true } - .disabled(feature.rows.isEmpty) - LitheContextMenuItem.action("Batch Edit") { showsBatchUpdateSheet = true } - .disabled(selectedRows.isEmpty || feature.selectedProfile?.readOnly == true) - LitheContextMenuItem.action("Delete Selected") { showsBatchDeleteConfirmation = true } - .disabled(selectedRows.isEmpty || feature.selectedProfile?.readOnly == true) - } label: { - LitheIDEAIcon(resourcePath: "expui/general/moreHorizontal.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - .workbenchHoverHelp(Text("Data Tools")) - .accessibilityLabel("Data Tools") - if !selectedRows.isEmpty { - Text("\(selectedRows.count) selected") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.secondaryText) - } - } - .disabled(feature.selectedTable == nil || feature.workspaceSection != .data) - } - } - Spacer(minLength: 4) - LitheMenu { - LitheContextMenuItem.action("Copy Selected Rows as TSV") { copySelectedRowsAsTSV() } - .disabled(selectedRows.isEmpty) - LitheContextMenuItem.action("Paste TSV from Clipboard") { pasteFromClipboard() } - } label: { - HStack(spacing: 2) { - Text("TSV").font(LitheTheme.uiFont(size: 12)) - LitheIDEAIcon(resourcePath: "expui/general/chevronDown.svg", size: 16, preservesOriginalColors: true) - } - .padding(.horizontal, 4) - } - .litheToolbarIconButton(isIconOnly: false) - .fixedSize() - .accessibilityLabel("TSV") - .disabled(feature.selectedTable == nil || feature.workspaceSection != .data) - toolbarDivider - Button { previousPage() } label: { - LitheIDEAIcon(resourcePath: "expui/general/chevronLeft.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("Previous page")) - .accessibilityLabel("Previous page") - .disabled(feature.workspaceSection != .data || feature.selectedTable == nil || feature.currentOffset == 0) - Text(pageLabel).font(LitheTheme.uiFont(size: 12)).foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1).fixedSize() - Button { nextPage() } label: { - LitheIDEAIcon(resourcePath: "expui/general/chevronRight.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton().workbenchHoverHelp(Text("Next page")) - .accessibilityLabel("Next page") - .disabled(feature.workspaceSection != .data || feature.selectedTable == nil || feature.currentOffset + feature.rows.count >= feature.totalRows) - } - .padding(.horizontal, 6) - .frame(height: LitheTheme.Metrics.toolWindowHeaderHeight) - .litheWorkbenchSurface(LitheTheme.editor) - } - - private var filterPopover: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("WHERE").font(LitheTheme.codeFont) - LitheSettingsSegmentedControl(selection: $filterJoin, options: [.and, .or], width: 104) { - $0 == .and ? "AND" : "OR" - } - Spacer() - Button { filterConditions.append(.init(column: feature.columns.first ?? "")) } label: { - HStack(spacing: 4) { - LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) - Text("Add Condition") - } - } - .litheToolbarIconButton(isIconOnly: false) - } - ForEach($filterConditions) { $condition in - HStack(spacing: 8) { - Toggle("Enable condition", isOn: $condition.isEnabled) - .toggleStyle(.checkbox).labelsHidden() - .help(condition.isEnabled ? "Disable condition" : "Enable condition") - LitheSettingsSelect(selection: $condition.column, options: feature.columns, width: 150, accessibilityLabel: "Column", title: { $0 }, localizesTitles: false) - .frame(width: 150) - LitheSettingsSelect(selection: $condition.operator, options: DatabaseFilterOperator.allCases, width: 130, accessibilityLabel: "Operator", title: { $0.menuTitle }) - .frame(width: 130) - TextField("Value", text: $condition.value) - .focused($focusedFilter, equals: condition.id) - .modifier(LitheCommitDialogInputStyle(focused: focusedFilter == condition.id)) - .frame(width: 170) - .disabled(condition.operator == .isNull || condition.operator == .isNotNull) - Button(role: .destructive) { filterConditions.removeAll { $0.id == condition.id } } label: { - LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16, preservesOriginalColors: true) - } - .litheToolbarIconButton() - } - } - HStack(spacing: LitheTheme.ActionButton.spacing) { - Button("Clear Filters") { clearFilters() }.buttonStyle(LitheActionButtonStyle()) - Spacer() - Button("Reset Conditions") { filterConditions = [.init(column: feature.columns.first ?? "")] } - .buttonStyle(LitheActionButtonStyle()) - Button("Apply Filter") { applyFilter(); showsFilterPopover = false } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - } - } - .padding(16) - .frame(width: 630) - } - - private var sortPopover: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("ORDER BY").font(LitheTheme.uiFont(size: 13, weight: .semibold, design: .monospaced)) - Spacer() - Button { sortConditions.append(.init(column: feature.columns.first ?? "")) } label: { - HStack(spacing: 4) { - LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) - Text("Add Sort") - } - } - .litheToolbarIconButton(isIconOnly: false) - } - if sortConditions.isEmpty { - Text("No sorting") - .font(LitheTheme.uiFont(size: 11)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, minHeight: 42) - } - ForEach(Array(sortConditions.indices), id: \.self) { index in - HStack(spacing: 8) { - Text("\(index + 1)").font(LitheTheme.uiFont(size: 10, design: .monospaced)).foregroundStyle(LitheTheme.tertiaryText).frame(width: 18) - LitheSettingsSelect(selection: $sortConditions[index].column, options: feature.columns, width: 190, accessibilityLabel: "Column", title: { $0 }, localizesTitles: false) - .frame(width: 190) - LitheSettingsSelect(selection: $sortConditions[index].descending, options: [false, true], width: 120, accessibilityLabel: "Direction", title: { $0 ? "Descending" : "Ascending" }) - .frame(width: 120) - Button { moveSort(from: index, by: -1) } label: { LitheIDEAIcon(resourcePath: "general/arrowUp.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton().help("Move up").disabled(index == 0) - Button { moveSort(from: index, by: 1) } label: { LitheIDEAIcon(resourcePath: "general/arrowDown.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton().help("Move down").disabled(index == sortConditions.count - 1) - Button(role: .destructive) { sortConditions.remove(at: index) } label: { LitheIDEAIcon(resourcePath: "expui/general/delete.svg", size: 16, preservesOriginalColors: true) } - .litheToolbarIconButton() - } - } - HStack(spacing: LitheTheme.ActionButton.spacing) { - Button("Clear Sorting") { clearSorting() }.buttonStyle(LitheActionButtonStyle()) - Spacer() - Button("Apply Sorting") { applySorting(); showsSortPopover = false } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - } - } - .padding(16) - .frame(width: 520) - } - - private func clausePill(title: Text, icon: String, active: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 6) { - LitheIDEAIcon(resourcePath: icon, size: LitheTheme.Metrics.toolbarIconSize, - preservesOriginalColors: true) - title.font(LitheTheme.codeFont) - Spacer(minLength: 0) - } - .foregroundStyle(active ? LitheTheme.DatabaseConsole.keyword : LitheTheme.DatabaseGrid.clausePlaceholder) - .padding(.horizontal, 6) - .padding(.top, 6).padding(.bottom, 5) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - } - - private var toolbarDivider: some View { - Rectangle().fill(LitheTheme.ActionToolbar.separator) - .frame(width: 1, height: 17).frame(width: 7, height: 24) - } - - private var grid: some View { - GeometryReader { geometry in - let widths = columnWidths(availableWidth: geometry.size.width) - ScrollViewReader { proxy in - ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - ForEach(0..<(feature.rows.count + insertedRows.count), id: \.self) { index in - if index < feature.rows.count { - rowView(index: index, row: feature.rows[index], columnWidths: widths) - } else { - insertedRowView(index: index - feature.rows.count, columnWidths: widths) - } - } - } header: { header(columnWidths: widths) } - } - .frame( - minWidth: max(geometry.size.width, widths.values.reduce(0, +) + selectionColumnWidth), - minHeight: geometry.size.height, - alignment: .topLeading - ) - } - .litheScrollViewChrome() - .background(LitheToolWindowActivityTracker(isActive: $gridActive)) - .onChange(of: insertedRows.count) { count in - guard count > 0, let column = feature.columns.first else { return } - let row = feature.rows.count + count - 1 - proxy.scrollTo(row, anchor: .bottom) - editingCell = nil - pasteAnchor = CellKey(row: row, column: column) - focusedCell = pasteAnchor - } - .onChange(of: jumpTargetColumn) { column in - guard let column else { return } - withAnimation { proxy.scrollTo("column-\(column)", anchor: .center) } - jumpTargetColumn = nil - } - } - } - } - - private var gridBorder: Color { LitheTheme.DatabaseGrid.border } - - private func isHighlightedRow(_ row: Int) -> Bool { - selectedRows.contains(row) || pasteAnchor?.row == row - } - - private func mutationBackground(_ color: Color, row: Int) -> Color { - LitheTheme.DatabaseGrid.mutationBackground(color, highlighted: isHighlightedRow(row)) - } - - private func cellTextAlignment(_ value: DatabaseValue?) -> TextAlignment { - switch value { - case .integer, .number, .decimal: return .trailing - default: return .leading - } - } - - private func header(columnWidths: [String: CGFloat]) -> some View { - HStack(spacing: 0) { - Button { toggleAllRows() } label: { - HStack(spacing: 7) { - Text("#") - } - .frame(width: selectionColumnWidth, height: 25) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .help(allRowsSelected ? "Deselect all rows on this page" : "Select all rows on this page") - .overlay(alignment: .trailing) { - Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth).allowsHitTesting(false) - } - ForEach(feature.columns, id: \.self) { column in - HStack(spacing: 4) { - LitheIDEAIcon(resourcePath: feature.primaryKeyColumns.contains(column) - ? "database/expui/columnGoldKey.svg" : "database/expui/column.svg", - size: LitheTheme.Tree.iconSize, - preservesOriginalColors: true) - Text(column) - .lineLimit(1) - Spacer(minLength: 4) - if let index = appliedSort.firstIndex(where: { $0.column == column }), appliedSort.count > 1 { - Text("\(index + 1)") - .font(LitheTheme.uiFont(size: 12)) - .foregroundStyle(LitheTheme.tertiaryText) - } - LitheMenu { - LitheContextMenuItem.action("Ascending", icon: AnyView(LitheIDEAIcon(resourcePath: "general/arrowUp.svg", size: 16, preservesOriginalColors: true))) { - setSort(column: column, descending: false) - } - LitheContextMenuItem.action("Descending", icon: AnyView(LitheIDEAIcon(resourcePath: "general/arrowDown.svg", size: 16, preservesOriginalColors: true))) { - setSort(column: column, descending: true) - } - } label: { - LitheIDEAIcon(resourcePath: sortIcon(for: column), size: 16, preservesOriginalColors: true) - .foregroundStyle(isSorted(column) ? LitheTheme.accent : LitheTheme.tertiaryText) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .fixedSize() - .help("Sort column") - } - .font(LitheTheme.codeFont) - .padding(.leading, 4) - .padding(.trailing, 3) - .frame(width: columnWidths[column] ?? 40, height: 25, alignment: .leading) - .id("column-\(column)") - .background(!selectedRows.isEmpty || pasteAnchor?.column == column - ? LitheTheme.DatabaseGrid.currentRowBackground : Color.clear) - .overlay(alignment: .trailing) { - Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth) - } - } - } - .font(LitheTheme.codeFont) - .foregroundStyle(LitheTheme.DatabaseGrid.text) - .background(LitheTheme.DatabaseGrid.headerBackground) - .overlay(alignment: .bottom) { - Rectangle().fill(gridBorder).frame(height: LitheTheme.DatabaseGrid.borderWidth) - } - } - - private func rowView(index: Int, row: DatabaseRow, columnWidths: [String: CGFloat]) -> some View { - HStack(spacing: 0) { - Button { toggleSelection(index) } label: { - HStack(spacing: 7) { - Text("\(index + 1)") - .font(LitheTheme.codeFont) - .foregroundStyle(isHighlightedRow(index) ? LitheTheme.DatabaseGrid.currentRowNumber : LitheTheme.DatabaseGrid.rowNumber) - .monospacedDigit() - } - .frame(width: selectionColumnWidth, height: LitheTheme.DatabaseGrid.rowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .accessibilityValue(selectedRows.contains(index) ? Text("Selected") : Text("Not selected")) - .background(deletedRows.contains(index) - ? mutationBackground(applyFailed ? LitheTheme.DatabaseGrid.failedMutation : LitheTheme.Diff.deleted, row: index) - : isHighlightedRow(index) ? LitheTheme.DatabaseGrid.currentRowBackground : Color.clear) - .disabled(deletedRows.contains(index)) - .litheContextMenu { rowContextMenu(index: index) } - .overlay(alignment: .trailing) { - Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth).allowsHitTesting(false) - } - ForEach(feature.columns, id: \.self) { column in - let key = CellKey(row: index, column: column) - Group { - if editingCell == key { - TextField("", text: binding(row: index, column: column, original: row[column])) - .textFieldStyle(.plain) - .litheFocusEffectHidden() - .focused($focusedCell, equals: key) - // Request focus after the old focusable text leaves the key-view loop. - .task { - focusedCell = nil - await Task.yield() - guard !Task.isCancelled else { return } - focusedCell = key - } - .onSubmit { editingCell = nil } - } else { - Text(display(drafts[key] ?? row[column])) - .lineLimit(1).truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: cellTextAlignment(row[column]) == .trailing ? .trailing : .leading) - .focusable() - .litheFocusEffectHidden() - .focused($focusedCell, equals: key) - } - } - .font(LitheTheme.codeFont).foregroundStyle(LitheTheme.DatabaseGrid.text) - .multilineTextAlignment(cellTextAlignment(row[column])) - .padding(.horizontal, 3) - .frame(width: columnWidths[column] ?? 40, height: LitheTheme.DatabaseGrid.rowHeight) - .background((pasteAnchor == key || selectedRows.contains(index)) && editingCell != key - ? (gridActive && controlActiveState == .key ? LitheTheme.DatabaseGrid.selected : LitheTheme.DatabaseGrid.inactiveSelected) - : deletedRows.contains(index) || drafts[key] == nil ? Color.clear - : mutationBackground(applyFailed ? LitheTheme.DatabaseGrid.failedMutation : LitheTheme.Diff.modified, row: index)) - .contentShape(Rectangle()) - .overlay { - if editingCell != key { - DatabaseGridCellClickTarget { clickCount in - selectedRows = [] - pasteAnchor = key - focusedCell = key - editingCell = clickCount >= 2 && !feature.isApplyingTableEdits && feature.selectedProfile?.readOnly != true && !deletedRows.contains(index) ? key : nil - } - } - } - .overlay(alignment: .trailing) { Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth) } - .overlay { - if editingCell == key, gridActive, controlActiveState == .key { - Rectangle().strokeBorder(LitheTheme.DatabaseGrid.editingBorder, - lineWidth: LitheTheme.DatabaseGrid.editingBorderWidth) - .padding(.trailing, LitheTheme.DatabaseGrid.borderWidth).padding(.bottom, LitheTheme.DatabaseGrid.borderWidth) - .allowsHitTesting(false) - } - } - .litheContextMenu { cellContextMenu(row: index, column: column) } - } - } - .background(deletedRows.contains(index) - ? mutationBackground(applyFailed ? LitheTheme.DatabaseGrid.failedMutation : LitheTheme.Diff.deleted, row: index) - : isHighlightedRow(index) ? LitheTheme.DatabaseGrid.currentRowBackground : Color.clear) - .contentShape(Rectangle()) - .overlay(alignment: .bottom) { - Rectangle().fill(gridBorder).frame(height: LitheTheme.DatabaseGrid.borderWidth) - } - } - - private func insertedRowView(index: Int, columnWidths: [String: CGFloat]) -> some View { - HStack(spacing: 0) { - Text("\(feature.rows.count + index + 1)") - .font(LitheTheme.codeFont) - .foregroundStyle(isHighlightedRow(feature.rows.count + index) ? LitheTheme.DatabaseGrid.currentRowNumber : LitheTheme.DatabaseGrid.rowNumber) - .frame(width: selectionColumnWidth, height: LitheTheme.DatabaseGrid.rowHeight) - .overlay(alignment: .trailing) { - Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth).allowsHitTesting(false) - } - ForEach(feature.columns, id: \.self) { column in - let key = CellKey(row: feature.rows.count + index, column: column) - let value = insertedRows[index][column] - Group { - if editingCell == key { - TextField("", text: insertedBinding(row: index, column: column)) - .textFieldStyle(.plain).litheFocusEffectHidden() - .focused($focusedCell, equals: key) - .task { - focusedCell = nil - await Task.yield() - guard !Task.isCancelled else { return } - focusedCell = key - } - .onSubmit { editingCell = nil } - } else { - Text(value == nil || value == .null ? "" : display(value)) - .lineLimit(1).truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - .focusable().litheFocusEffectHidden() - .focused($focusedCell, equals: key) - } - } - .font(LitheTheme.codeFont) - .foregroundStyle(value == nil || value == .null ? LitheTheme.DatabaseGrid.nullText : LitheTheme.DatabaseGrid.text) - .padding(.horizontal, 3) - .frame(width: columnWidths[column] ?? 40, height: LitheTheme.DatabaseGrid.rowHeight) - .background(pasteAnchor == key && editingCell != key - ? (gridActive && controlActiveState == .key ? LitheTheme.DatabaseGrid.selected : LitheTheme.DatabaseGrid.inactiveSelected) - : Color.clear) - .contentShape(Rectangle()) - .overlay { - if editingCell != key { - DatabaseGridCellClickTarget { clickCount in - selectedRows = [] - pasteAnchor = key - focusedCell = key - editingCell = clickCount >= 2 && !feature.isApplyingTableEdits && feature.selectedProfile?.readOnly != true ? key : nil - } - } - } - .overlay(alignment: .trailing) { Rectangle().fill(gridBorder).frame(width: LitheTheme.DatabaseGrid.borderWidth) } - .overlay { - if editingCell == key, gridActive, controlActiveState == .key { - Rectangle().strokeBorder(LitheTheme.DatabaseGrid.editingBorder, - lineWidth: LitheTheme.DatabaseGrid.editingBorderWidth) - .padding(.trailing, LitheTheme.DatabaseGrid.borderWidth).padding(.bottom, LitheTheme.DatabaseGrid.borderWidth) - .allowsHitTesting(false) - } - } - .litheContextMenu { - var items: [LitheContextMenuItem] = [] - items.append(.action("Set NULL", action: { insertedRows[index][column] = .null })) - items.append(.action("Use Column Default", action: { insertedRows[index].removeValue(forKey: column) })) - return items - } - } - } - .background(mutationBackground(applyFailed ? LitheTheme.DatabaseGrid.failedMutation : LitheTheme.Diff.inserted, - row: feature.rows.count + index)) - .overlay(alignment: .bottom) { - Rectangle().fill(gridBorder).frame(height: LitheTheme.DatabaseGrid.borderWidth) - } - } - - private func rowContextMenu(index: Int) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("Row Details", action: { rowDetailsIndex = index })) - items.append(.separator) - items.append(.action("Delete Row", role: .destructive, isEnabled: !(feature.selectedProfile?.readOnly == true), action: { - deletedRows.insert(index) - selectedRows.remove(index) - })) - return items - } - - private func cellContextMenu(row: Int, column: String) -> [LitheContextMenuItem] { - var items: [LitheContextMenuItem] = [] - items.append(.action("Set NULL", isEnabled: !(feature.selectedProfile?.readOnly == true), action: { setNull(row: row, column: column) })) - items.append(.action("Set Empty String", isEnabled: !(feature.selectedProfile?.readOnly == true), action: { setEmptyString(row: row, column: column) })) - items.append(.separator) - items.append(contentsOf: rowContextMenu(index: row)) - return items - } - - private func columnWidths(availableWidth: CGFloat) -> [String: CGFloat] { - // ponytail: sample 100 loaded rows; persistent user-resized widths can replace this. - LitheTheme.DatabaseGrid.columnWidths(feature.columns, availableWidth: availableWidth, - rowHeaderWidth: selectionColumnWidth) { column in - feature.rows.prefix(100).map { display($0[column]) } - } - } - - private var selectionColumnWidth: CGFloat { - LitheTheme.DatabaseGrid.rowHeaderWidth(rowCount: feature.rows.count + insertedRows.count) - } - private var selectableRowIndexes: Set { - Set(feature.rows.indices).subtracting(deletedRows) - } - private var allRowsSelected: Bool { - !selectableRowIndexes.isEmpty && selectedRows == selectableRowIndexes - } - private var hasChanges: Bool { !drafts.isEmpty || !insertedRows.isEmpty || !deletedRows.isEmpty } - private func toggleSelection(_ index: Int) { - editingCell = nil - if selectedRows.contains(index) { selectedRows.remove(index) } else { selectedRows.insert(index) } - } - private func toggleAllRows() { - editingCell = nil - if allRowsSelected { selectedRows = [] } - else { selectedRows = selectableRowIndexes } - } - private func markSelectedForDeletion() { deletedRows.formUnion(selectedRows); selectedRows = [] } - private func discard() { drafts = [:]; insertedRows = []; selectedRows = []; deletedRows = []; pasteAnchor = nil; editingCell = nil; focusedCell = nil; applyFailed = false } - - private func binding(row: Int, column: String, original: DatabaseValue?) -> Binding { - let key = CellKey(row: row, column: column) - let originalText = display(original) - return Binding(get: { drafts[key].map(display) ?? originalText }, set: { value in - if value == originalText { drafts.removeValue(forKey: key) } else { drafts[key] = .string(value) } - }) - } - - private func insertedBinding(row: Int, column: String) -> Binding { - Binding(get: { - guard insertedRows.indices.contains(row) else { return "" } - let value = insertedRows[row][column] - if case let .string(text) = value { return text } - return value == .null ? "" : value.map(display) ?? "" - }, set: { - guard insertedRows.indices.contains(row) else { return } - if $0.isEmpty, insertedRows[row][column] == nil || insertedRows[row][column] == .null { return } - insertedRows[row][column] = .string($0) - }) - } - - private func setNull(row: Int, column: String) { - let key = CellKey(row: row, column: column) - if feature.rows[row][column] == .null { drafts.removeValue(forKey: key) } - else { drafts[key] = .null } - } - - private func setEmptyString(row: Int, column: String) { - let key = CellKey(row: row, column: column) - if feature.rows[row][column] == .string("") { drafts.removeValue(forKey: key) } - else { drafts[key] = .string("") } - } - - private func applyBatchUpdate(column: String, value: String, setNull: Bool) { - for rowIndex in selectedRows where feature.rows.indices.contains(rowIndex) && !deletedRows.contains(rowIndex) { - let key = CellKey(row: rowIndex, column: column) - let newValue: DatabaseValue = setNull ? .null : .string(value) - if feature.rows[rowIndex][column] == newValue { - drafts.removeValue(forKey: key) - } else { - drafts[key] = newValue - } - } - } - - private func apply() { - if feature.selectedProfile?.productionProtection == true, !deletedRows.isEmpty { - showsProtectedTableChangeConfirmation = true - return - } - performApply(confirmed: false) - } - - private func performApply(confirmed: Bool) { - let profileID = feature.selectedProfileID - let table = feature.selectedTable - Task { - guard feature.selectedProfileID == profileID, feature.selectedTable == table, - !feature.isApplyingTableEdits else { return } - _ = await feature.apply( - drafts: drafts.map { DatabaseCellDraft(rowIndex: $0.key.row, column: $0.key.column, value: $0.value) }, - insertedRows: insertedRows, - deletedIndexes: deletedRows, - confirmed: confirmed - ) - } - } - - private func display(_ value: DatabaseValue?) -> String { - value?.displayText ?? "NULL" - } - - private func metadataLabel(_ row: DatabaseRow) -> String { - let preferredKeys = ["index_name", "name", "constraint_name", "column_name", "referenced_table_name"] - let values = preferredKeys.compactMap { key -> String? in - guard let value = row[key] else { return nil } - let text = display(value) - return text.isEmpty ? nil : text - } - return values.isEmpty ? row.keys.sorted().joined(separator: ", ") : values.joined(separator: " - ") - } - - private var draftFilters: [DatabaseFilter] { - filterConditions.compactMap { condition in - guard condition.isEnabled, !condition.column.isEmpty else { return nil } - let needsValue = condition.operator != .isNull && condition.operator != .isNotNull - guard !needsValue || !condition.value.isEmpty else { return nil } - return DatabaseFilter( - column: condition.column, - operator: condition.operator, - value: needsValue ? .string(condition.value) : .null, - join: filterJoin - ) - } - } - private var sortClauseTitle: String { - guard !appliedSort.isEmpty else { return "ORDER BY" } - let summary = appliedSort.prefix(2).map { "\($0.column) \($0.descending ? "↓" : "↑")" }.joined(separator: ", ") - return appliedSort.count > 2 ? "ORDER BY · \(summary)…" : "ORDER BY · \(summary)" - } - private var pageLabel: String { - guard feature.totalRows > 0 else { return "0 / 0" } - return "\(feature.currentOffset + 1)-\(feature.currentOffset + feature.rows.count) / \(feature.totalRows)" - } - private func applyFilter() { - appliedFilters = draftFilters - reloadQuery(offset: 0) - } - private func clearFilters() { - filterConditions = [.init(column: feature.columns.first ?? "")] - appliedFilters = [] - reloadQuery(offset: 0) - } - private func clearSorting() { - sortConditions = [] - appliedSort = [] - reloadQuery(offset: 0) - } - private func clearQuery() { - filterConditions = [.init(column: feature.columns.first ?? "")] - sortConditions = [] - appliedFilters = [] - appliedSort = [] - reloadQuery(offset: 0) - } - private func applySorting() { - appliedSort = sortConditions.compactMap { $0.column.isEmpty ? nil : DatabaseSort(column: $0.column, descending: $0.descending) } - reloadQuery(offset: 0) - } - private func moveSort(from index: Int, by delta: Int) { - let destination = index + delta - guard sortConditions.indices.contains(index), sortConditions.indices.contains(destination) else { return } - sortConditions.swapAt(index, destination) - } - private func reloadQuery(offset: Int) { - selectedRows = []; pasteAnchor = nil; editingCell = nil; focusedCell = nil - Task { - await feature.loadPage(filters: appliedFilters, sort: appliedSort, offset: offset) - } - } - private func refreshTable() { - reloadQuery(offset: feature.currentOffset) - } - private func setSort(column: String, descending: Bool) { - appliedSort = [DatabaseSort(column: column, descending: descending)] - sortConditions = appliedSort.map { .init(column: $0.column, descending: $0.descending) } - reloadQuery(offset: 0) - } - private func isSorted(_ column: String) -> Bool { - appliedSort.contains { $0.column == column } - } - private func sortIcon(for column: String) -> String { - guard let sort = appliedSort.first(where: { $0.column == column }) else { - return "general/arrowSplitCenterV.svg" - } - return sort.descending ? "general/arrowDown.svg" : "general/arrowUp.svg" - } - private func previousPage() { reloadQuery(offset: max(0, feature.currentOffset - feature.pageSize)) } - private func nextPage() { reloadQuery(offset: feature.currentOffset + feature.pageSize) } - - private func pasteFromClipboard() { - guard !feature.isApplyingTableEdits else { return } - guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return } - let lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty } - guard !lines.isEmpty, !feature.columns.isEmpty else { return } - let anchorRow = pasteAnchor?.row ?? selectedRows.min() ?? 0 - let anchorColumn = pasteAnchor.flatMap { feature.columns.firstIndex(of: $0.column) } ?? 0 - for (rowOffset, line) in lines.enumerated() { - let values = line.components(separatedBy: "\t") - let targetRow = anchorRow + rowOffset - let insertedIndex = targetRow - feature.rows.count - if insertedIndex >= 0 { - while insertedRows.count <= insertedIndex { insertedRows.append([:]) } - } - for (columnOffset, value) in values.enumerated() { - let columnIndex = anchorColumn + columnOffset - guard feature.columns.indices.contains(columnIndex) else { continue } - let column = feature.columns[columnIndex] - if targetRow < feature.rows.count { - drafts[CellKey(row: targetRow, column: column)] = .string(value) - } else { - insertedRows[insertedIndex][column] = .string(value) - } - } - } - } - - private func copySelectedRowsAsTSV() { - let indexes = selectedRows.sorted().filter(feature.rows.indices.contains) - guard !indexes.isEmpty else { return } - let columns = feature.columns - let text = indexes.map { index in - columns.map { display(feature.rows[index][$0]) }.joined(separator: "\t") - }.joined(separator: "\n") - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - - private func replaceCurrentPage() { - guard !replaceText.isEmpty else { return } - let columns = replaceColumn.isEmpty ? feature.columns : [replaceColumn] - for (rowIndex, row) in feature.rows.enumerated() { - for column in columns { - guard let value = row[column] else { continue } - let current = display(value) - // A masked value is a display placeholder, never a safe source - // value for a bulk replacement. - guard current != "******" else { continue } - let replacement = current.replacingOccurrences(of: replaceText, with: replacementText) - if replacement != current { - drafts[CellKey(row: rowIndex, column: column)] = .string(replacement) - } - } - } - } - - @State private var importFormat = DatabaseTransferFormat.csv - - private var exportFilename: String { - let base = exportFormat == .sql ? (feature.selectedProfile?.database.nonEmpty ?? "database") : (feature.selectedTable ?? "table") - return "\(base).\(exportFormat.rawValue)" - } - - private func export(_ format: DatabaseTransferFormat) { - exportFormat = format - Task { - if format == .sql { - guard let url = await feature.exportDataFile(format: format) else { return } - temporaryExportURL = url - exportDocument = DatabaseTransferDocument(fileURL: url) - } else { - guard let data = await feature.exportData(format: format) else { return } - exportDocument = DatabaseTransferDocument(data: data) - } - showsExporter = true - } - } - - private func importFile(_ result: Result) { - do { - let url = try result.get() - let accessed = url.startAccessingSecurityScopedResource() - defer { if accessed { url.stopAccessingSecurityScopedResource() } } - if importFormat == .sql { - let temporaryURL = try feature.prepareImportFile(from: url) - stageImport(fileURL: temporaryURL, format: .sql) - return - } - stageImport(data: try feature.readImportData(from: url), format: importFormat) - } catch { feature.errorMessage = error.localizedDescription } - } - - private func stageImport(data: Data? = nil, fileURL: URL? = nil, format: DatabaseTransferFormat) { - pendingImportData = data - pendingImportURL = fileURL - pendingImportFormat = format - if format == .sql || feature.selectedProfile?.productionProtection == true { - showsProtectedImportConfirmation = true - } else { - startPendingImport(confirmed: false) - } - } - - private func startPendingImport(confirmed: Bool) { - guard let format = pendingImportFormat else { return } - let data = pendingImportData - let fileURL = pendingImportURL - pendingImportData = nil - pendingImportURL = nil - pendingImportFormat = nil - Task { - if let fileURL { - defer { feature.removeTemporaryFile(fileURL) } - _ = await feature.importDataFile(fileURL, format: format, confirmed: confirmed) - } else if let data { - _ = await feature.importData(data, format: format, confirmed: confirmed) - } - } - } - - private func discardPendingImport() { - if let pendingImportURL { feature.removeTemporaryFile(pendingImportURL) } - pendingImportData = nil - pendingImportURL = nil - pendingImportFormat = nil - } - - private var importConfirmationMessage: String { - if pendingImportFormat == .sql { - return "Restoring a SQL backup replaces the current database objects and data. A recovery snapshot will be created first." - } - return "This connection has production protection enabled. Importing can change database data." - } -} - -private struct DatabaseBatchUpdateSheet: View { - let columns: [String] - let selectedCount: Int - let onApply: (String, String, Bool) -> Void - let onCancel: () -> Void - @State private var column = "" - @State private var value = "" - @State private var setNull = false - @FocusState private var valueFocused: Bool - - var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 12) { - Text("\(selectedCount) selected").foregroundStyle(LitheTheme.secondaryText) - Text("Set one field for every selected row. The change remains pending until you choose Apply.") - .foregroundStyle(LitheTheme.secondaryText).fixedSize(horizontal: false, vertical: true) - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - GridRow { - Text("Column").frame(width: 80, alignment: .leading) - LitheSettingsSelect(selection: $column, options: columns, width: 316, accessibilityLabel: "Column", title: { $0 }, localizesTitles: false) - } - GridRow { - Text("Value") - TextField("New value", text: $value).focused($valueFocused) - .modifier(LitheCommitDialogInputStyle(focused: valueFocused)).disabled(setNull) - } - GridRow { Color.clear.frame(width: 80, height: 1); Toggle("Set NULL", isOn: $setNull).toggleStyle(.checkbox) } - } - }.padding(16) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack(spacing: LitheTheme.ActionButton.spacing) { - Spacer() - Button("Cancel", action: onCancel).buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) - Button("Stage Batch Edit") { onApply(column, value, setNull); onCancel() } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)).keyboardShortcut(.defaultAction) - .disabled(column.isEmpty || selectedCount == 0) - }.padding(16) - } - .font(LitheTheme.uiFont(size: 13)).foregroundStyle(LitheTheme.primaryText) - .frame(width: 440).background(LitheCommitDialogStyle.background) - .onAppear { if column.isEmpty { column = columns.first ?? "" } } - } -} - -private struct DatabaseTableEmptyState: View { - let hasConnection: Bool - var hasColumns = true - - private var instruction: LocalizedStringKey { - if !hasConnection { - "Select a connection in the database tree" - } else if hasColumns { - "Double-click a table to view data" - } else { - "No columns to display" - } - } - - var body: some View { - LitheToolWindowStatusText(instruction) - .accessibilityRepresentation { Text(instruction) } - } -} - -typealias CellKey = DatabaseCellKey - -private struct DatabaseTableFilterDraft: Identifiable { - let id = UUID() - var isEnabled = true - var column = "" - var `operator`: DatabaseFilterOperator = .contains - var value = "" -} - -private struct DatabaseTableSortDraft: Identifiable { - let id = UUID() - var column = "" - var descending = false -} - -private extension DatabaseFilterOperator { - var title: LocalizedStringKey { LocalizedStringKey(menuTitle) } - var menuTitle: String { - switch self { - case .equals: "Equals" - case .notEquals: "Not Equals" - case .greaterThan: "Greater Than" - case .lessThan: "Less Than" - case .contains: "Contains" - case .startsWith: "Starts With" - case .isNull: "Is NULL" - case .isNotNull: "Is Not NULL" - } - } -} - -private struct DatabaseRowDetailsSheet: View { - let rowNumber: Int - let columns: [String] - let row: DatabaseRow - let onCancel: () -> Void - - var body: some View { - VStack(spacing: 0) { - Text("#\(rowNumber)").foregroundStyle(LitheTheme.secondaryText) - .frame(maxWidth: .infinity, alignment: .leading).padding(16) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - ScrollView { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - ForEach(columns, id: \.self) { column in - GridRow { - Text(column).frame(width: 150, alignment: .leading) - Text(row[column]?.displayText ?? "").font(LitheTheme.codeFont) - .foregroundStyle(row[column] == .null ? LitheTheme.DatabaseGrid.nullText : LitheTheme.DatabaseGrid.text) - .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) - } - } - }.padding(16) - }.litheScrollViewChrome() - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack { Spacer(); Button("Close", action: onCancel).buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) }.padding(16) - } - .font(LitheTheme.uiFont(size: 13)).foregroundStyle(LitheTheme.primaryText) - .frame(width: 620, height: 460).background(LitheCommitDialogStyle.background) - } -} - -private struct DatabaseReplaceSheet: View { - @Environment(\.locale) private var locale - let columns: [String] - @Binding var column: String - @Binding var searchText: String - @Binding var replacementText: String - let onReplace: () -> Void - let onCancel: () -> Void - @FocusState private var focusedField: String? - - var body: some View { - VStack(spacing: 0) { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { - GridRow { - Text("Column").frame(width: 96, alignment: .leading) - LitheSettingsSelect(selection: $column, options: [""] + columns, width: 280, accessibilityLabel: "Column", - title: { $0.isEmpty ? String(localized: "All columns", locale: locale) : $0 }, localizesTitles: false) - } - GridRow { - Text("Find") - TextField("", text: $searchText).accessibilityLabel("Find").focused($focusedField, equals: "find") - .modifier(LitheCommitDialogInputStyle(focused: focusedField == "find")) - } - GridRow { - Text("Replace with") - TextField("", text: $replacementText).accessibilityLabel("Replace with").focused($focusedField, equals: "replace") - .modifier(LitheCommitDialogInputStyle(focused: focusedField == "replace")) - } - }.padding(16) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - HStack(spacing: LitheTheme.ActionButton.spacing) { - Spacer() - Button("Cancel", action: onCancel).buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) - Button("Replace") { onReplace(); onCancel() } - .buttonStyle(LitheActionButtonStyle(isPrimary: true)).keyboardShortcut(.defaultAction) - .disabled(searchText.isEmpty) - }.padding(16) - } - .font(LitheTheme.uiFont(size: 13)).foregroundStyle(LitheTheme.primaryText) - .frame(width: 420).background(LitheCommitDialogStyle.background) - } -} - - -struct DatabaseTransferDocument: FileDocument { - static var readableContentTypes: [UTType] { [.data] } - let data: Data? - let fileURL: URL? - init(data: Data) { self.data = data; fileURL = nil } - init(fileURL: URL) { data = nil; self.fileURL = fileURL } - init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data(); fileURL = nil } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - if let fileURL { return try FileWrapper(url: fileURL, options: []) } - return FileWrapper(regularFileWithContents: data ?? Data()) - } -} - -extension DatabaseTransferFormat { - var contentType: UTType { switch self { case .csv: .commaSeparatedText; case .json: .json; case .sql: .sql } } -} - -extension UTType { - static let sql = UTType(filenameExtension: "sql") ?? .plainText -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} - -struct DatabaseGridCellClickTarget: NSViewRepresentable { - let onMouseDown: (Int) -> Void - - func makeNSView(context: Context) -> DatabaseGridCellClickView { - let view = DatabaseGridCellClickView() - view.onMouseDown = onMouseDown - return view - } - - func updateNSView(_ view: DatabaseGridCellClickView, context: Context) { - view.onMouseDown = onMouseDown - } -} - -final class DatabaseGridCellClickView: NSView { - var onMouseDown: ((Int) -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - guard let event = NSApp.currentEvent, event.type == .leftMouseDown, - !event.modifierFlags.contains(.control) else { return nil } - return super.hitTest(point) - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } - - override func mouseDown(with event: NSEvent) { - // Select on the first down event; never wait for double-click arbitration. - guard event.type == .leftMouseDown, !event.modifierFlags.contains(.control) else { return } - onMouseDown?(event.clickCount) - } -} diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 367b6149d..a7a84abfb 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -4346,11 +4346,6 @@ final class LineNumberGutterView: NSView { private var foldIndicatorAnimationTimer: Timer? private var trackingArea: NSTrackingArea? private var hoveredDebugBreakpointLine: Int? - var usesDatabaseConsoleAppearance = false - var currentConsoleLine = 1 - var consoleExecutionMarkers: [Int: String] = [:] { - didSet { if consoleExecutionMarkers != oldValue { needsDisplay = true } } - } private var palette = CodeEditorPalette.dark private var gitLineChangeMarkersByLine: [Int: GitLineChangeMarker] = [:] private var onShowGitLineChange: ((GitLineChangeMarker) -> Void)? @@ -4360,8 +4355,6 @@ final class LineNumberGutterView: NSView { private var contextGitLineChange: GitLineChangeMarker? private var foldsByStartLine: [Int: [JavaFoldRegion]] = [:] private var gutterLayout = EditorGutterLayout(lineNumberTextWidth: 0) - private var consoleLineNumberRightEdge: CGFloat = 22 - var consoleLineNumberOnlyWidth: CGFloat { consoleLineNumberRightEdge + 4 } private var editorGutterOriginX: CGFloat { isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth : 0 @@ -4412,15 +4405,10 @@ final class LineNumberGutterView: NSView { guard let textView else { return } let maximumLineNumber = max(1, (textView as? CodeTextView)?.lineCount() ?? textView.string.reduce(1) { $1 == "\n" ? $0 + 1 : $0 }) let editorFont = textView.font ?? LitheTheme.editorFont(size: 13) - let lineNumberFont = usesDatabaseConsoleAppearance ? editorFont : EditorGutterLayout.lineNumberFont(for: editorFont) + let lineNumberFont = EditorGutterLayout.lineNumberFont(for: editorFont) let textWidth = (String(maximumLineNumber) as NSString).size( withAttributes: [.font: lineNumberFont] ).width - if usesDatabaseConsoleAppearance { - // Community New UI: 4pt empty annotation area + 4pt gap, with a 14pt breakpoint placeholder. - consoleLineNumberRightEdge = 8 + max(14, ceil(textWidth)) - needsDisplay = true - } let nextLayout = EditorGutterLayout(lineNumberTextWidth: textWidth, isSearchPreview: isSearchPreview) guard gutterLayout != nextLayout else { return } gutterLayout = nextLayout @@ -4691,7 +4679,7 @@ final class LineNumberGutterView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) if !displaysTransparentBackground { - (usesDatabaseConsoleAppearance ? NSColor(LitheTheme.DatabaseGrid.background) : palette.gutterBackground).setFill() + palette.gutterBackground.setFill() dirtyRect.fill() } guard let textView, @@ -4700,14 +4688,6 @@ final class LineNumberGutterView: NSView { let textContainer = textView.textContainer else { return } let visibleRect = scrollView.documentVisibleRect - if usesDatabaseConsoleAppearance { - var line = LitheTextViewportLayout.consoleCaretLineRect(in: textView) - line.origin.x = bounds.minX - line.origin.y -= visibleRect.minY - line.size.width = bounds.width - NSColor(LitheTheme.DatabaseGrid.currentRowBackground).setFill() - line.intersection(dirtyRect).fill() - } let textContainerVisibleRect = NSRect( x: visibleRect.minX - textView.textContainerOrigin.x, y: visibleRect.minY - textView.textContainerOrigin.y, @@ -4719,9 +4699,7 @@ final class LineNumberGutterView: NSView { in: textContainer ) guard layoutManager.numberOfGlyphs > 0 else { - let lineHeight = usesDatabaseConsoleAppearance - ? LitheTextViewportLayout.consoleLineHeight(in: textView) - : max(18, layoutManager.defaultLineHeight(for: textView.font ?? LitheTheme.editorFont(size: 13))) + let lineHeight = max(18, layoutManager.defaultLineHeight(for: textView.font ?? LitheTheme.editorFont(size: 13))) drawLineNumber(1, y: textView.textContainerInset.height, height: lineHeight) drawEditorDivider(in: dirtyRect) return @@ -4765,7 +4743,7 @@ final class LineNumberGutterView: NSView { lineNumber += 1 continue } - if !usesDatabaseConsoleAppearance, lineNumber - 1 == currentLine { + if lineNumber - 1 == currentLine { palette.currentLine.setFill() NSRect( x: bounds.minX + EditorLayoutMetrics.currentLineHorizontalInset, @@ -4834,18 +4812,11 @@ final class LineNumberGutterView: NSView { lineNumber += 1 } - if usesDatabaseConsoleAppearance, layoutManager.extraLineFragmentTextContainer === textContainer { - let extra = layoutManager.extraLineFragmentRect - // TextKit's EOF fragment omits paragraph line spacing; use the same height as a typed console line. - drawLineNumber(TextLineIndex(source: textView.string as NSString).starts.count, - y: extra.minY + textView.textContainerOrigin.y - visibleRect.minY, - height: LitheTextViewportLayout.consoleLineHeight(in: textView)) - } drawEditorDivider(in: dirtyRect) } private func drawEditorDivider(in dirtyRect: NSRect) { - (usesDatabaseConsoleAppearance ? NSColor(LitheTheme.DatabaseGrid.border) : palette.gutterDivider).setFill() + palette.gutterDivider.setFill() if isBlameVisible { NSRect( x: EditorLayoutMetrics.blameMetadataWidth, @@ -4859,7 +4830,7 @@ final class LineNumberGutterView: NSView { y: dirtyRect.minY, width: 1, height: dirtyRect.height).fill() } NSRect( - x: bounds.width - (usesDatabaseConsoleAppearance ? 3 : 1), + x: bounds.width - 1, y: dirtyRect.minY, width: 1, height: dirtyRect.height @@ -4877,18 +4848,16 @@ final class LineNumberGutterView: NSView { size: max(8, editorFont.pointSize - 1), weight: .semibold ) - : (usesDatabaseConsoleAppearance ? editorFont : EditorGutterLayout.lineNumberFont(for: editorFont)), + : EditorGutterLayout.lineNumberFont(for: editorFont), .foregroundColor: isExecutionLine ? palette.link - : (usesDatabaseConsoleAppearance - ? NSColor(number == currentConsoleLine ? LitheTheme.DatabaseGrid.currentRowNumber : LitheTheme.DatabaseGrid.rowNumber) - : (isBreakpointLine ? palette.text : palette.lineNumber)) + : (isBreakpointLine ? palette.text : palette.lineNumber) ] let size = label.size(withAttributes: attributes) let centeredY = y + max(0, (height - size.height) / 2) label.draw( at: NSPoint( - x: usesDatabaseConsoleAppearance ? consoleLineNumberRightEdge - size.width : editorGutterOriginX + max( + x: editorGutterOriginX + max( gutterLayout.lineNumberRange.lowerBound, gutterLayout.lineNumberRange.upperBound - size.width @@ -4898,12 +4867,6 @@ final class LineNumberGutterView: NSView { ), withAttributes: attributes ) - if usesDatabaseConsoleAppearance, let path = consoleExecutionMarkers[number] { - let resource = palette.isDark ? LitheIcons.darkIdeaAssetPath(for: path) : path - LitheIcons.ideaImage(resourcePath: resource)?.draw(in: NSRect(x: consoleLineNumberRightEdge + 4, - y: y + (height - 16) / 2, width: 16, height: 16), from: .zero, operation: .sourceOver, - fraction: 1, respectFlipped: true, hints: nil) - } } private func drawFoldIndicator(_ region: JavaFoldRegion, y: CGFloat, height: CGFloat) { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index bf8b74c2b..40a16b070 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -211,7 +211,6 @@ struct EditorAreaView: View { private var activeItem: EditorTabItem? { if model.isAgentEditorSelected { return .agentConversation } - if model.isDatabaseWorkspaceSelected { return model.activeDatabaseEditorTab } if model.isRepositoryDiffSelected { return .repositoryDiff } return model.activeEditorTerminalSession.map { .terminal($0.id) } ?? model.activeMediaDocument.map { .media($0.id) } @@ -318,17 +317,9 @@ struct EditorAreaView: View { Group { switch settings.editorTabLayoutMode { case .singleLine: - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - editorTabItems - } - } - .onChange(of: model.activeDatabaseEditorTab) { item in - if model.isDatabaseWorkspaceSelected { proxy.scrollTo(item, anchor: .trailing) } - } - .onChange(of: model.editorTabItems) { _ in - if model.isDatabaseWorkspaceSelected { proxy.scrollTo(model.activeDatabaseEditorTab, anchor: .trailing) } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + editorTabItems } } .frame(height: 36) @@ -374,12 +365,6 @@ struct EditorAreaView: View { ForEach(displayedTabItems) { item in Group { switch item { - case .databaseOverview: - databaseTab(overview: true) - case .database(let profileID): - databaseTab(overview: false, profileID: profileID) - case let .databaseQuery(profileID, queryID): - databaseTab(overview: false, profileID: profileID, queryID: queryID) case .agentConversation: agentConversationTab case .repositoryDiff: @@ -430,49 +415,6 @@ struct EditorAreaView: View { return items } - private func databaseTab(overview: Bool, profileID: UUID? = nil, queryID: UUID? = nil) -> some View { - let item: EditorTabItem = queryID.flatMap { id in profileID.map { .databaseQuery(profileID: $0, queryID: id) } } - ?? (overview ? .databaseOverview : .database(profileID)) - let selected = paneItem == item - return HStack(spacing: 0) { - HStack(spacing: 7) { - if queryID != nil, let profile = model.databaseFeatureIfActive?.profiles.first(where: { $0.id == profileID }) { - DatabaseBrandIcon(kind: profile.kind, size: 16) - } else { - LitheIDEAIcon(resourcePath: "database/expui/dbms@20x20.svg", size: 16) - } - Group { - if overview { - Text("Database Overview") - } else if let feature = model.databaseFeatureIfActive { - DatabaseWorkspaceTabTitle(feature: feature, profileID: profileID, queryID: queryID) - } - }.font(LitheTheme.uiFont(size: 13)).lineLimit(1) - } - .padding(.leading, 8) - .frame(height: 28) - .contentShape(Rectangle()) - .onTapGesture { - if overview { model.openDatabaseWorkspace(overview: true) } - else { model.selectDatabaseWorkspace(profileID: profileID, queryID: queryID) } - } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityAction { - if overview { model.openDatabaseWorkspace(overview: true) } - else { model.selectDatabaseWorkspace(profileID: profileID, queryID: queryID) } - } - LitheToolWindowTabCloseButton { model.closeDatabaseTab(item) } - .opacity(selected || hoveredTabItem == item ? 1 : 0) - .allowsHitTesting(selected || hoveredTabItem == item) - } - .foregroundStyle(selected ? LitheTheme.primaryText : LitheTheme.secondaryText) - .modifier(LitheToolWindowTabStyle(isSelected: selected, isActive: editorGroupIsActive)) - .padding(.horizontal, 4).padding(.vertical, 4) - .onHover { updateHoveredTab(item, isHovering: $0) } - .background { editorTabFrameReader(for: item) } - } - private var agentConversationTab: some View { let selected = paneItem == .agentConversation return HStack(spacing: 0) { @@ -841,10 +783,6 @@ struct EditorAreaView: View { private func closeEditorTab(_ item: EditorTabItem) { switch item { case .agentConversation: model.showAgentChatsInToolWindow() - case .databaseOverview: - model.closeDatabaseWorkspace(overview: true) - case .database, .databaseQuery: - model.closeDatabaseTab(item) case .repositoryDiff: model.closeGitCommitDiff() case .document(let documentID): @@ -888,19 +826,6 @@ struct EditorAreaView: View { private func tabDragLabel(_ item: EditorTabItem) -> some View { switch item { case .agentConversation: AgentEditorTabTitle(model: model) - case .databaseOverview: - LitheIDEAIcon(resourcePath: "database/expui/dbms@20x20.svg", size: 16) - Text("Database Overview") - case .database(let profileID): - LitheIDEAIcon(resourcePath: "database/expui/dbms@20x20.svg", size: 16) - if let feature = model.databaseFeatureIfActive { - DatabaseWorkspaceTabTitle(feature: feature, profileID: profileID, queryID: nil) - } - case let .databaseQuery(profileID, queryID): - if let feature = model.databaseFeatureIfActive { - if let profile = feature.profiles.first(where: { $0.id == profileID }) { DatabaseBrandIcon(kind: profile.kind, size: 16) } - DatabaseWorkspaceTabTitle(feature: feature, profileID: profileID, queryID: queryID) - } case .document(let id): if let document = model.openDocuments.first(where: { $0.id == id }) { LitheIcon(kind: EditorDocumentIconResolver.kind(for: document.url, resolvedKind: resolvedTabIcons[id]), size: 16) @@ -941,7 +866,7 @@ struct EditorAreaView: View { guard tabDragState.draggedItem == item, let plan = tabDragPlan(for: item, translation: translation, location: location) else { return } tabDragPreview.move(by: translation) - if let groupID, !item.isDatabaseTab { + if let groupID { let destination = drag.destination(at: location, sourceGroup: groupID) if drag.target != destination { drag.target = destination } } @@ -958,7 +883,7 @@ struct EditorAreaView: View { finishTabDrag() return } - if let groupID, !item.isDatabaseTab, let destination = drag.destination(at: location, sourceGroup: groupID) { + if let groupID, let destination = drag.destination(at: location, sourceGroup: groupID) { finishTabDrag() model.moveEditorTab(item, toGroup: destination.group, side: destination.side) return @@ -1222,12 +1147,6 @@ struct EditorAreaView: View { if paneItem == .agentConversation { AgentConversationView(model: model, isEditor: true) - } else if let item = paneItem, item.isDatabaseTab, let feature = model.databaseFeatureIfActive { - Group { - if item == .databaseOverview { DatabaseOverviewView() } - else if case .databaseQuery = item { DatabaseSQLWorkspaceView() } - else { DatabaseWorkspaceView() } - }.environmentObject(feature) } else if paneDiffSelected { if let feature = model.gitFeatureIfActive, let context = feature.selectedGitCommitDiffContext { RepositoryDiffView(feature: feature, context: context, diff --git a/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift index adff3a02b..280112166 100644 --- a/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift +++ b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift @@ -39,3 +39,15 @@ enum GitRepositoryColor { Color(hex: paletteHex[((index % paletteHex.count) + paletteHex.count) % paletteHex.count]) } } + +private extension Color { + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + let red = Double((value >> 16) & 0xff) / 255 + let green = Double((value >> 8) & 0xff) / 255 + let blue = Double(value & 0xff) / 255 + self.init(red: red, green: green, blue: blue) + } +} diff --git a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift index 2be0ac331..798b47fd0 100644 --- a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift +++ b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift @@ -342,7 +342,7 @@ enum ProblemsPanelScope: CaseIterable { @MainActor static func currentFileURL(in model: AppModel) -> URL? { // Match FileEditorManager.selectedEditor, including non-text editor selection. - guard !model.isAgentEditorSelected, !model.isDatabaseWorkspaceSelected, !model.isRepositoryDiffSelected, + guard !model.isAgentEditorSelected, !model.isRepositoryDiffSelected, model.gitFeatureIfActive?.selectedChange == nil, model.gitFeatureIfActive?.branchComparison == nil, model.activeEditorTerminalSession == nil, diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 1fd032b60..7b4d1191f 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -1,7 +1,6 @@ import SwiftUI import LitheModuleAPI import LitheApplicationKernel -import LitheDatabaseModule import LitheDebugModule import LitheExecutionModule import LitheGitModule @@ -70,7 +69,7 @@ enum WorkbenchModuleUIComposition { static var registrations: [WorkbenchModuleUIRegistry.Registration] { [workspaceRegistration, searchRegistration, terminalRegistration, agentRegistration, gitRegistration, languageRegistration, - executionRegistration, databaseRegistration, debugRegistration, + executionRegistration, debugRegistration, .init(contributions: WorkbenchFeatureModel.shellContributions, actions: applicationActions)] } @@ -346,50 +345,6 @@ enum WorkbenchModuleUIComposition { ] ) - private static let databaseRegistration = WorkbenchModuleUIRegistry.Registration( - contributions: DatabaseModule.moduleContributions, - actions: [ - .init(id: "database.services.toggle", perform: { model in - if model.workbenchFeature.isVisible(.databaseServices) { - model.workbenchFeature.setVisibility(.databaseServices, isVisible: false) - } else { - Task { await model.activateDatabaseModule(toolWindow: .databaseServices) } - } - }, activateOwner: { await $0.databaseModuleCoordinator.activate() != nil }), - .init(id: "database.toggle", perform: { model in - if model.workbenchFeature.isVisible(.database) { - model.workbenchFeature.setVisibility(.database, isVisible: false) - } else { - Task { await model.activateDatabaseModule() } - } - }, activateOwner: { await $0.databaseModuleCoordinator.activate() != nil }) - ], - renderers: [ - .init(id: "database.services", ideaAssetPath: "expui/toolwindows/services@20x20.svg", - isVisible: { $0.databaseFeatureIfActive != nil }, - isSelected: { $0.workbenchFeature.isVisible(.databaseServices) }, - content: { model in - guard let feature = model.databaseFeatureIfActive else { return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) } - return AnyView(DatabaseServicesView().environmentObject(feature)) - }, - contentIdentity: { WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.databaseFeatureIfActive) }), - .init( - id: "database.workspace", - ideaAssetPath: "database/expui/dbms@20x20.svg", - isVisible: { _ in true }, - isSelected: { $0.workbenchFeature.isVisible(.database) }, - content: { model in - guard let feature = model.databaseFeatureIfActive else { - return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) - } - return AnyView(DatabaseSidebarView().environmentObject(feature)) - }, - contentIdentity: { WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.databaseFeatureIfActive) }, - rightSidebarBehavior: .docked - ) - ] - ) - private static let debugRegistration = WorkbenchModuleUIRegistry.Registration( contributions: DebugModule.moduleContributions, actions: [ diff --git a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift b/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift deleted file mode 100644 index e6dbd61de..000000000 --- a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift +++ /dev/null @@ -1,2745 +0,0 @@ -import Combine -import Foundation - -private struct DatabaseSQLBatchError: LocalizedError { - package let statementIndex: Int - package let message: String - - package var errorDescription: String? { - "Statement \(statementIndex) failed: \(message) Batch stopped; earlier statements may have been applied." - } -} - -package enum DatabaseConnectionStatus: Equatable, Sendable { - case idle - case connecting - case connected - case failed - - package var title: String { - switch self { - case .idle: "Not connected" - case .connecting: "Connecting" - case .connected: "Connected" - case .failed: "Connection failed" - } - } -} - -package enum DatabaseTreeLoadState: Equatable, Sendable { - case idle, loading, ready - case failed(String) -} - -package struct DatabaseTreeDatabase: Sendable { - package var state: DatabaseTreeLoadState = .idle - package var tables: [String] = [] - package var objects: [DatabaseObjectKind: [DatabaseRow]] = [:] - var requestID = UUID() - - package init(state: DatabaseTreeLoadState = .idle, tables: [String] = [], objects: [DatabaseObjectKind: [DatabaseRow]] = [:]) { - self.state = state - self.tables = tables - self.objects = objects - } -} - -package struct DatabaseConnectionTree: Sendable { - package var state: DatabaseTreeLoadState = .idle - package var names: [String] = [] - package var databases: [String: DatabaseTreeDatabase] = [:] - var requestID = UUID() - - package init() {} -} - -package struct DatabaseTreeTableKey: Hashable, Sendable { - package let profileID: UUID - package let database: String - package let table: String - package init(profileID: UUID, database: String, table: String) { - self.profileID = profileID - self.database = database - self.table = table - } -} - -private struct DatabaseTreeDatabaseKey: Hashable { - let profileID: UUID - let database: String -} - -package struct DatabaseTreeTable: Sendable { - package var state: DatabaseTreeLoadState = .idle - package var columns: [DatabaseRow] = [] - package var indexes: [DatabaseRow] = [] - package var foreignKeys: [DatabaseRow] = [] - package var requestID = UUID() - package init() {} -} - -@MainActor -package final class DatabaseFeatureModel: ObservableObject { - @Published package private(set) var profiles: [DatabaseProfile] - @Published package private(set) var folders: [DatabaseConnectionFolder] - @Published package var selectedProfileID: UUID? - @Published package private(set) var tables: [String] = [] - @Published package private(set) var databaseOptions: [String] = [] - // Navigation caches are independent of the active editor; never persist credentials or rows here. - @Published package private(set) var databaseTrees: [UUID: DatabaseConnectionTree] = [:] - @Published private var treeRequests: Set = [] - private var databaseNameTasks: [UUID: Task<[String], Error>] = [:] - private var databaseStructureTasks: [DatabaseTreeDatabaseKey: Task] = [:] - @Published package private(set) var tableTrees: [DatabaseTreeTableKey: DatabaseTreeTable] = [:] - private var tableMetadataTasks: [DatabaseTreeTableKey: Task] = [:] - @Published package var selectedTable: String? { - didSet { if selectedTable != oldValue { tableRequestID = nil; discardTableEdits() } } - } - // Editing belongs to the active table, not the lifetime of its SwiftUI renderer. - @Published private var storedTableCellDrafts: [DatabaseCellKey: DatabaseValue] = [:] - package var tableCellDrafts: [DatabaseCellKey: DatabaseValue] { - get { storedTableCellDrafts } - set { guard !isApplyingTableEdits else { return }; storedTableCellDrafts = newValue } - } - @Published private var storedTableInsertedRows: [DatabaseRow] = [] - package var tableInsertedRows: [DatabaseRow] { - get { storedTableInsertedRows } - set { guard !isApplyingTableEdits else { return }; storedTableInsertedRows = newValue } - } - @Published private var storedTableDeletedRows: Set = [] - package var tableDeletedRows: Set { - get { storedTableDeletedRows } - set { guard !isApplyingTableEdits else { return }; storedTableDeletedRows = newValue } - } - @Published package var tableApplyFailed = false - @Published private var applyingTableProfileGenerations: [UUID: UInt64] = [:] - private var deferredTableRefreshProfileIDs: Set = [] - package var isApplyingTableEdits: Bool { - selectedProfileID.map { applyingTableProfileGenerations[$0] != nil } ?? false - } - @Published package private(set) var columns: [String] = [] - @Published package private(set) var columnTypes: [String: String] = [:] - @Published package private(set) var rows: [DatabaseRow] = [] - @Published package private(set) var totalRows: Int64 = 0 - @Published package private(set) var currentOffset = 0 - package let pageSize = 200 - @Published package private(set) var primaryKeyColumns: [String] = [] - @Published package private(set) var indexes: [DatabaseRow] = [] - @Published package private(set) var foreignKeys: [DatabaseRow] = [] - @Published package private(set) var objects: [DatabaseObjectKind: [DatabaseRow]] = [:] - @Published package private(set) var lastExplainResult: DatabaseQueryResult? - @Published package private(set) var lastDiagnostics: DatabaseQueryResult? - @Published package private(set) var recoveryPoints: [DatabaseRecoveryPoint] - @Published package private(set) var auditEntries: [DatabaseAuditEntry] - @Published package private(set) var executionEvents: [DatabaseExecutionEvent] - @Published package private(set) var backupSchedules: [DatabaseBackupSchedule] - @Published package private(set) var sqlTabs: [DatabaseSQLTab] - @Published package var selectedSQLTabID: UUID? - @Published package var workspaceSection: DatabaseWorkspaceSection = .data - @Published package private(set) var sqlHistory: [DatabaseSQLHistoryEntry] - @Published package private(set) var connectionStatuses: [UUID: DatabaseConnectionStatus] = [:] - @Published package private(set) var isLoading = false - @Published package private(set) var backupProgress: Double? - @Published package var errorMessage: String? - @Published package private(set) var redisKeys: [RedisKeySummary] = [] - @Published package private(set) var redisNextCursor = "0" - @Published package private(set) var redisSelectedKey: RedisKeyDetail? - @Published package var redisIncludeSize = true - @Published package private(set) var nacosConfigs: [NacosConfigSummary] = [] - @Published package private(set) var nacosConfigTotalCount = 0 - @Published package var nacosSelectedConfig: NacosConfigDetail? - @Published package private(set) var nacosServices: [NacosServiceSummary] = [] - @Published package private(set) var nacosServiceTotalCount = 0 - @Published package private(set) var nacosInstances: [NacosInstanceSummary] = [] - - private let operations: any DatabaseOperations - private let connectionStore: DatabaseConnectionStore - private let recoveryStore: any DatabaseRecoveryStoring - private let fileStorage: any DatabaseFileStorage - private var backupTimer: Timer? - private var scheduledBackupTasks: [UUID: Task] = [:] - // Keep lightweight navigation and query drafts per connection; pages reload through existing operations. - private var workspaceSelections: [UUID: (database: String, table: String?, section: DatabaseWorkspaceSection, - sqlTabs: [DatabaseSQLTab], sqlTabID: UUID?)] = [:] - // Provider work outlives navigation snapshots; only runSQL completion releases these locks. - @Published private var runningSQLTabs: [UUID: Set] = [:] - private var profileGeneration: UInt64 = 0 - private var tableListRequestID: UUID? - private var databaseListRequestID: UUID? - private var tableRequestID: UUID? - private var redisScanRequestID: UUID? - private var redisDetailRequestID: UUID? - private var redisScanPattern = "*" - private var nacosConfigListRequestID: UUID? - private var nacosConfigDetailRequestID: UUID? - private var nacosServiceListRequestID: UUID? - private var nacosInstanceRequestID: UUID? - private var nacosConfigSearchDataID = "" - private var nacosConfigSearchGroup = "" - private var nacosSelectedServiceName: String? - private var nacosSelectedServiceGroup: String? - // Keep the unmasked page separate so primary-key mutations and exports never - // accidentally persist the display placeholder. - private var sourceRows: [DatabaseRow] = [] - - package init( - operations: any DatabaseOperations, - connectionStore: DatabaseConnectionStore, - recoveryStore: any DatabaseRecoveryStoring = UnavailableDatabaseRecoveryStore(), - fileStorage: any DatabaseFileStorage = UnavailableDatabaseFileStorage() - ) { - self.operations = operations - self.connectionStore = connectionStore - self.recoveryStore = recoveryStore - self.fileStorage = fileStorage - profiles = connectionStore.load() - folders = connectionStore.loadFolders() - let firstSQLTab = DatabaseSQLTab(title: "Query 1") - sqlTabs = [firstSQLTab] - selectedSQLTabID = firstSQLTab.id - sqlHistory = connectionStore.loadSQLHistory() - recoveryPoints = recoveryStore.recoveryPoints() - auditEntries = recoveryStore.auditEntries() - executionEvents = recoveryStore.executionEvents() - backupSchedules = connectionStore.loadBackupSchedules() - migrateLegacyGroupsIfNeeded() - refreshBackupTimer() - } - - package var hasActiveModuleWork: Bool { - isLoading - || !treeRequests.isEmpty - || !applyingTableProfileGenerations.isEmpty - || !runningSQLTabs.isEmpty - || backupProgress != nil - || !scheduledBackupTasks.isEmpty - } - - package func prepareForModuleRelease() { - backupTimer?.invalidate() - backupTimer = nil - profileGeneration &+= 1 - for task in scheduledBackupTasks.values { task.cancel() } - scheduledBackupTasks.removeAll() - workspaceSelections.removeAll() - for task in databaseNameTasks.values { task.cancel() } - databaseNameTasks.removeAll() - for task in databaseStructureTasks.values { task.cancel() } - databaseStructureTasks.removeAll() - databaseTrees.removeAll() - tableTrees.removeAll() - for task in tableMetadataTasks.values { task.cancel() } - tableMetadataTasks.removeAll() - treeRequests.removeAll() - tableListRequestID = nil - databaseListRequestID = nil - tableRequestID = nil - redisScanRequestID = nil - redisDetailRequestID = nil - nacosConfigListRequestID = nil - nacosConfigDetailRequestID = nil - nacosServiceListRequestID = nil - nacosInstanceRequestID = nil - } - - package func add(_ profile: DatabaseProfile, password: String) async -> Bool { - await save(profile, password: password) - } - - package func update(_ profile: DatabaseProfile, password: String?) async -> Bool { - await save(profile, password: password) - } - - package func hasSavedPassword(for profile: DatabaseProfile) -> Bool { - connectionStore.hasPassword(for: profile.id) - } - - /// Probe the draft without persisting credentials, changing selection, or publishing connection status. - package func testConnection(_ profile: DatabaseProfile, password: String?) async throws { - try Task.checkCancellation() - let generation = profileGeneration - let connection = connection(profile, password: password) - let task = Task.detached { [operations] in try operations.testConnection(connection) } - try await withTaskCancellationHandler { - do { - try await task.value - try Task.checkCancellation() - guard profileGeneration == generation else { throw CancellationError() } - } catch { - try Task.checkCancellation() - throw error - } - } onCancel: { - task.cancel() - } - } - - private func save(_ profile: DatabaseProfile, password: String?) async -> Bool { - let generation = profileGeneration - let tracksUI = selectedProfileID == profile.id - if tracksUI { isLoading = true } - errorMessage = nil - setConnectionStatus(.connecting, for: profile.id) - do { - var profile = profile - if profile.folderID != nil { profile.group = "" } - let previousPassword = connectionStore.password(for: profile.id) - let hadPreviousPassword = connectionStore.hasPassword(for: profile.id) - let connection = connection(profile, password: password ?? previousPassword) - try await Task.detached { [operations] in try operations.testConnection(connection) }.value - let previousProfiles = profiles - var updated = previousProfiles.filter { $0.id != profile.id }; updated.append(profile) - let previousPersistence = previousProfiles.first { $0.id == profile.id }?.passwordPersistence ?? .forever - let changesPassword = password != nil || previousPersistence != profile.passwordPersistence - if changesPassword { - try connectionStore.savePassword(password ?? previousPassword, for: profile.id, persistence: profile.passwordPersistence) - } - do { - try connectionStore.save(updated) - } catch { - if changesPassword { - if hadPreviousPassword { - try? connectionStore.savePassword(previousPassword, for: profile.id, persistence: previousPersistence) - } else { - try? connectionStore.deletePassword(for: profile.id) - } - } - throw error - } - profiles = sortedProfiles(updated) - invalidateDatabaseTree(for: profile.id) - setConnectionStatus(.connected, for: profile.id) - guard profileGeneration == generation else { return true } - guard selectedProfileID == profile.id else { return true } - activateProfile(profile) - if profile.kind.supportsDataGrid { - await refreshTables() - } else { - isLoading = false - } - return true - } catch { - setConnectionStatus(.failed, for: profile.id) - if profileGeneration == generation { - errorMessage = executionError(error) - if tracksUI { isLoading = false } - } - return false - } - } - - @discardableResult - package func remove(_ profile: DatabaseProfile) -> Bool { - do { - let updated = profiles.filter { $0.id != profile.id } - try connectionStore.save(updated); try connectionStore.deletePassword(for: profile.id); try connectionStore.deleteSQLHistory(for: profile.id); try connectionStore.deleteBackupSchedule(for: profile.id); try recoveryStore.deleteExecutionEvents(for: profile.id) - profiles = updated - invalidateDatabaseTree(for: profile.id) - workspaceSelections.removeValue(forKey: profile.id) - connectionStatuses.removeValue(forKey: profile.id) - sqlHistory.removeAll { $0.profileID == profile.id } - executionEvents.removeAll { $0.profileID == profile.id } - backupSchedules.removeAll { $0.profileID == profile.id } - refreshBackupTimer() - if selectedProfileID == profile.id { - clearProfileScopedState() - } - return true - } catch { errorMessage = error.localizedDescription; return false } - } - - package func createFolder(name: String, parentID: UUID? = nil, movingProfileID: UUID? = nil, movingFolderID: UUID? = nil) -> Bool { - let components = name.split(separator: "/", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard !components.isEmpty, !components.contains(where: \.isEmpty) else { errorMessage = "Folder name is required."; return false } - guard parentID == nil || folders.contains(where: { $0.id == parentID }), - movingProfileID == nil || profiles.contains(where: { $0.id == movingProfileID }), - movingProfileID == nil || movingFolderID == nil else { - errorMessage = "The selected connection folder no longer exists."; return false - } - var updatedFolders = folders - var destination = parentID - for (index, component) in components.enumerated() { - if let existing = updatedFolders.first(where: { $0.parentID == destination && $0.name.caseInsensitiveCompare(component) == .orderedSame }) { - guard index < components.count - 1 else { errorMessage = "A folder with this name already exists."; return false } - destination = existing.id - } else { - let folder = DatabaseConnectionFolder(name: component, parentID: destination) - updatedFolders.append(folder) - destination = folder.id - } - } - if let movingFolderID { - guard folderMoveAllowed(movingFolderID, toParent: destination, in: updatedFolders), - let index = updatedFolders.firstIndex(where: { $0.id == movingFolderID }) else { - errorMessage = "The selected folder cannot be moved there."; return false - } - updatedFolders[index].parentID = destination - } - var updatedProfiles = profiles - if let movingProfileID, let index = updatedProfiles.firstIndex(where: { $0.id == movingProfileID }) { - updatedProfiles[index].folderID = destination - updatedProfiles[index].group = "" - } - do { - if movingProfileID != nil { try connectionStore.saveOrganization(profiles: updatedProfiles, folders: updatedFolders) } - else { try connectionStore.saveFolders(updatedFolders) } - folders = sortedFolders(updatedFolders) - profiles = sortedProfiles(updatedProfiles) - errorMessage = nil - return true - } catch { errorMessage = error.localizedDescription; return false } - } - - package func canMoveFolder(_ id: UUID, toParent parentID: UUID?) -> Bool { - folderMoveAllowed(id, toParent: parentID, in: folders) - } - - private func folderMoveAllowed(_ id: UUID, toParent parentID: UUID?, in candidates: [DatabaseConnectionFolder]) -> Bool { - guard let folder = candidates.first(where: { $0.id == id }), - parentID == nil || candidates.contains(where: { $0.id == parentID }), - !candidates.contains(where: { $0.id != id && $0.parentID == parentID && $0.name.caseInsensitiveCompare(folder.name) == .orderedSame }) else { return false } - // ponytail: Parent scans are bounded by the saved folder count; use an ID index for large folder catalogs. - var cursor = parentID, visited: Set = [] - while let current = cursor { - guard current != id, visited.insert(current).inserted else { return false } - cursor = candidates.first(where: { $0.id == current })?.parentID - } - return true - } - - @discardableResult - package func moveFolder(_ id: UUID, toParent parentID: UUID?) -> Bool { - guard canMoveFolder(id, toParent: parentID), let index = folders.firstIndex(where: { $0.id == id }) else { - errorMessage = "The selected folder cannot be moved there."; return false - } - var updated = folders - updated[index].parentID = parentID - do { - try connectionStore.saveFolders(updated) - folders = sortedFolders(updated) - errorMessage = nil - return true - } catch { errorMessage = error.localizedDescription; return false } - } - - package func renameFolder(_ folder: DatabaseConnectionFolder, to name: String) -> Bool { - let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty else { - errorMessage = "Folder name is required." - return false - } - guard !folders.contains(where: { $0.id != folder.id && $0.parentID == folder.parentID && $0.name.caseInsensitiveCompare(normalized) == .orderedSame }) else { - errorMessage = "A folder with this name already exists." - return false - } - do { - var updated = folders - guard let index = updated.firstIndex(where: { $0.id == folder.id }) else { return false } - updated[index].name = normalized - try connectionStore.saveFolders(updated) - folders = sortedFolders(updated) - return true - } catch { - errorMessage = error.localizedDescription - return false - } - } - - /// Removing a folder never removes a saved connection. Its connections are - /// moved to the root so credentials and history remain intact. - package func removeFolder(_ folder: DatabaseConnectionFolder) { - do { - let updatedProfiles = profiles.map { profile -> DatabaseProfile in - guard profile.folderID == folder.id else { return profile } - var profile = profile - profile.folderID = folder.parentID - return profile - } - let updatedFolders = folders.compactMap { current -> DatabaseConnectionFolder? in - guard current.id != folder.id else { return nil } - var current = current - if current.parentID == folder.id { current.parentID = folder.parentID } - return current - } - try connectionStore.save(updatedProfiles) - try connectionStore.saveFolders(updatedFolders) - profiles = sortedProfiles(updatedProfiles) - folders = sortedFolders(updatedFolders) - } catch { - errorMessage = error.localizedDescription - } - } - - @discardableResult - package func move(_ profile: DatabaseProfile, toFolder folderID: UUID?) -> Bool { - guard folderID == nil || folders.contains(where: { $0.id == folderID }) else { - errorMessage = "The selected connection folder no longer exists." - return false - } - do { - var updated = profiles - guard let index = updated.firstIndex(where: { $0.id == profile.id }) else { return false } - updated[index].folderID = folderID - updated[index].group = "" - try connectionStore.save(updated) - profiles = sortedProfiles(updated) - errorMessage = nil - return true - } catch { - errorMessage = error.localizedDescription - return false - } - } - - @discardableResult - package func importDBXConnections(plan: DatabaseDBXImportPlan, selectedIDs: Set) -> Int { - errorMessage = nil - var updatedFolders = folders - var folderIDMap: [UUID: UUID] = [:] - for importedFolder in plan.folders { - let targetParentID = importedFolder.parentID.flatMap { folderIDMap[$0] } - if let existing = updatedFolders.first(where: { - $0.parentID == targetParentID && $0.name.caseInsensitiveCompare(importedFolder.name) == .orderedSame - }) { - folderIDMap[importedFolder.id] = existing.id - } else { - let folder = DatabaseConnectionFolder(name: importedFolder.name, parentID: targetParentID) - updatedFolders.append(folder) - folderIDMap[importedFolder.id] = folder.id - } - } - - var updatedProfiles = profiles - var imported: [(profile: DatabaseProfile, password: String)] = [] - for candidate in plan.candidates where selectedIDs.contains(candidate.id) && !candidate.isDuplicate { - var profile = candidate.profile - profile.folderID = profile.folderID.flatMap { folderIDMap[$0] } - let duplicate = updatedProfiles.contains { - $0.name == profile.name && $0.host == profile.host && $0.port == profile.port && $0.path == profile.path - } - guard !duplicate else { continue } - updatedProfiles.append(profile) - imported.append((profile, candidate.password)) - } - guard !imported.isEmpty else { return 0 } - - var writtenPasswordIDs: [UUID] = [] - do { - for item in imported where !item.password.isEmpty { - try connectionStore.savePassword(item.password, for: item.profile.id) - writtenPasswordIDs.append(item.profile.id) - } - try connectionStore.saveFolders(updatedFolders) - try connectionStore.save(updatedProfiles) - folders = sortedFolders(updatedFolders) - profiles = sortedProfiles(updatedProfiles) - return imported.count - } catch { - for id in writtenPasswordIDs { try? connectionStore.deletePassword(for: id) } - try? connectionStore.saveFolders(folders) - try? connectionStore.save(profiles) - errorMessage = error.localizedDescription - return 0 - } - } - - package func disconnect(_ profile: DatabaseProfile) { - invalidateDatabaseTree(for: profile.id) - setConnectionStatus(.idle, for: profile.id) - if selectedProfileID == profile.id { - clearProfileScopedState() - } - } - - package func duplicate(_ profile: DatabaseProfile) -> DatabaseProfile? { - var copy = profile - copy = DatabaseProfile( - name: "\(profile.name) Copy", kind: profile.kind, host: profile.host, port: profile.port, - username: profile.username, database: profile.database, path: profile.path, ssl: profile.ssl, - folderID: profile.folderID, colorHex: profile.colorHex, readOnly: profile.readOnly, - productionProtection: profile.productionProtection, maskSensitiveFields: profile.maskSensitiveFields, - sensitiveColumnPatterns: profile.sensitiveColumnPatterns, caCertificatePath: profile.caCertificatePath, - serverName: profile.serverName, sshHost: profile.sshHost, sshPort: profile.sshPort, - sshUsername: profile.sshUsername, sshKeyPath: profile.sshKeyPath, sshLocalPort: profile.sshLocalPort, - proxyURL: profile.proxyURL - ) - copy.passwordPersistence = profile.passwordPersistence - do { - var updated = profiles - updated.append(copy) - try connectionStore.save(updated) - let password = connectionStore.password(for: profile.id) - if !password.isEmpty { - try connectionStore.savePassword(password, for: copy.id) - } - profiles = sortedProfiles(updated) - return copy - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - package func select(_ profile: DatabaseProfile) async { - activateProfile(profile) - if profile.kind.supportsDataGrid { - await refreshTables(invalidateMetadata: false) - } - } - - package func visibleDatabaseNames(for profileID: UUID) -> [String] { - let names = databaseTrees[profileID]?.names ?? [] - guard let selection = profiles.first(where: { $0.id == profileID })?.visibleDatabases else { return names } - let selected = Set(selection) - return names.filter { selected.contains($0) } - } - - /// A tree scope change saves preferences only; it must not reconnect or navigate the editor. - @discardableResult - package func setVisibleDatabases(_ names: [String]?, for profileID: UUID) -> Bool { - guard let index = profiles.firstIndex(where: { $0.id == profileID }), - profiles[index].kind == .mysql || profiles[index].kind == .mariadb, - databaseTrees[profileID]?.state == .ready, - Set(names ?? []).isSubset(of: Set(databaseTrees[profileID]?.names ?? [])) else { - errorMessage = "The database selection is no longer available. Refresh and try again." - return false - } - do { - var updated = profiles - updated[index].visibleDatabases = names.map { Array(Set($0)).sorted() } - try connectionStore.save(updated) - profiles = updated - let visible = Set(visibleDatabaseNames(for: profileID)) - for name in databaseTrees[profileID]?.databases.keys.filter({ !visible.contains($0) }) ?? [] { - cancelDatabaseTreeLoad(for: profileID, database: name) - } - databaseTrees[profileID]?.databases = databaseTrees[profileID]?.databases.filter { visible.contains($0.key) } ?? [:] - for key in tableTrees.keys.filter({ $0.profileID == profileID && !visible.contains($0.database) }) { - tableTrees.removeValue(forKey: key) - tableMetadataTasks.removeValue(forKey: key)?.cancel() - } - errorMessage = nil - return true - } catch { - errorMessage = error.localizedDescription - return false - } - } - - /// Expanding a connection inspects only its accessible database names, without moving the editor. - package func loadDatabaseTree(for profileID: UUID, force: Bool = false) async { - guard let profile = profiles.first(where: { $0.id == profileID }), - profile.kind == .mysql || profile.kind == .mariadb else { return } - var tree = databaseTrees[profileID] ?? DatabaseConnectionTree() - guard tree.state != .loading, force || tree.state != .ready else { return } - let previousState = tree.state - let previousStatus = connectionStatus(for: profile) - tree.state = .loading - tree.requestID = UUID() - let requestID = tree.requestID - treeRequests.insert(requestID) - defer { treeRequests.remove(requestID) } - databaseTrees[profileID] = tree - setConnectionStatus(.connecting, for: profileID) - let connection = await tableConnection(profile, database: profile.database) - do { - try Task.checkCancellation() - guard databaseTrees[profileID]?.requestID == requestID else { return } - let worker = Task.detached { [operations] in - try Task.checkCancellation() - return try operations.listDatabases(connection: connection) - } - databaseNameTasks[profileID] = worker - defer { - if databaseTrees[profileID]?.requestID == requestID { databaseNameTasks.removeValue(forKey: profileID) } - } - let names = try await withTaskCancellationHandler { - let names = try await worker.value - try Task.checkCancellation() - return names - } onCancel: { - worker.cancel() - } - guard databaseTrees[profileID]?.requestID == requestID else { return } - databaseTrees[profileID]?.names = Array(Set(names)).sorted() - for name in databaseTrees[profileID]?.databases.keys.filter({ !names.contains($0) }) ?? [] { - cancelDatabaseTreeLoad(for: profileID, database: name) - } - databaseTrees[profileID]?.databases = databaseTrees[profileID]?.databases.filter { names.contains($0.key) } ?? [:] - for key in tableTrees.keys.filter({ $0.profileID == profileID && !names.contains($0.database) }) { - tableTrees.removeValue(forKey: key) - tableMetadataTasks.removeValue(forKey: key)?.cancel() - } - databaseTrees[profileID]?.state = .ready - setConnectionStatus(.connected, for: profileID) - } catch { - guard databaseTrees[profileID]?.requestID == requestID else { return } - if Task.isCancelled || error is CancellationError { - databaseTrees[profileID]?.state = previousState - setConnectionStatus(previousStatus, for: profileID) - } else { - databaseTrees[profileID]?.state = .failed(executionError(error)) - setConnectionStatus(.failed, for: profileID) - } - } - } - - /// Each expanded database owns its request token, including after navigation or disconnect. - package func loadDatabaseTree(_ database: String, for profileID: UUID, force: Bool = false) async { - guard let profile = profiles.first(where: { $0.id == profileID }), - databaseTrees[profileID]?.names.contains(database) == true else { return } - var snapshot = databaseTrees[profileID]?.databases[database] ?? DatabaseTreeDatabase() - guard snapshot.state != .loading, force || snapshot.state != .ready else { return } - let previousState = snapshot.state - snapshot.state = .loading - snapshot.requestID = UUID() - let requestID = snapshot.requestID - treeRequests.insert(requestID) - defer { treeRequests.remove(requestID) } - databaseTrees[profileID]?.databases[database] = snapshot - let target = await tableConnection(profile, database: database) - do { - try Task.checkCancellation() - guard databaseTrees[profileID]?.databases[database]?.requestID == requestID else { return } - let key = DatabaseTreeDatabaseKey(profileID: profileID, database: database) - let worker = Task.detached { [operations] in - try Task.checkCancellation() - let rows = try operations.listTables(connection: target, schema: "") - try Task.checkCancellation() - let objects = try await Self.loadObjects(operations: operations, connection: target) - try Task.checkCancellation() - let tables = rows.compactMap { $0["table_name"]?.text ?? $0["TABLE_NAME"]?.text ?? $0["name"]?.text } - return DatabaseTreeDatabase(state: .ready, tables: tables, objects: objects) - } - databaseStructureTasks[key] = worker - defer { - if databaseTrees[profileID]?.databases[database]?.requestID == requestID { databaseStructureTasks.removeValue(forKey: key) } - } - var loaded = try await withTaskCancellationHandler { - let loaded = try await worker.value - try Task.checkCancellation() - return loaded - } onCancel: { - worker.cancel() - } - guard databaseTrees[profileID]?.databases[database]?.requestID == requestID else { return } - loaded.requestID = requestID - databaseTrees[profileID]?.databases[database] = loaded - for tableKey in tableTrees.keys.filter({ $0.profileID == profileID && $0.database == database && !loaded.tables.contains($0.table) }) { - tableTrees.removeValue(forKey: tableKey) - tableMetadataTasks.removeValue(forKey: tableKey)?.cancel() - } - } catch { - guard databaseTrees[profileID]?.databases[database]?.requestID == requestID else { return } - databaseTrees[profileID]?.databases[database]?.state = Task.isCancelled || error is CancellationError - ? previousState : .failed(executionError(error)) - } - } - - /// Refresh only inspected structure; row data and the active editor remain unchanged. - package func refreshDatabaseTree(for profileID: UUID, database: String? = nil, force: Bool = false) async { - guard let profile = profiles.first(where: { $0.id == profileID }), - profile.kind == .mysql || profile.kind == .mariadb else { return } - let tables = tableTrees.keys.filter { $0.profileID == profileID && (database == nil || $0.database == database) } - let databases = database.map { [$0] } ?? Set(Array((databaseTrees[profileID]?.databases ?? [:]).keys) + tables.map(\.database)).sorted() - if force { invalidateDatabaseTree(for: profileID) } - if database == nil || force { await loadDatabaseTree(for: profileID, force: true) } - guard databaseTrees[profileID]?.state == .ready else { return } - let visible = Set(visibleDatabaseNames(for: profileID)) - for name in databases where database != nil || visible.contains(name) { - await loadDatabaseTree(name, for: profileID, force: true) - } - for key in tables { - guard database != nil || visible.contains(key.database) else { continue } - guard databaseTrees[profileID]?.databases[key.database]?.state == .ready, - databaseTrees[profileID]?.databases[key.database]?.tables.contains(key.table) == true else { continue } - await loadTableTree(key, force: true) - } - } - - private func invalidateDatabaseTree(for profileID: UUID) { - cancelDatabaseTreeLoad(for: profileID) - databaseTrees.removeValue(forKey: profileID) - for key in tableTrees.keys.filter({ $0.profileID == profileID }) { - tableTrees.removeValue(forKey: key) - tableMetadataTasks.removeValue(forKey: key)?.cancel() - } - } - - /// Collapsed nodes keep completed metadata but stop requests no longer visible in the tree. - package func cancelDatabaseTreeLoad(for profileID: UUID, database: String? = nil) { - for key in tableMetadataTasks.keys.filter({ $0.profileID == profileID && (database == nil || $0.database == database) }) { - cancelTableTreeLoad(key) - } - if let database { - databaseStructureTasks.removeValue(forKey: DatabaseTreeDatabaseKey(profileID: profileID, database: database))?.cancel() - if databaseTrees[profileID]?.databases[database]?.state == .loading { - databaseTrees[profileID]?.databases[database]?.state = .idle - databaseTrees[profileID]?.databases[database]?.requestID = UUID() - } - return - } - databaseNameTasks.removeValue(forKey: profileID)?.cancel() - for key in databaseStructureTasks.keys.filter({ $0.profileID == profileID }) { - databaseStructureTasks.removeValue(forKey: key)?.cancel() - } - if databaseTrees[profileID]?.state == .loading { - databaseTrees[profileID]?.state = .idle - databaseTrees[profileID]?.requestID = UUID() - setConnectionStatus(.idle, for: profileID) - } - for name in databaseTrees[profileID]?.databases.keys.map({ $0 }) ?? [] where databaseTrees[profileID]?.databases[name]?.state == .loading { - databaseTrees[profileID]?.databases[name]?.state = .idle - databaseTrees[profileID]?.databases[name]?.requestID = UUID() - } - } - - package func loadTableTree(_ key: DatabaseTreeTableKey, force: Bool = false) async { - guard let profile = profiles.first(where: { $0.id == key.profileID }) else { return } - do { _ = try await tableMetadata(key, profile: profile, force: force) } - catch { - // The node owns the failed state and Retry action; do not replace an unrelated editor error. - } - } - - /// A collapsed tree node relinquishes its request unless the active editor is using it. - package func cancelTableTreeLoad(_ key: DatabaseTreeTableKey) { - guard !(isLoading && selectedProfileID == key.profileID && selectedProfile?.database == key.database && selectedTable == key.table), - let task = tableMetadataTasks.removeValue(forKey: key) else { return } - task.cancel() - if tableTrees[key]?.state == .loading { - tableTrees[key]?.state = .idle - tableTrees[key]?.requestID = UUID() - } - } - - private func tableConnection(_ profile: DatabaseProfile, database: String) async -> DatabaseConnection { - var target = connection(profile, password: "") - target.database = database - let base = target - // Keychain access is synchronous too; keep it off the UI actor along with database I/O. - return await Task.detached { [connectionStore] in - var target = base - target.password = connectionStore.password(for: profile.id) - return target - }.value - } - - private func tableMetadata(_ key: DatabaseTreeTableKey, profile: DatabaseProfile, force: Bool = false) async throws -> DatabaseTreeTable { - if let task = tableMetadataTasks[key] { - if force { - task.cancel() - tableMetadataTasks.removeValue(forKey: key) - } else { - return try await task.value - } - } - if !force, let cached = tableTrees[key], cached.state == .ready { return cached } - var snapshot = tableTrees[key] ?? DatabaseTreeTable() - snapshot.state = .loading - snapshot.requestID = UUID() - let requestID = snapshot.requestID - tableTrees[key] = snapshot - treeRequests.insert(requestID) - // Each key owns one worker. Collapsing its view does not cancel another consumer's request. - let base = connection(profile, password: "") - let task = Task.detached { [operations, connectionStore] in - try Task.checkCancellation() - var connection = base - connection.database = key.database - connection.password = connectionStore.password(for: key.profileID) - let target = connection - async let columns = operations.describeTable(connection: target, schema: "", table: key.table) - async let indexes = operations.listIndexes(connection: target, schema: "", table: key.table) - async let foreignKeys = operations.listForeignKeys(connection: target, schema: "", table: key.table) - let loaded = try await (columns, indexes, foreignKeys) - var result = DatabaseTreeTable() - result.state = .ready - result.columns = loaded.0 - result.indexes = loaded.1 - result.foreignKeys = loaded.2 - result.requestID = requestID - return result - } - tableMetadataTasks[key] = task - defer { - treeRequests.remove(requestID) - if tableTrees[key]?.requestID == requestID { tableMetadataTasks.removeValue(forKey: key) } - } - do { - let loaded = try await task.value - if tableTrees[key]?.requestID == requestID { tableTrees[key] = loaded } - return loaded - } catch { - if tableTrees[key]?.requestID == requestID { tableTrees[key]?.state = .failed(executionError(error)) } - throw error - } - } - - /// Resolve a tree action against its database before invoking existing editor or mutation actions. - package func selectDatabaseContext(_ profile: DatabaseProfile) async -> Bool { - guard let current = profiles.first(where: { $0.id == profile.id }) else { return false } - if current.kind == .mysql || current.kind == .mariadb { - if current.database == profile.database { - if selectedProfileID != profile.id { await select(current) } - return selectedProfileID == profile.id - } - await loadDatabaseTree(for: profile.id) - guard databaseTrees[profile.id]?.names.contains(profile.database) == true else { - errorMessage = "The selected database is no longer accessible. Refresh the connection." - return false - } - if selectedProfileID != profile.id { activateProfile(current) } - databaseOptions = databaseTrees[profile.id]?.names ?? [] - await selectDatabase(profile.database, for: current) - return selectedProfileID == profile.id && selectedProfile?.database == profile.database - } - if selectedProfileID != profile.id { await select(current) } - return selectedProfileID == profile.id - } - - // Refreshing a background connection must not navigate the active editor. - package func refresh(_ profile: DatabaseProfile) async { - if selectedProfileID == profile.id { - if profile.kind.supportsDataGrid { await refreshTables() } - else if profile.kind == .redis { await loadRedisKeys(pattern: "") } - else { await loadNacosConfigs(dataId: "", group: ""); await loadNacosServices(serviceName: "", group: "") } - } else { - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - try await Task.detached { [operations] in try operations.testConnection(connection) }.value - guard profiles.contains(where: { $0.id == profile.id }) else { return } - setConnectionStatus(.connected, for: profile.id) - } catch { - guard profiles.contains(where: { $0.id == profile.id }) else { return } - setConnectionStatus(.failed, for: profile.id) - errorMessage = executionError(error) - } - } - } - - package func refreshTables(invalidateMetadata: Bool = true) async { - if let id = selectedProfileID, let applyingGeneration = applyingTableProfileGenerations[id] { - // Returning to a connection needs a fresh load after its older write releases the lock. - if applyingGeneration != profileGeneration { deferredTableRefreshProfileIDs.insert(id) } - return - } - guard let profile = selectedProfile else { - tables = [] - databaseOptions = [] - return - } - guard profile.kind.supportsDataGrid else { - tables = [] - databaseOptions = [] - return - } - if invalidateMetadata { - for key in tableTrees.keys.filter({ $0.profileID == profile.id && $0.database == profile.database }) { - if tableMetadataTasks[key] == nil { tableTrees.removeValue(forKey: key) } - } - } - let generation = profileGeneration - let requestID = UUID() - let tableToRefresh = selectedTable - tableListRequestID = requestID - let databaseRequestID: UUID? = profile.kind == .mysql || profile.kind == .mariadb ? requestID : nil - databaseListRequestID = databaseRequestID - isLoading = true; errorMessage = nil - tables = []; objects = [:] - if tableToRefresh != nil, !hasTableEdits { - rows = []; sourceRows = []; totalRows = 0; currentOffset = 0 - } - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - if profile.kind == .mysql || profile.kind == .mariadb { - let databaseRows = try await Task.detached { [operations] in - try operations.listDatabases(connection: connection) - }.value - guard isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID else { return } - databaseOptions = databaseRows - var tree = databaseTrees[profile.id] ?? DatabaseConnectionTree() - tree.names = Array(Set(databaseRows)).sorted() - tree.databases = tree.databases.filter { databaseRows.contains($0.key) } - if tree.state != .loading { tree.state = .ready } - databaseTrees[profile.id] = tree - // A server connection need not choose a default database just to browse its tree. - if profile.database.isEmpty { - objects = [:] - setConnectionStatus(.connected, for: profile.id) - isLoading = false - return - } - } else { - databaseOptions = [] - } - let result = try await Task.detached { [operations] in try operations.listTables(connection: connection, schema: "") }.value - guard isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID else { return } - setConnectionStatus(.connected, for: profile.id) - tables = result.compactMap { row in - row["table_name"]?.text ?? row["TABLE_NAME"]?.text ?? row["name"]?.text - } - if profile.kind.isSQLDatabase { - await refreshObjects(profileID: profile.id, generation: generation, tableRequestID: requestID, databaseRequestID: databaseRequestID) - } - guard isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID else { return } - if profile.kind == .mysql || profile.kind == .mariadb { - var tree = databaseTrees[profile.id] ?? DatabaseConnectionTree() - tree.names = Array(Set(databaseOptions)).sorted() - tree.databases = tree.databases.filter { databaseOptions.contains($0.key) } - if tree.state != .loading { tree.state = .ready } - if !profile.database.isEmpty, databaseOptions.contains(profile.database) { - tree.databases[profile.database] = DatabaseTreeDatabase( - state: errorMessage.map { .failed($0) } ?? .ready, tables: tables, objects: objects - ) - } - databaseTrees[profile.id] = tree - } - if let tableToRefresh, selectedTable == tableToRefresh { - if tables.contains(tableToRefresh) { - // Keep the original row/key snapshot while edits are pending, including - // refreshes caused by another console. The explicit refresh action clears edits. - if !hasTableEdits { await openTable(tableToRefresh) } - } else { - selectedTable = nil - rows = []; sourceRows = []; columns = []; columnTypes = [:] - primaryKeyColumns = []; indexes = []; foreignKeys = []; totalRows = 0; currentOffset = 0 - } - } - } catch { - guard isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID else { return } - tables = []; objects = [:]; databaseOptions = [] - errorMessage = executionError(error) - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID { isLoading = false } - } - - package func refreshDatabases() async { - guard let profile = selectedProfile, profile.kind == .mysql || profile.kind == .mariadb else { return } - let generation = profileGeneration - let requestID = UUID() - databaseListRequestID = requestID - isLoading = true - errorMessage = nil - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - let rows = try await Task.detached { [operations] in - try operations.listDatabases(connection: connection) - }.value - guard isCurrent(profileID: profile.id, generation: generation), databaseListRequestID == requestID else { return } - databaseOptions = rows - setConnectionStatus(.connected, for: profile.id) - isLoading = false - } catch { - if isCurrent(profileID: profile.id, generation: generation), databaseListRequestID == requestID { - databaseOptions = [] - errorMessage = executionError(error) - setConnectionStatus(.failed, for: profile.id) - isLoading = false - } - } - } - - package func selectDatabase(_ database: String, for profile: DatabaseProfile) async { - guard selectedProfileID == profile.id, - (profile.kind == .mysql || profile.kind == .mariadb), - databaseOptions.contains(database) else { return } - var updated = profiles - guard let index = updated.firstIndex(where: { $0.id == profile.id }) else { return } - var updatedProfile = updated[index] - updatedProfile.database = database - updated[index] = updatedProfile - do { - try connectionStore.save(updated) - selectedTable = nil - profiles = sortedProfiles(updated) - activateProfile(updatedProfile) - await refreshTables(invalidateMetadata: false) - } catch { - errorMessage = executionError(error) - } - } - - private func refreshObjects(profileID: UUID, generation: UInt64, tableRequestID: UUID, databaseRequestID: UUID?) async { - guard isCurrent(profileID: profileID, generation: generation), - self.tableListRequestID == tableRequestID, - self.databaseListRequestID == databaseRequestID, - let profile = profiles.first(where: { $0.id == profileID }) else { return } - let connection = connection(profile) - do { - let loaded = try await Self.loadObjects(operations: operations, connection: connection) - guard isCurrent(profileID: profileID, generation: generation), self.tableListRequestID == tableRequestID, self.databaseListRequestID == databaseRequestID else { return } - objects = loaded - } catch { - if isCurrent(profileID: profileID, generation: generation), self.tableListRequestID == tableRequestID, self.databaseListRequestID == databaseRequestID { - objects = [:] - errorMessage = executionError(error) - } - } - } - - nonisolated private static func loadObjects(operations: any DatabaseOperations, connection: DatabaseConnection) async throws -> [DatabaseObjectKind: [DatabaseRow]] { - try await withThrowingTaskGroup(of: (DatabaseObjectKind, [DatabaseRow]).self) { group in - for kind in DatabaseObjectKind.allCases { - group.addTask { - (kind, try operations.listObjects(connection: connection, schema: "", kind: kind)) - } - } - var result: [DatabaseObjectKind: [DatabaseRow]] = [:] - for try await (kind, rows) in group { result[kind] = rows } - return result - } - } - - package func loadSchemaSnapshot(profileID: UUID) async -> DatabaseSchemaSnapshot? { - guard let profile = profiles.first(where: { $0.id == profileID }) else { - errorMessage = "The selected database connection no longer exists." - return nil - } - let generation = profileGeneration - let tracksUI = selectedProfileID == profileID - if tracksUI { - isLoading = true - errorMessage = nil - } - do { - let connection = connection(profile) - let tableRows = try await Task.detached { [operations] in - try operations.listTables(connection: connection, schema: "") - }.value - let tableNames = tableRows.compactMap { row -> String? in - let type = row["table_type"]?.text ?? row["TABLE_TYPE"]?.text ?? "" - guard type.isEmpty || type.caseInsensitiveCompare("BASE TABLE") == .orderedSame || type.caseInsensitiveCompare("table") == .orderedSame else { return nil } - return row["table_name"]?.text ?? row["TABLE_NAME"]?.text ?? row["name"]?.text - } - let tables = try await withThrowingTaskGroup(of: DatabaseSchemaTableSnapshot.self) { group in - for table in tableNames { - group.addTask { [operations] in - let description = try operations.describeTable(connection: connection, schema: "", table: table) - let indexes = try operations.listIndexes(connection: connection, schema: "", table: table) - let foreignKeys = try operations.listForeignKeys(connection: connection, schema: "", table: table) - return DatabaseSchemaTableSnapshot( - name: table, - columns: description.compactMap { row in - guard let name = row["column_name"]?.text ?? row["COLUMN_NAME"]?.text ?? row["name"]?.text else { return nil } - let dataType = row["data_type"]?.text ?? row["DATA_TYPE"]?.text ?? row["type"]?.text ?? "" - let nullable = if let value = row["is_nullable"]?.text ?? row["IS_NULLABLE"]?.text { - value.caseInsensitiveCompare("NO") != .orderedSame - } else if let notNull = row["notnull"]?.integerValue { - notNull == 0 - } else { - true - } - let defaultValue = row["column_default"]?.text ?? row["COLUMN_DEFAULT"]?.text ?? row["dflt_value"]?.text - let isPrimary = row["column_key"]?.text == "PRI" || row["COLUMN_KEY"]?.text == "PRI" || (row["pk"]?.integerValue ?? 0) > 0 - return DatabaseSchemaColumnSnapshot(name: name, dataType: dataType, isNullable: nullable, defaultValue: defaultValue, isPrimaryKey: isPrimary) - }, - indexes: indexes.compactMap { row in - guard let name = row["index_name"]?.text ?? row["INDEX_NAME"]?.text ?? row["name"]?.text else { return nil } - let definition = row["definition"]?.text ?? row["index_definition"]?.text ?? "" - return DatabaseSchemaIndexSnapshot(name: name, definition: definition) - }, - foreignKeys: foreignKeys.compactMap { row in - guard let name = row["constraint_name"]?.text ?? row["CONSTRAINT_NAME"]?.text ?? row["id"]?.text else { return nil } - guard let column = row["column_name"]?.text ?? row["COLUMN_NAME"]?.text ?? row["from"]?.text else { return nil } - guard let referencedTable = row["referenced_table_name"]?.text ?? row["REFERENCED_TABLE_NAME"]?.text ?? row["table"]?.text else { return nil } - guard let referencedColumn = row["referenced_column_name"]?.text ?? row["REFERENCED_COLUMN_NAME"]?.text ?? row["to"]?.text else { return nil } - return DatabaseSchemaForeignKeySnapshot(name: name, column: column, referencedTable: referencedTable, referencedColumn: referencedColumn) - } - ) - } - } - var snapshots: [DatabaseSchemaTableSnapshot] = [] - for try await table in group { snapshots.append(table) } - return snapshots.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - if tracksUI && !isCurrent(profileID: profileID, generation: generation) { return nil } - if tracksUI { isLoading = false } - return DatabaseSchemaSnapshot(profileID: profile.id, profileName: profile.name, kind: profile.kind, schema: "", tables: tables) - } catch { - if tracksUI && isCurrent(profileID: profileID, generation: generation) { - errorMessage = executionError(error) - isLoading = false - } - return nil - } - } - - package func applySchemaMigration(_ diff: DatabaseSchemaDiffResult, targetProfileID: UUID, confirmed: Bool = false) async -> Bool { - guard let profile = profiles.first(where: { $0.id == targetProfileID }) else { - errorMessage = "The target database connection no longer exists." - return false - } - guard diff.target.profileID == targetProfileID else { - errorMessage = "The migration target does not match the selected connection." - return false - } - guard diff.source.kind == diff.target.kind else { - errorMessage = "Automatic migration requires source and target connections to use the same database engine." - return false - } - guard !diff.items.isEmpty else { return true } - guard !diff.requiresConfirmation || confirmed else { - errorMessage = "The schema migration contains destructive changes and needs confirmation." - return false - } - if diff.items.contains(where: { $0.sql.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("--") }) { - errorMessage = "This migration contains a review-only SQL step and cannot be applied automatically." - return false - } - let generation = profileGeneration - let tracksUI = selectedProfileID == targetProfileID - if tracksUI { - isLoading = true - errorMessage = nil - } - do { - let recoveryPoint = try await createRecoveryPoint(profile: profile, reason: "Before schema migration") - let connection = connection(profile) - for item in diff.items { - for statement in DatabaseSchemaDiffEngine.statements(in: item.sql) where !statement.hasPrefix("--") { - _ = try await Task.detached { [operations] in - try operations.execute(connection: connection, sql: statement, values: [], confirmed: confirmed, allowWrite: false) - }.value - } - } - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "schemaMigration", summary: "Applied schema migration from \(diff.source.profileName)", createdAt: Date(), recoveryPointID: recoveryPoint.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - if tracksUI && isCurrent(profileID: targetProfileID, generation: generation) { - await refreshTables() - if let table = selectedTable { await openTable(table) } - isLoading = false - } - return true - } catch { - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "schemaMigration", summary: "Schema migration failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if tracksUI && isCurrent(profileID: targetProfileID, generation: generation) { - errorMessage = sanitizedError - isLoading = false - } - return false - } - } - - package func applySchemaChange(_ change: DatabaseSchemaChange, confirmed: Bool = false) async -> Bool { - guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return false } - let generation = profileGeneration - isLoading = true; errorMessage = nil - do { - let connection = connection(profile) - let recoveryPoint = try await createRecoveryPoint(profile: profile, reason: "Before schema change") - _ = try await Task.detached { [operations] in - try operations.applySchemaChange(connection: connection, schema: "", change: change, confirmed: confirmed, allowWrite: false) - }.value - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "schemaChange", summary: "Applied \(change.operation)", createdAt: Date(), recoveryPointID: recoveryPoint.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - await refreshTables() - if let table = selectedTable { await openTable(table) } - return true - } catch { - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "schemaChange", summary: "Schema change failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = sanitizedError - isLoading = false - } - return false - } - } - - package func explainSQL(_ sql: String, format: String = "json") async -> DatabaseQueryResult? { - guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return nil } - let generation = profileGeneration - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.explain(connection: connection, sql: sql, format: format) - }.value - guard isCurrent(profileID: profile.id, generation: generation) else { return nil } - let displayResult = masked(result) - lastExplainResult = displayResult - return displayResult - } catch { - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = executionError(error) - } - return nil - } - } - - package func loadDiagnostics(_ request: DatabaseDiagnosticsRequest) async -> DatabaseQueryResult? { - guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return nil } - let generation = profileGeneration - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.diagnostics(connection: connection, request: request) - }.value - guard isCurrent(profileID: profile.id, generation: generation) else { return nil } - let displayResult = masked(result) - lastDiagnostics = displayResult - return displayResult - } catch { - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = executionError(error) - } - return nil - } - } - - package func selectTable(_ table: String) { - if selectedTable != table { - selectedTable = table - rows = []; sourceRows = []; columns = []; columnTypes = [:] - primaryKeyColumns = []; indexes = []; foreignKeys = []; totalRows = 0; currentOffset = 0 - } - isLoading = true - if isApplyingTableEdits, let id = selectedProfileID { - deferredTableRefreshProfileIDs.insert(id) - } - } - - package func openTable(_ table: String) async { - guard selectedProfile != nil else { return } - selectTable(table) - guard !isApplyingTableEdits else { return } - await reloadTable(table) - } - - private func reloadTable(_ table: String) async { - guard let profile = selectedProfile else { return } - let generation = profileGeneration - let requestID = UUID() - selectedTable = table - tableRequestID = requestID - isLoading = true; errorMessage = nil - do { - let connection = await tableConnection(profile, database: profile.database) - guard isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID else { return } - let key = DatabaseTreeTableKey(profileID: profile.id, database: profile.database, table: table) - async let metadata = tableMetadata(key, profile: profile) - async let page = Task.detached { [operations, pageSize] in try operations.pageTable(connection: connection, schema: "", table: table, limit: pageSize, offset: 0, filters: [], sort: []) }.value - let (structure, result) = try await (metadata, page) - let description = structure.columns - let loadedIndexes = structure.indexes - let loadedForeignKeys = structure.foreignKeys - guard isCurrent(profileID: profile.id, generation: generation), - selectedTable == table, - tableRequestID == requestID else { return } - columns = description.compactMap { $0["column_name"]?.text ?? $0["COLUMN_NAME"]?.text ?? $0["name"]?.text } - columnTypes = Dictionary(uniqueKeysWithValues: description.compactMap { row in - guard let name = row["column_name"]?.text ?? row["COLUMN_NAME"]?.text ?? row["name"]?.text else { return nil } - let type = row["data_type"]?.text ?? row["DATA_TYPE"]?.text ?? row["type"]?.text ?? "" - return (name, type.lowercased()) - }) - primaryKeyColumns = description.compactMap { row in - let name = row["column_name"]?.text ?? row["COLUMN_NAME"]?.text ?? row["name"]?.text - let mysqlPrimary = row["column_key"]?.text == "PRI" || row["COLUMN_KEY"]?.text == "PRI" - let sqlitePrimary = row["pk"]?.integerValue ?? 0 > 0 - return mysqlPrimary || sqlitePrimary ? name : nil - } - if columns.isEmpty { columns = result.rows.first.map { Array($0.keys).sorted() } ?? [] } - discardTableEdits() - sourceRows = result.rows - rows = masked(result.rows) - totalRows = result.totalRows ?? Int64(result.rows.count) - indexes = loadedIndexes; foreignKeys = loadedForeignKeys - currentOffset = 0 - } catch { - if isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID { - discardTableEdits() - errorMessage = executionError(error) - rows = []; sourceRows = []; columns = []; columnTypes = [:]; indexes = []; foreignKeys = []; totalRows = 0 - } - } - if isCurrent(profileID: profile.id, generation: generation), tableRequestID == requestID { isLoading = false } - } - - package func loadPage(filters: [DatabaseFilter], sort: [DatabaseSort], offset: Int) async { - guard !isApplyingTableEdits else { return } - guard let profile = selectedProfile, let table = selectedTable else { return } - let generation = profileGeneration - let requestID = UUID() - tableRequestID = requestID - isLoading = true; errorMessage = nil - do { - let connection = connection(profile) - let result = try await Task.detached { [operations, pageSize] in - try operations.pageTable(connection: connection, schema: "", table: table, limit: pageSize, offset: max(0, offset), filters: filters, sort: sort) - }.value - guard isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID else { return } - discardTableEdits() - sourceRows = result.rows - rows = masked(result.rows) - totalRows = result.totalRows ?? Int64(result.rows.count); currentOffset = max(0, offset) - } catch { - if isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID { - discardTableEdits() - errorMessage = executionError(error) - rows = []; sourceRows = []; totalRows = 0 - } - } - if isCurrent(profileID: profile.id, generation: generation), tableRequestID == requestID { isLoading = false } - } - - package func apply(drafts: [DatabaseCellDraft], insertedRows: [DatabaseRow], deletedIndexes: Set, confirmed: Bool = false) async -> Bool { - guard !isLoading, !isApplyingTableEdits, let profile = selectedProfile, let table = selectedTable else { return false } - let requestID = tableRequestID - let generation = profileGeneration - var changesByRow: [Int: DatabaseRow] = [:] - for draft in drafts { changesByRow[draft.rowIndex, default: [:]][draft.column] = typedValue(draft.value, for: draft.column) } - var mutations = insertedRows.map { row in - DatabaseMutation(action: .insert, table: table, values: row.mapValuesWithKeys { column, value in typedValue(value, for: column) }) - } - for (index, values) in changesByRow where !deletedIndexes.contains(index) { - guard sourceRows.indices.contains(index), let key = keyValues(sourceRows[index]) else { tableApplyFailed = true; errorMessage = "This table needs a primary key before rows can be edited."; return false } - mutations.append(DatabaseMutation(action: .update, table: table, values: values, key: key)) - } - for index in deletedIndexes { - guard sourceRows.indices.contains(index), let key = keyValues(sourceRows[index]) else { tableApplyFailed = true; errorMessage = "This table needs a primary key before rows can be deleted."; return false } - mutations.append(DatabaseMutation(action: .delete, table: table, key: key)) - } - guard !mutations.isEmpty else { return true } - applyingTableProfileGenerations[profile.id] = generation - isLoading = true; errorMessage = nil - let succeeded: Bool - do { - let connection = connection(profile) - let recoveryPoint = profile.kind == .mongodb ? nil : try await createRecoveryPoint(profile: profile, reason: "Before table edit") - _ = try await Task.detached { [operations] in try operations.applyChanges(connection: connection, schema: "", mutations: mutations, confirmed: confirmed, allowWrite: false) }.value - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "tableEdit", summary: "Applied table cell changes", createdAt: Date(), recoveryPointID: recoveryPoint?.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - if isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID { - discardTableEdits() - await reloadTable(table) - } - succeeded = true - } catch { - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "tableEdit", summary: "Table cell changes failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation), selectedTable == table, tableRequestID == requestID { - tableApplyFailed = true - errorMessage = sanitizedError - isLoading = false - } - succeeded = false - } - applyingTableProfileGenerations.removeValue(forKey: profile.id) - if deferredTableRefreshProfileIDs.remove(profile.id) != nil, selectedProfileID == profile.id { - await refreshTables() - } - return succeeded - } - - package var hasTableEdits: Bool { !tableCellDrafts.isEmpty || !tableInsertedRows.isEmpty || !tableDeletedRows.isEmpty } - - package func discardTableEdits() { - storedTableCellDrafts = [:]; storedTableInsertedRows = []; storedTableDeletedRows = []; tableApplyFailed = false - } - - package func exportData(format: DatabaseTransferFormat) async -> Data? { - guard let profile = selectedProfile else { return nil } - let connection = connection(profile) - let table = selectedTable - let generation = profileGeneration - do { - let data = try await Task.detached { [operations, table] in - switch format { - case .csv, .json: - guard let table else { throw DatabaseTransferError.tableRequired } - let sql = "SELECT * FROM \(Self.quotedIdentifier(table, for: connection.kind))" - return format == .csv - ? try operations.exportCSV(connection: connection, sql: sql, values: [], limit: 100_000) - : try operations.exportJSON(connection: connection, sql: sql, values: [], limit: 100_000) - case .sql: - return try operations.exportSQL(connection: connection, options: DatabaseSQLExportOptions()) - } - }.value - return data - } catch { - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = executionError(error) - } - return nil - } - } - - package func exportDataFile(format: DatabaseTransferFormat) async -> URL? { - guard format == .sql, let profile = selectedProfile else { return nil } - let generation = profileGeneration - let outputURL = fileStorage.temporaryDirectory().appendingPathComponent("lithe-database-\(UUID().uuidString).sql") - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.exportSQLToFile( - connection: connection, - options: DatabaseSQLExportOptions(schema: "", selectedTables: [], includeStructure: true, includeData: true, limit: 0), - outputURL: outputURL - ) - }.value - guard result.path == outputURL.path, fileStorage.fileExists(at: outputURL) else { - throw DatabaseSidecarError.invalidResponse("Database backup file was not created") - } - return outputURL - } catch { - try? fileStorage.removeItem(at: outputURL) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = executionError(error) - } - return nil - } - } - - package func prepareImportFile(from url: URL) throws -> URL { - let destination = fileStorage.temporaryDirectory().appendingPathComponent("lithe-import-\(UUID().uuidString).sql") - try fileStorage.copyItem(at: url, to: destination) - return destination - } - - package func readImportData(from url: URL) throws -> Data { - try fileStorage.readData(from: url) - } - - package func removeTemporaryFile(_ url: URL) { - try? fileStorage.removeItem(at: url) - } - - package func importData(_ data: Data, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { - guard let profile = selectedProfile else { return false } - let generation = profileGeneration - let table = selectedTable - isLoading = true; errorMessage = nil - do { - let connection = connection(profile) - let recoveryPoint = try await createRecoveryPoint(profile: profile, reason: "Before import") - _ = try await Task.detached { [operations, table] in - switch format { - case .csv: - guard let table else { throw DatabaseTransferError.tableRequired } - return try operations.importCSV(connection: connection, schema: "", table: table, data: data) - case .json: - guard let table else { throw DatabaseTransferError.tableRequired } - return try operations.importJSON(connection: connection, schema: "", table: table, data: data) - case .sql: - return try operations.restoreSQL(connection: connection, data: data, confirmed: confirmed, allowWrite: false) - } - }.value - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "import", summary: "Imported \(format.rawValue) data", createdAt: Date(), recoveryPointID: recoveryPoint.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - if let table { await openTable(table) } else { await refreshTables() } - return true - } catch { - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "import", summary: "Import failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = sanitizedError - isLoading = false - } - return false - } - } - - package func importDataFile(_ fileURL: URL, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { - guard format == .sql, let profile = selectedProfile else { return false } - let generation = profileGeneration - let table = selectedTable - isLoading = true - errorMessage = nil - do { - let connection = connection(profile) - let recoveryPoint = try await createRecoveryPoint(profile: profile, reason: "Before SQL import") - _ = try await Task.detached { [operations] in - try operations.restoreSQLFile(connection: connection, fileURL: fileURL, confirmed: confirmed, allowWrite: false) - }.value - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "import", summary: "Imported SQL backup", createdAt: Date(), recoveryPointID: recoveryPoint.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - if let table { await openTable(table) } else { await refreshTables() } - return true - } catch { - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "import", summary: "SQL import failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = sanitizedError - isLoading = false - } - return false - } - } - - package var selectedSQLTab: DatabaseSQLTab? { sqlTabs.first { $0.id == selectedSQLTabID } } - - package var sqlCompletionItems: [String] { - let keywords = [ - "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", - "JOIN", "LEFT JOIN", "ORDER BY", "GROUP BY", "LIMIT", "EXPLAIN", "SHOW", "DESCRIBE", "BEGIN", "COMMIT", "ROLLBACK" - ] - var seen = Set() - return (keywords + tables + columns).filter { seen.insert($0.lowercased()).inserted }.sorted { - $0.localizedCaseInsensitiveCompare($1) == .orderedAscending - } - } - - package func addSQLTab(sql: String = "") { - let tab = DatabaseSQLTab(title: "Query \(sqlTabs.count + 1)", sql: sql) - sqlTabs.append(tab) - selectedSQLTabID = tab.id - } - - package func sqlTab(_ id: UUID, profileID: UUID) -> DatabaseSQLTab? { - guard profiles.contains(where: { $0.id == profileID }) else { return nil } - let tabs = selectedProfileID == profileID ? sqlTabs : workspaceSelections[profileID]?.sqlTabs ?? [] - return tabs.first { $0.id == id } - } - - package func closeSQLTab(_ id: UUID, profileID: UUID? = nil) { - if let profileID, profileID != selectedProfileID { - guard var saved = workspaceSelections[profileID] else { return } - saved.sqlTabs.removeAll { $0.id == id } - if saved.sqlTabs.isEmpty { saved.sqlTabs = [DatabaseSQLTab(title: "Query 1")] } - if saved.sqlTabID == id { saved.sqlTabID = saved.sqlTabs.first?.id } - workspaceSelections[profileID] = saved - return - } - guard sqlTabs.count > 1 else { - updateSQLTab(id) { tab in - tab.sql = ""; tab.result = nil; tab.resultColumns = []; tab.rowsAffected = nil; tab.execution = nil; tab.errorMessage = nil - tab.statementExecutions = [] - } - return - } - guard let index = sqlTabs.firstIndex(where: { $0.id == id }) else { return } - sqlTabs.remove(at: index) - if selectedSQLTabID == id { selectedSQLTabID = sqlTabs[max(0, index - 1)].id } - } - - package func updateSQL(_ sql: String, in tabID: UUID) { - updateSQLTab(tabID) { tab in - rebaseSQLExecutions(&tab.statementExecutions, from: tab.sql, to: sql) - tab.sql = sql - tab.errorMessage = nil - } - } - - package func clearSQLOutput(in tabID: UUID) { - updateSQLTab(tabID) { $0.outputHiddenThroughExecutionID = $0.statementExecutions.last?.id } - } - - package func dismissSQLError(in tabID: UUID) { - updateSQLTab(tabID) { $0.errorMessage = nil } - } - - package func formatSQL(in tabID: UUID) { - guard let tab = sqlTabs.first(where: { $0.id == tabID }) else { return } - updateSQL(DatabaseSQLFormatter.format(tab.sql), in: tabID) - } - - package func analysis(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) -> DatabaseSQLAnalysis { - DatabaseSQLAnalyzer.analyze(sql(for: tabID, scope: scope)) - } - - /// Returns the analysis the user must confirm before running, or nil when the SQL may run directly. - /// The database helper owns production protection, so it is asked before the first statement runs; - /// otherwise a statement it rejects would stop the batch after earlier statements were applied. - package func sqlConfirmation(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) async -> DatabaseSQLAnalysis? { - let sql = sql(for: tabID, scope: scope) - let analysis = DatabaseSQLAnalyzer.analyze(sql) - if analysis.requiresConfirmation { return analysis } - guard analysis.canExecute, let profile = selectedProfile, profile.productionProtection, - analysis.statements.contains(where: { !DatabaseSQLAnalyzer.analyze($0).kind.usesQueryEndpoint }) else { return nil } - let connection = connection(profile) - // A helper failure falls through: execute reports it, and the helper still refuses unconfirmed risky SQL. - let requiresConfirmation = (try? await Task.detached { [operations] in - try operations.requiresWriteConfirmation(connection: connection, sql: sql) - }.value) ?? false - guard requiresConfirmation else { return nil } - return DatabaseSQLAnalysis(kind: analysis.kind, statementCount: analysis.statementCount, requiresConfirmation: true, - warning: "Production protection requires confirmation before this SQL runs.", statements: analysis.statements) - } - - private func sql(for tabID: UUID, scope: DatabaseSQLExecutionScope) -> String { - switch scope { - case .all: - return sqlTabs.first(where: { $0.id == tabID })?.sql ?? "" - case let .selection(selection): - return selection - } - } - - package func restoreSQLHistory(_ entry: DatabaseSQLHistoryEntry) { - if let selected = selectedSQLTab, selected.sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - updateSQL(entry.sql, in: selected.id) - } else { - addSQLTab(sql: entry.sql) - } - } - - package func runSQL(in tabID: UUID, scope: DatabaseSQLExecutionScope = .all, confirmedRisk: Bool = false, selectionRange: NSRange? = nil) async { - guard let profile = selectedProfile, var sourceTab = sqlTabs.first(where: { $0.id == tabID }) else { - errorMessage = "Select a database connection first." - return - } - guard runningSQLTabs[profile.id]?.contains(tabID) != true else { return } - var sql = sql(for: tabID, scope: scope).trimmingCharacters(in: .whitespacesAndNewlines) - let originalSource = sourceTab.sql as NSString - var searchRange = NSRange(location: 0, length: originalSource.length) - if case .selection = scope { - // A string-only caller cannot disambiguate repeated selections; omit its decoration. - searchRange = selectionRange.flatMap { range in - range.location != NSNotFound && range.location <= originalSource.length && range.length <= originalSource.length - range.location - && originalSource.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) == sql ? range : nil - } ?? NSRange(location: NSNotFound, length: 0) - } - // Format at the execution boundary, never while typing or composing marked text. - sql = DatabaseSQLFormatter.format(sql) - if case .all = scope { - sourceTab.sql = sql - searchRange = NSRange(location: 0, length: sql.utf16.count) - } else if !sql.isEmpty, searchRange.location != NSNotFound { - let selectedSource = originalSource.substring(with: searchRange) - let prefix = String(selectedSource.prefix(while: { $0.isWhitespace })) - let suffix = String(selectedSource.reversed().prefix(while: { $0.isWhitespace }).reversed()) - let replacement = prefix + sql + suffix - sourceTab.sql = originalSource.replacingCharacters(in: searchRange, with: replacement) - searchRange.length = replacement.utf16.count - } - updateSQL(sourceTab.sql, in: tabID) - let source = sourceTab.sql as NSString - let analysis = DatabaseSQLAnalyzer.analyze(sql) - updateSQLTab(tabID) { tab in - tab.errorMessage = nil - tab.result = nil - tab.resultColumns = [] - tab.rowsAffected = nil - tab.execution = nil - } - guard analysis.canExecute else { - updateSQLTab(tabID) { $0.errorMessage = analysis.warning } - return - } - guard !analysis.requiresConfirmation || confirmedRisk else { - updateSQLTab(tabID) { $0.errorMessage = analysis.warning } - return - } - - runningSQLTabs[profile.id, default: []].insert(tabID) - defer { - runningSQLTabs[profile.id]?.remove(tabID) - if runningSQLTabs[profile.id]?.isEmpty == true { runningSQLTabs.removeValue(forKey: profile.id) } - if selectedProfileID == profile.id { updateSQLTab(tabID) { $0.isRunning = false } } - if let index = workspaceSelections[profile.id]?.sqlTabs.firstIndex(where: { $0.id == tabID }) { - workspaceSelections[profile.id]?.sqlTabs[index].isRunning = false - } - } - let connection = connection(profile) - let generation = profileGeneration - let startedAt = Date() - var recoveryPointID: UUID? - var totalRowsAffected: UInt64 = 0 - var hasRowsAffected = false - var lastQueryResult: DatabaseQueryResult? - var completedStatementCount = 0 - var stoppedForNavigation = false - updateSQLTab(tabID) { tab in - tab.isRunning = true; tab.errorMessage = nil; tab.result = nil; tab.resultColumns = []; tab.rowsAffected = nil; tab.execution = nil - // Output retains small log records; result pages belong only to the current batch. - tab.statementExecutions = tab.statementExecutions.map { record in - var record = record; record.result = nil; return record - } - } - - do { - let hasMutation = analysis.statements.contains { - !DatabaseSQLAnalyzer.analyze($0).kind.usesQueryEndpoint - } - let recoveryPoint: DatabaseRecoveryPoint? = hasMutation ? try await createRecoveryPoint(profile: profile, reason: "Before SQL execution") : nil - recoveryPointID = recoveryPoint?.id - for (index, statement) in analysis.statements.enumerated() { - guard isCurrent(profileID: profile.id, generation: generation), sqlTabs.contains(where: { $0.id == tabID }) else { - stoppedForNavigation = true - break - } - let statementSQL = statement.trimmingCharacters(in: .whitespacesAndNewlines) - var range = searchRange.location == NSNotFound ? NSRange(location: NSNotFound, length: 0) : source.range(of: statementSQL, range: searchRange) - if range.location != NSNotFound { - if NSMaxRange(range) < NSMaxRange(searchRange), source.character(at: NSMaxRange(range)) == 59 { range.length += 1 } - searchRange = NSRange(location: NSMaxRange(range), length: NSMaxRange(searchRange) - NSMaxRange(range)) - } - let statementStarted = Date() - let clock = ContinuousClock() - let start = clock.now - let statementAnalysis = DatabaseSQLAnalyzer.analyze(statement) - var queryResult: DatabaseQueryResult? - var affected: UInt64? - func record(_ error: String? = nil) { - let duration = start.duration(to: clock.now).components - let milliseconds = max(0, Int(duration.seconds * 1_000 + duration.attoseconds / 1_000_000_000_000_000)) - guard isCurrent(profileID: profile.id, generation: generation) else { return } - appendSQLStatementExecution(DatabaseSQLStatementExecution(id: UUID(), sql: statementSQL, - sourceSQL: sourceTab.sql, sourceRange: range.location == NSNotFound ? nil : range, - startedAt: statementStarted, batchStartedAt: startedAt, durationMilliseconds: milliseconds, - result: queryResult.map(masked), rowsReturned: queryResult?.rows.count, rowsAffected: affected, errorMessage: error), in: tabID) - } - do { - if statementAnalysis.kind.usesQueryEndpoint { - lastQueryResult = try await Task.detached { [operations] in - try operations.query(connection: connection, sql: statement.trimmingCharacters(in: .whitespacesAndNewlines), values: [], limit: 10_000) - }.value - queryResult = lastQueryResult - } else { - let result = try await Task.detached { [operations] in - try operations.execute(connection: connection, sql: statement.trimmingCharacters(in: .whitespacesAndNewlines), values: [], confirmed: confirmedRisk, allowWrite: false) - }.value - totalRowsAffected += result.rowsAffected - hasRowsAffected = true - affected = result.rowsAffected - } - completedStatementCount += 1 - record() - } catch { - let message = executionError(error) - record(message) - if analysis.statementCount == 1 { throw error } - throw DatabaseSQLBatchError(statementIndex: index + 1, message: message) - } - } - - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - let columns = lastQueryResult?.resolvedColumns ?? [] - let execution = DatabaseSQLExecution( - startedAt: startedAt, - durationMilliseconds: duration, - rowsReturned: lastQueryResult?.rows.count, - rowsAffected: hasRowsAffected ? totalRowsAffected : nil, - truncated: lastQueryResult?.truncated ?? false - ) - let stopMessage = stoppedForNavigation - ? "SQL batch stopped after \(completedStatementCount) of \(analysis.statementCount) statements because the query context changed. Earlier statements may have been applied." - : nil - // Persist the original operation regardless of which connection is now visible. - if isCurrent(profileID: profile.id, generation: generation) { - updateSQLTab(tabID) { tab in - tab.result = lastQueryResult.map(masked) - tab.resultColumns = columns - tab.rowsAffected = hasRowsAffected ? totalRowsAffected : nil - tab.execution = execution - } - } - recordSQLHistory(profileID: profile.id, sql: sql, analysis: analysis, execution: execution) - let operation = analysis.statementCount > 1 ? "batch" : analysis.kind.rawValue - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .sql, operation: operation, startedAt: startedAt, durationMilliseconds: execution.durationMilliseconds, status: stoppedForNavigation ? .cancelled : .succeeded, rowsReturned: execution.rowsReturned, rowsAffected: execution.rowsAffected, errorMessage: stopMessage)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "sql", summary: stopMessage ?? "Executed \(completedStatementCount) SQL statement\(completedStatementCount == 1 ? "" : "s")", createdAt: startedAt, recoveryPointID: recoveryPointID, rowsAffected: execution.rowsAffected, succeeded: !stoppedForNavigation, errorMessage: stopMessage)) - if hasMutation, isCurrent(profileID: profile.id, generation: generation) { await refreshTables() } - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - if isCurrent(profileID: profile.id, generation: generation), - sqlTabs.first(where: { $0.id == tabID })?.statementExecutions.contains(where: { $0.batchStartedAt == startedAt }) != true { - // Preparation can fail before any statement reaches the provider; log it without a fabricated range/result. - appendSQLStatementExecution(DatabaseSQLStatementExecution(id: UUID(), sql: sql, sourceSQL: sourceTab.sql, - sourceRange: nil, startedAt: startedAt, batchStartedAt: startedAt, durationMilliseconds: duration, - result: nil, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError), in: tabID) - } - let operation = analysis.statementCount > 1 ? "batch" : analysis.kind.rawValue - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .sql, operation: operation, startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: hasRowsAffected ? totalRowsAffected : nil, errorMessage: sanitizedError)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "sql", summary: "SQL execution failed", createdAt: startedAt, recoveryPointID: recoveryPointID, rowsAffected: hasRowsAffected ? totalRowsAffected : nil, succeeded: false, errorMessage: sanitizedError)) - let execution = DatabaseSQLExecution(startedAt: startedAt, durationMilliseconds: duration, - rowsReturned: nil, rowsAffected: hasRowsAffected ? totalRowsAffected : nil, truncated: false) - recordSQLHistory(profileID: profile.id, sql: sql, analysis: analysis, execution: execution) - if isCurrent(profileID: profile.id, generation: generation) { - updateSQLTab(tabID) { tab in - tab.errorMessage = sanitizedError - tab.execution = execution - tab.rowsAffected = hasRowsAffected ? totalRowsAffected : nil; tab.result = nil; tab.resultColumns = [] - } - } - } - } - - package func rollback(to point: DatabaseRecoveryPoint) async -> Bool { - guard let profile = selectedProfile, profile.id == point.profileID else { errorMessage = "Select the connection that owns this recovery point."; return false } - let generation = profileGeneration - isLoading = true; errorMessage = nil - do { - let connection = connection(profile) - if point.isCompressed { - let data = try recoveryStore.data(for: point) - _ = try await Task.detached { [operations] in try operations.restoreSQL(connection: connection, data: data, confirmed: true, allowWrite: false) }.value - } else { - let fileURL = try recoveryStore.fileURL(for: point) - _ = try await Task.detached { [operations] in try operations.restoreSQLFile(connection: connection, fileURL: fileURL, confirmed: true, allowWrite: false) }.value - } - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "rollback", summary: "Restored recovery point", createdAt: Date(), recoveryPointID: point.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - await refreshTables() - if let table = selectedTable { await openTable(table) } - isLoading = false - return true - } catch { - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = executionError(error) - isLoading = false - } - return false - } - } - - package func configureBackupSchedule(profileID: UUID, isEnabled: Bool, intervalHours: Int, retentionCount: Int) { - let nextRun = Date().addingTimeInterval(TimeInterval(max(1, intervalHours)) * 3_600) - let schedule = DatabaseBackupSchedule(profileID: profileID, isEnabled: isEnabled, intervalHours: intervalHours, retentionCount: retentionCount, nextRunAt: nextRun) - backupSchedules.removeAll { $0.profileID == profileID } - backupSchedules.append(schedule) - persistBackupSchedules() - refreshBackupTimer() - } - - package func createBackup(profileID: UUID, reason: String = "Manual backup") async -> Bool { - guard !Task.isCancelled else { return false } - guard let profile = profiles.first(where: { $0.id == profileID }) else { errorMessage = "The backup connection no longer exists."; return false } - let generation = profileGeneration - do { - let point = try await createRecoveryPoint(profile: profile, reason: reason) - guard !Task.isCancelled, profileGeneration == generation else { return false } - pruneRecoveryPoints(for: profile.id) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "backup", summary: reason, createdAt: Date(), recoveryPointID: point.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) - return true - } catch { - guard !Task.isCancelled, profileGeneration == generation else { return false } - let sanitizedError = executionError(error) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "backup", summary: "Backup failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profileID, generation: generation) { - errorMessage = sanitizedError - } - return false - } - } - - package func runScheduledBackups(now: Date = Date()) { - for schedule in backupSchedules where schedule.isEnabled && schedule.nextRunAt <= now { - guard scheduledBackupTasks[schedule.profileID] == nil else { continue } - let generation = profileGeneration - let profileID = schedule.profileID - scheduledBackupTasks[profileID] = Task { [weak self] in - guard let self else { return } - let succeeded = await self.createBackup(profileID: profileID, reason: "Scheduled backup") - guard !Task.isCancelled, self.profileGeneration == generation else { return } - self.advanceBackupSchedule(for: profileID, succeeded: succeeded) - self.scheduledBackupTasks[profileID] = nil - } - } - } - - private func refreshBackupTimer() { - let needsTimer = backupSchedules.contains(where: \.isEnabled) - if needsTimer, backupTimer == nil { - backupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in self?.runScheduledBackups() } - } - } else if !needsTimer { - backupTimer?.invalidate() - backupTimer = nil - } - } - - private func advanceBackupSchedule(for profileID: UUID, succeeded: Bool) { - guard let index = backupSchedules.firstIndex(where: { $0.profileID == profileID }) else { return } - let interval = TimeInterval(max(1, backupSchedules[index].intervalHours)) * 3_600 - backupSchedules[index].nextRunAt = Date().addingTimeInterval(succeeded ? interval : min(interval, 300)) - persistBackupSchedules() - } - - private func pruneRecoveryPoints(for profileID: UUID) { - let retention = backupSchedules.first(where: { $0.profileID == profileID })?.retentionCount ?? 14 - let points = recoveryStore.recoveryPoints(for: profileID) - for point in points.dropFirst(retention) { try? recoveryStore.delete(point) } - recoveryPoints = recoveryStore.recoveryPoints() - } - - private func persistBackupSchedules() { - do { try connectionStore.saveBackupSchedules(backupSchedules) } - catch { errorMessage = error.localizedDescription } - } - - // MARK: - Redis workspace - - package func loadRedisKeys(pattern: String, reset: Bool = true) async { - guard let profile = selectedProfile, profile.kind == .redis else { return } - let generation = profileGeneration - redisScanPattern = pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "*" : pattern - let cursor = reset ? "0" : redisNextCursor - if !reset && cursor == "0" { return } - let requestID = UUID() - redisScanRequestID = requestID - let startedAt = Date() - let includeSize = redisIncludeSize - isLoading = true - errorMessage = nil - if reset { - redisKeys = [] - redisNextCursor = "0" - redisSelectedKey = nil - redisDetailRequestID = nil - } - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.redisScan(connection: connection, cursor: cursor, pattern: pattern.isEmpty ? "*" : pattern, count: 100, includeSize: includeSize) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: "SCAN", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: result.keys.count, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), redisScanRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - redisKeys = reset ? result.keys : Array(Dictionary(grouping: redisKeys + result.keys, by: \.key).compactMap { $0.value.first }) - redisNextCursor = result.nextCursor - } catch { - if Task.isCancelled { - if isCurrent(profileID: profile.id, generation: generation), redisScanRequestID == requestID { - isLoading = false - } - return - } - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: "SCAN", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), redisScanRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), redisScanRequestID == requestID { isLoading = false } - } - - package func loadRedisKey(_ key: String) async { - guard let profile = selectedProfile, profile.kind == .redis else { return } - let generation = profileGeneration - let requestID = UUID() - redisDetailRequestID = requestID - let startedAt = Date() - isLoading = true - errorMessage = nil - redisSelectedKey = nil - do { - let connection = connection(profile) - let detail = try await Task.detached { [operations] in - try operations.redisGetKey(connection: connection, key: key) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: "GET \(key)", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: 1, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), redisDetailRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - redisSelectedKey = detail - } catch { - if Task.isCancelled { - if isCurrent(profileID: profile.id, generation: generation), redisDetailRequestID == requestID { - isLoading = false - } - return - } - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: "GET \(key)", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), redisDetailRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), redisDetailRequestID == requestID { isLoading = false } - } - - package func saveRedisString(key: String, value: String, ttl: Int64?, confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Updated Redis string \(key)", confirmed: confirmed) { connection, operations in - try operations.redisSetString(connection: connection, key: key, value: value, ttl: ttl, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation(selectedKey: key) - } - } - - package func replaceRedisHash(key: String, entries: [RedisHashEntry], confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Updated Redis hash \(key)", confirmed: confirmed) { connection, operations in - try operations.redisReplaceHash(connection: connection, key: key, entries: entries, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation(selectedKey: key) - } - } - - package func setRedisTTL(key: String, ttl: Int64, confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Changed Redis TTL for \(key)", confirmed: confirmed) { connection, operations in - try operations.redisSetTTL(connection: connection, key: key, ttl: ttl, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation(selectedKey: key) - } - } - - package func renameRedisKey(key: String, newKey: String, confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Renamed Redis key \(key)", confirmed: confirmed) { connection, operations in - try operations.redisRenameKey(connection: connection, key: key, newKey: newKey, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation(selectedKey: newKey) - } - } - - package func deleteRedisKey(key: String, confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Deleted Redis key \(key)", confirmed: confirmed) { connection, operations in - try operations.redisDeleteKey(connection: connection, key: key, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation() - } - } - - package func flushRedisDatabase(confirmed: Bool) async -> Bool { - await performRedisWrite(summary: "Cleared Redis database", confirmed: confirmed) { connection, operations in - try operations.redisFlushDatabase(connection: connection, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.refreshRedisAfterMutation() - } - } - - private func refreshRedisAfterMutation(selectedKey: String? = nil) async { - await loadRedisKeys(pattern: redisScanPattern) - if let selectedKey { - await loadRedisKey(selectedKey) - } - } - - private func performRedisWrite( - summary: String, - confirmed: Bool, - operation: @escaping @Sendable (DatabaseConnection, any DatabaseOperations) throws -> Void, - afterSuccess: @escaping @MainActor () async -> Void - ) async -> Bool { - guard let profile = selectedProfile, profile.kind == .redis else { return false } - let generation = profileGeneration - let startedAt = Date() - isLoading = true - errorMessage = nil - do { - let activeConnection = connection(profile) - try await Task.detached { [operations] in try operation(activeConnection, operations) }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: summary, startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: nil, rowsAffected: nil, errorMessage: nil)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "redis", summary: summary, createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - setConnectionStatus(.connected, for: profile.id) - await afterSuccess() - isLoading = false - return true - } catch { - if Task.isCancelled { - if isCurrent(profileID: profile.id, generation: generation) { - isLoading = false - } - return false - } - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .redis, operation: summary, startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "redis", summary: summary, createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - isLoading = false - } - return false - } - } - - // MARK: - Nacos workspace - - package func loadNacosConfigs(dataId: String, group: String, page: Int = 1) async { - guard let profile = selectedProfile, profile.kind == .nacos else { return } - let generation = profileGeneration - let selectedConfigToRefresh = page == 1 ? nacosSelectedConfig.map { ($0.dataId, $0.group) } : nil - if page == 1 { - nacosConfigSearchDataID = dataId - nacosConfigSearchGroup = group - nacosConfigDetailRequestID = nil - nacosSelectedConfig = nil - } - let requestID = UUID() - nacosConfigListRequestID = requestID - let startedAt = Date() - isLoading = true - errorMessage = nil - if page == 1 { - nacosConfigs = [] - nacosConfigTotalCount = 0 - } - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.nacosListConfigs(connection: connection, dataId: dataId, group: group, page: page, pageSize: 100) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST CONFIGS", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: result.items.count, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), nacosConfigListRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - nacosConfigs = result.items - nacosConfigTotalCount = result.totalCount - if let selectedConfigToRefresh, - result.items.contains(where: { $0.dataId == selectedConfigToRefresh.0 && $0.group == selectedConfigToRefresh.1 }) { - await loadNacosConfig(dataId: selectedConfigToRefresh.0, group: selectedConfigToRefresh.1) - } - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST CONFIGS", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), nacosConfigListRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), nacosConfigListRequestID == requestID { isLoading = false } - } - - package func loadNacosConfig(dataId: String, group: String) async { - guard let profile = selectedProfile, profile.kind == .nacos else { return } - let generation = profileGeneration - let requestID = UUID() - nacosConfigDetailRequestID = requestID - let startedAt = Date() - isLoading = true - errorMessage = nil - nacosSelectedConfig = nil - do { - let connection = connection(profile) - let detail = try await Task.detached { [operations] in - try operations.nacosGetConfig(connection: connection, dataId: dataId, group: group) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "GET CONFIG \(group)/\(dataId)", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: 1, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), nacosConfigDetailRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - nacosSelectedConfig = detail - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "GET CONFIG \(group)/\(dataId)", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), nacosConfigDetailRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), nacosConfigDetailRequestID == requestID { isLoading = false } - } - - package func publishNacosConfig(dataId: String, group: String, content: String, type: String?, confirmed: Bool) async -> Bool { - guard let profile = selectedProfile, profile.kind == .nacos else { return false } - let summary = "Published Nacos config \(group)/\(dataId)" - return await performNacosWrite(profile: profile, summary: summary) { connection, operations in - try operations.nacosPublishConfig(connection: connection, dataId: dataId, group: group, content: content, type: type, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.loadNacosConfigs(dataId: self.nacosConfigSearchDataID, group: self.nacosConfigSearchGroup) - await self.loadNacosConfig(dataId: dataId, group: group) - } - } - - package func deleteNacosConfig(dataId: String, group: String, confirmed: Bool) async -> Bool { - guard let profile = selectedProfile, profile.kind == .nacos else { return false } - let summary = "Deleted Nacos config \(group)/\(dataId)" - return await performNacosWrite(profile: profile, summary: summary) { connection, operations in - try operations.nacosDeleteConfig(connection: connection, dataId: dataId, group: group, confirmed: confirmed, allowWrite: false) - } afterSuccess: { - await self.loadNacosConfigs(dataId: self.nacosConfigSearchDataID, group: self.nacosConfigSearchGroup) - self.nacosSelectedConfig = nil - } - } - - package func loadNacosServices(serviceName: String, group: String, page: Int = 1) async { - guard let profile = selectedProfile, profile.kind == .nacos else { return } - let generation = profileGeneration - let requestID = UUID() - nacosServiceListRequestID = requestID - let startedAt = Date() - isLoading = true - errorMessage = nil - if page == 1 { - nacosServices = [] - nacosServiceTotalCount = 0 - nacosInstances = [] - nacosInstanceRequestID = nil - } - setConnectionStatus(.connecting, for: profile.id) - do { - let connection = connection(profile) - let result = try await Task.detached { [operations] in - try operations.nacosListServices(connection: connection, serviceName: serviceName, group: group, page: page, pageSize: 100) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST SERVICES", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: result.items.count, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), nacosServiceListRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - nacosServices = result.items - nacosServiceTotalCount = result.totalCount - if let selectedName = nacosSelectedServiceName, let selectedGroup = nacosSelectedServiceGroup { - if result.items.contains(where: { $0.name == selectedName && $0.group == selectedGroup }) { - await loadNacosInstances(serviceName: selectedName, group: selectedGroup) - } else { - nacosSelectedServiceName = nil - nacosSelectedServiceGroup = nil - nacosInstances = [] - } - } - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST SERVICES", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), nacosServiceListRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), nacosServiceListRequestID == requestID { isLoading = false } - } - - package func loadNacosInstances(serviceName: String, group: String) async { - guard let profile = selectedProfile, profile.kind == .nacos else { return } - let generation = profileGeneration - nacosSelectedServiceName = serviceName - nacosSelectedServiceGroup = group - let requestID = UUID() - nacosInstanceRequestID = requestID - let startedAt = Date() - isLoading = true - errorMessage = nil - nacosInstances = [] - do { - let connection = connection(profile) - let items = try await Task.detached { [operations] in - try operations.nacosListInstances(connection: connection, serviceName: serviceName, group: group) - }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST INSTANCES", startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: items.count, rowsAffected: nil, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation), nacosInstanceRequestID == requestID else { return } - setConnectionStatus(.connected, for: profile.id) - nacosInstances = items - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: "LIST INSTANCES", startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - guard isCurrent(profileID: profile.id, generation: generation), nacosInstanceRequestID == requestID else { return } - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - } - if isCurrent(profileID: profile.id, generation: generation), nacosInstanceRequestID == requestID { isLoading = false } - } - - private func performNacosWrite( - profile: DatabaseProfile, - summary: String, - operation: @escaping @Sendable (DatabaseConnection, any DatabaseOperations) throws -> Void, - afterSuccess: @escaping @MainActor () async -> Void - ) async -> Bool { - let generation = profileGeneration - let startedAt = Date() - isLoading = true - errorMessage = nil - do { - let activeConnection = connection(profile) - try await Task.detached { [operations] in try operation(activeConnection, operations) }.value - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: summary, startedAt: startedAt, durationMilliseconds: duration, status: .succeeded, rowsReturned: nil, rowsAffected: nil, errorMessage: nil)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "nacos", summary: summary, createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: true, errorMessage: nil)) - guard isCurrent(profileID: profile.id, generation: generation) else { return true } - setConnectionStatus(.connected, for: profile.id) - await afterSuccess() - isLoading = false - return true - } catch { - let sanitizedError = executionError(error) - let duration = max(0, Int(Date().timeIntervalSince(startedAt) * 1_000)) - appendExecutionEvent(DatabaseExecutionEvent(id: UUID(), profileID: profile.id, profileName: profile.name, source: .nacos, operation: summary, startedAt: startedAt, durationMilliseconds: duration, status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: sanitizedError)) - appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "nacos", summary: summary, createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) - if isCurrent(profileID: profile.id, generation: generation) { - errorMessage = sanitizedError - setConnectionStatus(.failed, for: profile.id) - isLoading = false - } - return false - } - } - - private func clearSpecializedWorkspace() { - redisKeys = [] - redisNextCursor = "0" - redisSelectedKey = nil - redisScanPattern = "*" - nacosConfigs = [] - nacosConfigTotalCount = 0 - nacosSelectedConfig = nil - nacosConfigSearchDataID = "" - nacosConfigSearchGroup = "" - nacosServices = [] - nacosServiceTotalCount = 0 - nacosInstances = [] - nacosSelectedServiceName = nil - nacosSelectedServiceGroup = nil - } - - private func migrateLegacyGroupsIfNeeded() { - let legacyNames = profiles - .filter { $0.folderID == nil } - .map(\.group) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard !legacyNames.isEmpty else { return } - - var updatedFolders = folders - for name in Set(legacyNames) where !updatedFolders.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { - updatedFolders.append(DatabaseConnectionFolder(name: name)) - } - let folderByName = Dictionary(uniqueKeysWithValues: updatedFolders.map { ($0.name.lowercased(), $0.id) }) - let updatedProfiles = profiles.map { profile -> DatabaseProfile in - guard profile.folderID == nil else { return profile } - let legacyName = profile.group.trimmingCharacters(in: .whitespacesAndNewlines) - guard let folderID = folderByName[legacyName.lowercased()] else { return profile } - var profile = profile - profile.folderID = folderID - profile.group = "" - return profile - } - do { - try connectionStore.saveFolders(updatedFolders) - try connectionStore.save(updatedProfiles) - folders = sortedFolders(updatedFolders) - profiles = sortedProfiles(updatedProfiles) - } catch { - // Existing connections remain usable even if the one-time local - // migration cannot be persisted. Try again on the next launch. - } - } - - private func sortedProfiles(_ values: [DatabaseProfile]) -> [DatabaseProfile] { - values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - - private func sortedFolders(_ values: [DatabaseConnectionFolder]) -> [DatabaseConnectionFolder] { - values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - - private func createRecoveryPoint(profile: DatabaseProfile, reason: String) async throws -> DatabaseRecoveryPoint { - let connection = connection(profile) - let generation = profileGeneration - let tracksUI = selectedProfileID == profile.id - let temporaryURL = fileStorage.temporaryDirectory().appendingPathComponent("lithe-recovery-\(UUID().uuidString).sql") - if tracksUI { backupProgress = 0 } - defer { - try? fileStorage.removeItem(at: temporaryURL) - if tracksUI && isCurrent(profileID: profile.id, generation: generation) { backupProgress = nil } - } - let export = try await Task.detached { [operations] in - try operations.exportSQLToFile( - connection: connection, - options: DatabaseSQLExportOptions(schema: "", selectedTables: [], includeStructure: true, includeData: true, limit: 0), - outputURL: temporaryURL - ) - }.value - if tracksUI && isCurrent(profileID: profile.id, generation: generation) { backupProgress = 0.7 } - let point = try recoveryStore.createRecoveryPoint(profileID: profile.id, reason: reason, fileURL: temporaryURL, expectedSHA256: export.sha256) { [weak self] progress in - guard let self, tracksUI, self.isCurrent(profileID: profile.id, generation: generation) else { return } - self.backupProgress = 0.7 + progress * 0.3 - } - recoveryPoints = recoveryStore.recoveryPoints() - return point - } - - private func appendAudit(_ entry: DatabaseAuditEntry) { - do { - try recoveryStore.appendAudit(entry) - auditEntries = recoveryStore.auditEntries() - } catch { - // Recovery and audit failures are surfaced only when they prevent a - // write. A completed write must not be misreported as failed. - } - } - - private func appendExecutionEvent(_ event: DatabaseExecutionEvent) { - do { - try recoveryStore.appendExecutionEvent(event) - executionEvents = recoveryStore.executionEvents() - } catch { - // Execution logging must never change the result of a database operation. - } - } - - private func executionError(_ error: Error) -> String { - let message = error.localizedDescription - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - return message.count > 500 ? "\(message.prefix(497))..." : message - } - - private func rememberWorkspace() { - guard let profile = selectedProfile else { return } - clearSQLResults() - workspaceSelections[profile.id] = (profile.database, selectedTable, workspaceSection, sqlTabs, selectedSQLTabID) - } - - package func showGlobalHistory() { - rememberWorkspace() - clearProfileScopedState() - workspaceSection = .history - } - - package func activateProfile(_ profile: DatabaseProfile) { - rememberWorkspace() - profileGeneration &+= 1 - tableListRequestID = nil - databaseListRequestID = nil - tableRequestID = nil - redisScanRequestID = nil - redisDetailRequestID = nil - nacosConfigListRequestID = nil - nacosConfigDetailRequestID = nil - nacosServiceListRequestID = nil - nacosInstanceRequestID = nil - selectedProfileID = profile.id - selectedTable = nil - columns = [] - columnTypes = [:] - rows = [] - sourceRows = [] - totalRows = 0 - currentOffset = 0 - primaryKeyColumns = [] - indexes = [] - foreignKeys = [] - tables = [] - databaseOptions = [] - objects = [:] - lastExplainResult = nil - lastDiagnostics = nil - backupProgress = nil - resetSQLWorkspace() - workspaceSection = .data - if let saved = workspaceSelections[profile.id], saved.database == profile.database { - selectedTable = saved.table - workspaceSection = saved.section - sqlTabs = saved.sqlTabs - selectedSQLTabID = saved.sqlTabID - } - clearSpecializedWorkspace() - errorMessage = nil - isLoading = profile.kind.supportsDataGrid - setConnectionStatus(profile.kind.supportsDataGrid ? .connecting : .connected, for: profile.id) - } - - private func clearProfileScopedState() { - profileGeneration &+= 1 - tableListRequestID = nil - databaseListRequestID = nil - tableRequestID = nil - redisScanRequestID = nil - redisDetailRequestID = nil - nacosConfigListRequestID = nil - nacosConfigDetailRequestID = nil - nacosServiceListRequestID = nil - nacosInstanceRequestID = nil - selectedProfileID = nil - selectedTable = nil - columns = [] - columnTypes = [:] - rows = [] - sourceRows = [] - totalRows = 0 - currentOffset = 0 - primaryKeyColumns = [] - indexes = [] - foreignKeys = [] - tables = [] - databaseOptions = [] - objects = [:] - lastExplainResult = nil - lastDiagnostics = nil - backupProgress = nil - resetSQLWorkspace() - clearSpecializedWorkspace() - errorMessage = nil - isLoading = false - } - - private func resetSQLWorkspace() { - let tab = DatabaseSQLTab(title: "Query 1") - sqlTabs = [tab] - selectedSQLTabID = tab.id - } - - private func isCurrent(profileID: UUID, generation: UInt64) -> Bool { - selectedProfileID == profileID && profileGeneration == generation - } - - private func clearSQLResults() { - sqlTabs = sqlTabs.map { tab in - var tab = tab - tab.result = nil; tab.resultColumns = []; tab.rowsAffected = nil; tab.execution = nil; tab.errorMessage = nil - tab.isRunning = selectedProfileID.flatMap { runningSQLTabs[$0] }?.contains(tab.id) == true - tab.statementExecutions = [] - return tab - } - } - - private func updateSQLTab(_ id: UUID, update: (inout DatabaseSQLTab) -> Void) { - guard let index = sqlTabs.firstIndex(where: { $0.id == id }) else { return } - update(&sqlTabs[index]) - } - - private func rebaseSQLExecutions(_ records: inout [DatabaseSQLStatementExecution], from original: String, to sql: String) { - guard original != sql, !records.isEmpty else { return } - // ponytail: One linear UTF-16 edit envelope for at most 100 records; replace with native edit ranges if large consoles require it. - let old = Array(original.utf16), new = Array(sql.utf16) - var start = 0, suffix = 0 - while start < min(old.count, new.count), old[start] == new[start] { start += 1 } - while suffix < min(old.count, new.count) - start, old[old.count - suffix - 1] == new[new.count - suffix - 1] { suffix += 1 } - let oldEnd = old.count - suffix - let delimiter: (UInt16) -> Bool = { $0 == 59 || UnicodeScalar($0).map { CharacterSet.whitespacesAndNewlines.contains($0) } == true } - for index in records.indices { - var record = records[index] - if record.sourceSQL == original, var range = record.sourceRange, range.location >= 0, - range.location <= old.count, range.length > 0, range.length <= old.count - range.location { - if NSMaxRange(range) <= start { /* Unchanged prefix. */ } - else if range.location >= oldEnd { range.location += new.count - old.count } - else { record.sourceRange = nil } - if record.sourceRange != nil { - // Do not retain a token prefix when typing joins text to either statement boundary. - let before = range.location == 0 || delimiter(new[range.location - 1]) - let end = NSMaxRange(range) - let after = end == new.count || new[end - 1] == 59 || delimiter(new[end]) - record.sourceRange = before && after ? range : nil - } - } else { record.sourceRange = nil } - record.sourceSQL = sql - records[index] = record - } - } - - private func appendSQLStatementExecution(_ execution: DatabaseSQLStatementExecution, in tabID: UUID) { - updateSQLTab(tabID) { tab in - // ponytail: retain the latest 100 statements; disk-backed output can replace this bound. - var records = [execution] - rebaseSQLExecutions(&records, from: execution.sourceSQL, to: tab.sql) - tab.statementExecutions.append(contentsOf: records) - if tab.statementExecutions.count > 100 { tab.statementExecutions.removeFirst(tab.statementExecutions.count - 100) } - } - } - - private func recordSQLHistory(profileID: UUID, sql: String, analysis: DatabaseSQLAnalysis, execution: DatabaseSQLExecution) { - let entry = DatabaseSQLHistoryEntry( - profileID: profileID, - sql: sql, - kind: analysis.kind, - executedAt: execution.startedAt, - durationMilliseconds: execution.durationMilliseconds, - rowsReturned: execution.rowsReturned, - rowsAffected: execution.rowsAffected - ) - do { - try connectionStore.appendSQLHistory(entry) - sqlHistory.insert(entry, at: 0) - sqlHistory = Array(sqlHistory.prefix(100)) - } catch { - // A successful database operation must not be reported as failed - // merely because the local convenience history cannot be saved. - } - } - - private func keyValues(_ row: DatabaseRow) -> DatabaseRow? { - guard !primaryKeyColumns.isEmpty else { return nil } - var key: DatabaseRow = [:] - for column in primaryKeyColumns { guard let value = row[column] else { return nil }; key[column] = value } - return key - } - - private func masked(_ result: DatabaseQueryResult) -> DatabaseQueryResult { - DatabaseQueryResult(rows: masked(result.rows), columns: result.columns, truncated: result.truncated, totalRows: result.totalRows) - } - - private func masked(_ rows: [DatabaseRow]) -> [DatabaseRow] { - guard let profile = selectedProfile else { return rows } - return DatabaseSensitiveFieldMasker.mask(rows: rows, enabled: profile.maskSensitiveFields, patterns: profile.sensitiveColumnPatterns) - } - - private func typedValue(_ value: DatabaseValue, for column: String) -> DatabaseValue { - guard case let .string(text) = value else { return value } - let type = columnTypes[column] ?? "" - if type.contains("int"), let number = Int64(text) { return .integer(number) } - if type.contains("numeric") || type.contains("decimal") { return .decimal(text) } - if type.contains("double") || type.contains("float") || type.contains("real"), let number = Double(text) { return .number(number) } - if type == "boolean" || type == "bool" { - if ["true", "1"].contains(text.lowercased()) { return .bool(true) } - if ["false", "0"].contains(text.lowercased()) { return .bool(false) } - } - if type == "date" { return .object(["date": .string(text)]) } - if type.starts(with: "time") && !type.contains("stamp") { return .object(["time": .string(text)]) } - if type.contains("timestamp with time zone") || type == "timestamptz" { return .object(["timestampWithTimeZone": .string(text)]) } - if type.contains("timestamp") || type.contains("datetime") { return .object(["datetime": .string(text)]) } - if type == "uuid" { return .object(["uuid": .string(text)]) } - if selectedProfile?.kind == .mongodb, - ["document", "array", "objectid", "date", "value"].contains(type), - let parsed = try? JSONDecoder().decode(DatabaseValue.self, from: Data(text.utf8)) { - return parsed - } - if type == "json" || type == "jsonb", - let parsed = try? JSONDecoder().decode(DatabaseValue.self, from: Data(text.utf8)) { - return .object(["json": parsed]) - } - return value - } - - package var selectedProfile: DatabaseProfile? { profiles.first { $0.id == selectedProfileID } } - - package func connectionStatus(for profile: DatabaseProfile) -> DatabaseConnectionStatus { - connectionStatuses[profile.id] ?? .idle - } - - package var connectedProfileCount: Int { - connectionStatuses.values.reduce(into: 0) { count, status in - if status == .connected { count += 1 } - } - } - - private func setConnectionStatus(_ status: DatabaseConnectionStatus, for profileID: UUID) { - connectionStatuses[profileID] = status - } - - private func connection(_ profile: DatabaseProfile, password: String? = nil) -> DatabaseConnection { - DatabaseConnection(kind: profile.kind, host: profile.host, port: profile.port, username: profile.username, - password: password ?? connectionStore.password(for: profile.id), database: profile.database, path: profile.path, ssl: profile.ssl, - caCertificatePath: profile.caCertificatePath, serverName: profile.serverName, - sshHost: profile.sshHost, sshPort: profile.sshPort, sshUsername: profile.sshUsername, - sshKeyPath: profile.sshKeyPath, sshLocalPort: profile.sshLocalPort, proxyURL: profile.proxyURL, - readOnly: profile.readOnly, productionProtection: profile.productionProtection) - } - - nonisolated private static func quotedIdentifier(_ identifier: String, for kind: DatabaseKind) -> String { - if kind == .mysql || kind == .mariadb { return "`\(identifier.replacingOccurrences(of: "`", with: "``"))`" } - if kind == .sqlserver { return "[\(identifier.replacingOccurrences(of: "]", with: "]]"))]" } - return "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" - } -} - -private extension DatabaseValue { - var text: String? { - switch self { case let .string(value): value; case let .integer(value): String(value); case let .number(value): String(value); default: nil } - } - var integerValue: Int64? { if case let .integer(value) = self { value } else { nil } } -} - -private extension Dictionary where Key == String, Value == DatabaseValue { - func mapValuesWithKeys(_ transform: (String, DatabaseValue) -> DatabaseValue) -> Self { - Dictionary(uniqueKeysWithValues: map { ($0.key, transform($0.key, $0.value)) }) - } -} - -package struct DatabaseCellKey: Hashable, Sendable { - package let row: Int - package let column: String - package init(row: Int, column: String) { self.row = row; self.column = column } -} - -package struct DatabaseCellDraft: Sendable { - package let rowIndex: Int - package let column: String - package let value: DatabaseValue - package init(rowIndex: Int, column: String, value: DatabaseValue) { self.rowIndex = rowIndex; self.column = column; self.value = value } -} - -package enum DatabaseTransferFormat: String, Sendable { case csv, json, sql } -private enum DatabaseTransferError: LocalizedError { case tableRequired; var errorDescription: String? { "Select a table first." } } diff --git a/macos/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift b/macos/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift deleted file mode 100644 index 5e1c3caa1..000000000 --- a/macos/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift +++ /dev/null @@ -1,487 +0,0 @@ -import Foundation - -package enum DatabaseWorkspaceSection: String, CaseIterable, Identifiable, Sendable { - case data - case sql - case structure - case history - - package var id: String { rawValue } -} - -package enum DatabaseSensitiveFieldMasker { - package static func mask(rows: [DatabaseRow], enabled: Bool, patterns: [String]) -> [DatabaseRow] { - guard enabled else { return rows } - let normalizedPatterns = patterns - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - guard !normalizedPatterns.isEmpty else { return rows } - return rows.map { row in - row.reduce(into: DatabaseRow()) { masked, entry in - let (column, value) = entry - let normalizedColumn = column.lowercased() - if normalizedPatterns.contains(where: { normalizedColumn.contains($0) }) { - masked[column] = value == .null ? .null : .string("******") - } else { - masked[column] = value - } - } - } - } -} - -package enum DatabaseSQLStatementKind: String, Codable, Equatable, Sendable { - case query - case mutation - case definition - case transaction - case batch - case unknown - - package var usesQueryEndpoint: Bool { - self == .query - } -} - -package struct DatabaseSQLAnalysis: Equatable, Sendable { - package let kind: DatabaseSQLStatementKind - package let statementCount: Int - package let requiresConfirmation: Bool - package let warning: String? - package let statements: [String] - - package var canExecute: Bool { !statements.isEmpty || statementCount == 1 } - - package init( - kind: DatabaseSQLStatementKind, - statementCount: Int, - requiresConfirmation: Bool, - warning: String?, - statements: [String] = [] - ) { - self.kind = kind - self.statementCount = statementCount - self.requiresConfirmation = requiresConfirmation - self.warning = warning - self.statements = statements - } -} - -package enum DatabaseSQLExecutionScope: Equatable, Sendable { - case all - case selection(String) -} - -package struct DatabaseSQLTab: Identifiable, Equatable, Sendable { - package let id: UUID - package var title: String - package var sql: String - package var result: DatabaseQueryResult? - package var resultColumns: [String] - package var rowsAffected: UInt64? - package var execution: DatabaseSQLExecution? - package var errorMessage: String? - package var isRunning: Bool - package var statementExecutions: [DatabaseSQLStatementExecution] = [] - package var outputHiddenThroughExecutionID: UUID? - - package init(id: UUID = UUID(), title: String, sql: String = "") { - self.id = id - self.title = title - self.sql = sql - result = nil - resultColumns = [] - rowsAffected = nil - execution = nil - errorMessage = nil - isRunning = false - } -} - -/// Runtime output stays with its console, never in connection preferences. -package struct DatabaseSQLStatementExecution: Equatable, Identifiable, Sendable { - package let id: UUID - package let sql: String - // Rendering reference follows edits outside this statement; `sql` remains the actual executed text. - package var sourceSQL: String - package var sourceRange: NSRange? - package let startedAt: Date - package let batchStartedAt: Date - package let durationMilliseconds: Int - package var result: DatabaseQueryResult? - package let rowsReturned: Int? - package let rowsAffected: UInt64? - package let errorMessage: String? -} - -package struct DatabaseSQLExecution: Equatable, Sendable { - package let startedAt: Date - package let durationMilliseconds: Int - package let rowsReturned: Int? - package let rowsAffected: UInt64? - package let truncated: Bool -} - -/// Kept separately from a connection profile so neither passwords nor result -/// data ever enter preferences. The SQL text is intentionally retained for a -/// familiar query-history workflow. -package struct DatabaseSQLHistoryEntry: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package let profileID: UUID - package let sql: String - package let kind: DatabaseSQLStatementKind - package let executedAt: Date - package let durationMilliseconds: Int - package let rowsReturned: Int? - package let rowsAffected: UInt64? - - package init( - id: UUID = UUID(), - profileID: UUID, - sql: String, - kind: DatabaseSQLStatementKind, - executedAt: Date, - durationMilliseconds: Int, - rowsReturned: Int? = nil, - rowsAffected: UInt64? = nil - ) { - self.id = id - self.profileID = profileID - self.sql = sql - self.kind = kind - self.executedAt = executedAt - self.durationMilliseconds = durationMilliseconds - self.rowsReturned = rowsReturned - self.rowsAffected = rowsAffected - } -} - -package enum DatabaseSQLAnalyzer { - package static func analyze(_ sql: String) -> DatabaseSQLAnalysis { - let statements = DatabaseSQLLexing.statements(in: sql) - guard !statements.isEmpty else { - return DatabaseSQLAnalysis( - kind: .unknown, - statementCount: statements.count, - requiresConfirmation: false, - warning: "Enter a SQL statement.", - statements: statements - ) - } - - let analyses = statements.map(analyzeSingle) - let kinds = analyses.map(\.kind) - let kind = statements.count > 1 ? .batch : kinds[0] - return DatabaseSQLAnalysis( - kind: kind, - statementCount: statements.count, - requiresConfirmation: analyses.contains(where: \.requiresConfirmation), - warning: warning(for: analyses), - statements: statements - ) - } - - private static func warning(for analyses: [DatabaseSQLAnalysis]) -> String? { - let warnings = analyses.compactMap(\.warning) - return warnings.first(where: { !$0.contains("not recognized") }) ?? warnings.first - } - - private static func analyzeSingle(_ statement: String) -> DatabaseSQLAnalysis { - let tokens = DatabaseSQLLexing.keywords(in: statement) - guard let first = tokens.first else { - return DatabaseSQLAnalysis(kind: .unknown, statementCount: 0, requiresConfirmation: false, warning: "Enter a SQL statement.", statements: []) - } - - let kind: DatabaseSQLStatementKind - switch first { - case "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA", "VALUES": kind = .query - case "WITH": - let writeKeywords: Set = ["INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"] - kind = tokens.contains(where: writeKeywords.contains) ? .mutation : .query - case "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE": kind = .mutation - case "CREATE", "ALTER", "DROP", "TRUNCATE", "VACUUM", "ANALYZE", "GRANT", "REVOKE": kind = .definition - case "BEGIN", "START", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "SET": kind = .transaction - default: kind = .unknown - } - - let isUnqualifiedWrite = (tokens.contains("UPDATE") || tokens.contains("DELETE")) && !tokens.contains("WHERE") - let isDestructive = tokens.contains("DROP") || tokens.contains("TRUNCATE") - let isUnknownStatement = kind == .unknown - let requiresConfirmation = isUnqualifiedWrite || isDestructive - let warning: String? - if isUnqualifiedWrite { - warning = "This UPDATE or DELETE has no WHERE clause and can affect every row." - } else if isDestructive { - warning = "This statement can permanently remove database objects or data." - } else if isUnknownStatement { - warning = "This statement type is not recognized locally; the database will validate it." - } else { - warning = nil - } - return DatabaseSQLAnalysis(kind: kind, statementCount: 1, requiresConfirmation: requiresConfirmation, warning: warning, statements: [statement]) - } -} - -package enum DatabaseSQLFormatter { - package static func format(_ sql: String) -> String { - // ponytail: Top-level clause layout only; use a dialect formatter when a provider exposes one. - // ponytail: Backslash escaping depends on dialect AND session modes; keep ambiguous scripts verbatim until a provider formatter supplies that context. - if sql.contains("\\") || sql.range(of: #"\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$"#, options: .regularExpression) != nil { return sql } - // JetBrains SQL Code Style: preserve word case, SELECT elements on a new line, 4-space indent. - let selectIndent = " " - let clauses: Set = [ "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "RETURNING", "SET", "VALUES", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS"] - // SQL delimiters are scalar boundaries; a combining mark may share a Swift Character with its opening quote. - let source = sql.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.map { Character(String($0)) } - var output = "" - var word = "" - var needsSpace = false - var index = 0 - var depth = 0 - var previousWord = "" - var followsLineComment = false - - func appendWord() { - guard !word.isEmpty else { return } - let keyword = word.uppercased() - let qualified = output.hasSuffix(".") - let joinModifier = ["LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS"].contains(previousWord) - if depth == 0, !qualified, keyword == "FROM", !followsLineComment, output.hasSuffix("\n") { - output = output.trimmingCharacters(in: .whitespacesAndNewlines) - needsSpace = true - } - if depth == 0, !qualified, clauses.contains(keyword), !output.isEmpty, - !(joinModifier && (keyword == "JOIN" || keyword == "OUTER")) { - output = output.trimmingCharacters(in: .whitespaces) - if !output.hasSuffix("\n") { output.append("\n") } - } else if needsSpace, !output.isEmpty, !output.hasSuffix("\n") { output.append(" ") } - output.append(word) - previousWord = keyword - followsLineComment = false - word = "" - needsSpace = true - if depth == 0, !qualified, keyword == "SELECT" { - output.append("\n" + selectIndent) - needsSpace = false - } - } - - while index < source.count { - let character = source[index] - if character.isLetter || character.isNumber || character == "_" || character == "$" { - word.append(character) - index += 1 - continue - } - appendWord() - if character.isWhitespace { - if character == "\n", !output.isEmpty, !output.hasSuffix("\n"), !output.hasSuffix("\n" + selectIndent) { output.append("\n") } - needsSpace = !output.isEmpty && !output.hasSuffix("\n") && !output.hasSuffix(" ") - index += 1 - continue - } - if character == "#" || (character == "-" && index + 1 < source.count && source[index + 1] == "-") { - if needsSpace, !output.isEmpty, !output.hasSuffix("\n") { output.append(" ") } - output.append(character == "#" ? "#" : "--") - index += character == "#" ? 1 : 2 - while index < source.count { - output.append(source[index]) - if source[index] == "\n" { index += 1; break } - index += 1 - } - needsSpace = false - followsLineComment = true - continue - } - if character == "/", index + 1 < source.count, source[index + 1] == "*" { - if needsSpace, !output.isEmpty, !output.hasSuffix("\n") { output.append(" ") } - output.append("/*") - index += 2 - var commentDepth = 1 - while index < source.count { - let commentCharacter = source[index] - output.append(commentCharacter) - if commentCharacter == "*", index + 1 < source.count, source[index + 1] == "/" { - output.append("/") - index += 2 - commentDepth -= 1 - if commentDepth == 0 { break } - continue - } - if commentCharacter == "/", index + 1 < source.count, source[index + 1] == "*" { - output.append("*") - index += 2 - commentDepth += 1 - continue - } - index += 1 - } - needsSpace = true - continue - } - if character == "'" || character == "\"" || character == "`" || character == "[" { - // Preserve adjacent literal prefixes (E'...', X'...') and qualified quoted identifiers. - if index > 0, source[index - 1].isWhitespace, needsSpace, !output.isEmpty, !output.hasSuffix("\n") { output.append(" ") } - let quote: Character = character == "[" ? "]" : character - output.append(character) - index += 1 - while index < source.count { - let quoted = source[index] - output.append(quoted) - index += 1 - if quoted == "\\", index < source.count { - output.append(source[index]); index += 1; continue - } - if quoted == quote { - if index < source.count, source[index] == quote { - output.append(source[index]) - index += 1 - } else { - break - } - } - } - needsSpace = true - continue - } - if character == ";" { - output = output.trimmingCharacters(in: .whitespaces) - output.append(";\n") - previousWord = "" - needsSpace = false - } else if character == "," { - output = output.trimmingCharacters(in: .whitespaces) - output.append(", ") - needsSpace = false - } else if character == "(" { - output = output.trimmingCharacters(in: .whitespaces) - output.append("(") - depth += 1 - needsSpace = false - } else if character == ")" { - output = output.trimmingCharacters(in: .whitespaces) - output.append(")") - depth = max(0, depth - 1) - needsSpace = true - } else { - // Keep adjacent punctuation intact: decimals, qualified names, casts and compound operators. - if index > 0, source[index - 1].isWhitespace, !output.isEmpty, !output.hasSuffix("\n"), !output.hasSuffix(" ") { output.append(" ") } - output.append(character) - needsSpace = false - } - index += 1 - } - appendWord() - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -private enum DatabaseSQLLexing { - package static func keywords(in sql: String) -> [String] { - sanitized(sql).split { !$0.isLetter && $0 != "_" }.map { String($0).uppercased() } - } - - package static func statements(in sql: String) -> [String] { - let characters = sql.unicodeScalars.map { Character(String($0)) } - var statements: [String] = [] - var current = "" - var index = 0 - var quote: Character? - var lineComment = false - var blockComment = false - - func appendCurrentStatement() { - guard !sanitized(current).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - statements.append(current) - } - - while index < characters.count { - let character = characters[index] - let next = index + 1 < characters.count ? characters[index + 1] : nil - if lineComment { - current.append(character) - if character == "\n" { lineComment = false } - index += 1 - continue - } - if blockComment { - current.append(character) - if character == "*", next == "/" { - current.append("/") - blockComment = false - index += 2 - } else { - index += 1 - } - continue - } - if let activeQuote = quote { - current.append(character) - if character == "\\", next != nil { - current.append(next!) - index += 2 - continue - } - if character == activeQuote { - if next == activeQuote { index += 1; current.append(activeQuote) } - else { quote = nil } - } - index += 1; continue - } - if character == "-", next == "-" { lineComment = true; current.append(character); current.append(next!); index += 2; continue } - if character == "/", next == "*" { blockComment = true; current.append(character); current.append(next!); index += 2; continue } - if character == "'" || character == "\"" || character == "`" { quote = character; current.append(character); index += 1; continue } - if character == ";" { - appendCurrentStatement() - current = ""; index += 1; continue - } - current.append(character) - index += 1 - } - appendCurrentStatement() - return statements - } - - private static func sanitized(_ sql: String) -> String { - let characters = sql.unicodeScalars.map { Character(String($0)) } - var output = "" - var index = 0 - var quote: Character? - var lineComment = false - var blockComment = false - while index < characters.count { - let character = characters[index] - let next = index + 1 < characters.count ? characters[index + 1] : nil - if lineComment { - if character == "\n" { lineComment = false; output.append(" ") } - index += 1 - continue - } - if blockComment { - if character == "*", next == "/" { blockComment = false; index += 2; output.append(" ") } - else { index += 1 } - continue - } - if let activeQuote = quote { - if character == "\\", next != nil { - output.append(" ") - index += 2 - continue - } - if character == activeQuote { - if next == activeQuote { index += 2 } - else { quote = nil; index += 1 } - } else { - index += 1 - } - output.append(" ") - continue - } - if character == "-", next == "-" { lineComment = true; index += 2; output.append(" "); continue } - if character == "/", next == "*" { blockComment = true; index += 2; output.append(" "); continue } - if character == "'" || character == "\"" || character == "`" { quote = character; index += 1; output.append(" "); continue } - output.append(character) - index += 1 - } - return output - } -} diff --git a/macos/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift b/macos/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift deleted file mode 100644 index e9bd80b25..000000000 --- a/macos/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift +++ /dev/null @@ -1,401 +0,0 @@ -import Foundation - -package struct DatabaseSchemaColumnSnapshot: Codable, Equatable, Sendable { - package let name: String - package let dataType: String - package let isNullable: Bool - package let defaultValue: String? - package let isPrimaryKey: Bool -} - -package struct DatabaseSchemaIndexSnapshot: Codable, Equatable, Sendable { - package let name: String - package let definition: String -} - -package struct DatabaseSchemaForeignKeySnapshot: Codable, Equatable, Sendable { - package let name: String - package let column: String - package let referencedTable: String - package let referencedColumn: String -} - -package struct DatabaseSchemaTableSnapshot: Codable, Equatable, Sendable { - package let name: String - package let columns: [DatabaseSchemaColumnSnapshot] - package let indexes: [DatabaseSchemaIndexSnapshot] - package let foreignKeys: [DatabaseSchemaForeignKeySnapshot] -} - -package struct DatabaseSchemaSnapshot: Codable, Equatable, Sendable { - package let profileID: UUID - package let profileName: String - package let kind: DatabaseKind - package let schema: String - package let tables: [DatabaseSchemaTableSnapshot] -} - -private struct DatabaseForeignKeyGroup { - package let name: String - package let referencedTable: String - package let columns: [DatabaseSchemaForeignKeySnapshot] -} - -package enum DatabaseSchemaDiffKind: String, Codable, CaseIterable, Sendable { - case addTable - case dropTable - case addColumn - case dropColumn - case changeColumn - case addIndex - case dropIndex - case addForeignKey - case dropForeignKey - - package var isDestructive: Bool { - switch self { - case .dropTable, .dropColumn, .dropIndex, .dropForeignKey: true - default: false - } - } - - package var title: String { - switch self { - case .addTable: "Add table" - case .dropTable: "Drop table" - case .addColumn: "Add column" - case .dropColumn: "Drop column" - case .changeColumn: "Change column" - case .addIndex: "Add index" - case .dropIndex: "Drop index" - case .addForeignKey: "Add foreign key" - case .dropForeignKey: "Drop foreign key" - } - } -} - -package struct DatabaseSchemaDiffItem: Codable, Equatable, Identifiable, Sendable { - package let id: String - package let kind: DatabaseSchemaDiffKind - package let table: String - package let detail: String - package let sql: String - - package var isDestructive: Bool { kind.isDestructive } -} - -package struct DatabaseSchemaDiffResult: Codable, Equatable, Sendable { - package let source: DatabaseSchemaSnapshot - package let target: DatabaseSchemaSnapshot - package let items: [DatabaseSchemaDiffItem] - - package var requiresConfirmation: Bool { items.contains(where: { $0.isDestructive }) } - package var migrationSQL: String { items.map(\.sql).joined(separator: "\n") } -} - -package enum DatabaseSchemaDiffEngine { - package static func statements(in sql: String) -> [String] { - var result: [String] = [] - var statement = "" - var quote: Character? - var escaped = false - for character in sql { - statement.append(character) - if escaped { - escaped = false - continue - } - if character == "\\" && quote != nil { - escaped = true - continue - } - if let activeQuote = quote { - if character == activeQuote { quote = nil } - } else if character == "'" || character == "\"" || character == "`" { - quote = character - } else if character == ";" { - let completed = statement.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) - if !completed.isEmpty { result.append(String(completed)) } - statement = "" - } - } - let remaining = statement.trimmingCharacters(in: .whitespacesAndNewlines) - if !remaining.isEmpty { result.append(remaining) } - return result - } - - package static func compare(source: DatabaseSchemaSnapshot, target: DatabaseSchemaSnapshot) -> DatabaseSchemaDiffResult { - var items: [DatabaseSchemaDiffItem] = [] - let sourceTables = Dictionary(uniqueKeysWithValues: source.tables.map { ($0.name, $0) }) - let targetTables = Dictionary(uniqueKeysWithValues: target.tables.map { ($0.name, $0) }) - - let newTables = orderedNewTables(source.tables.filter { targetTables[$0.name] == nil }) - let newTableOrder = Dictionary(uniqueKeysWithValues: newTables.enumerated().map { ($0.element.name, $0.offset) }) - for table in newTables { - items.append(DatabaseSchemaDiffItem( - id: "add-table:\(table.name)", - kind: .addTable, - table: table.name, - detail: "Create table \(table.name)", - sql: createTableSQL(table, kind: target.kind, schema: target.schema) - )) - // Index definitions cannot be expressed portably inside CREATE - // TABLE, so carry explicit source indexes as subsequent steps. - compareIndexes( - table, - DatabaseSchemaTableSnapshot(name: table.name, columns: [], indexes: [], foreignKeys: []), - target: target, - items: &items - ) - } - for table in target.tables where sourceTables[table.name] == nil { - items.append(DatabaseSchemaDiffItem( - id: "drop-table:\(table.name)", - kind: .dropTable, - table: table.name, - detail: "Drop table \(table.name)", - sql: "DROP TABLE \(qualified(target.schema, table.name, kind: target.kind))" - )) - } - - for sourceTable in source.tables { - guard let targetTable = targetTables[sourceTable.name] else { continue } - compareColumns(sourceTable, targetTable, target: target, items: &items) - compareIndexes(sourceTable, targetTable, target: target, items: &items) - compareForeignKeys(sourceTable, targetTable, target: target, items: &items) - } - - return DatabaseSchemaDiffResult( - source: source, - target: target, - items: items.sorted { - let leftRank = migrationRank($0.kind) - let rightRank = migrationRank($1.kind) - if leftRank != rightRank { return leftRank < rightRank } - if $0.kind == .addTable, $1.kind == .addTable, - let leftOrder = newTableOrder[$0.table], let rightOrder = newTableOrder[$1.table], leftOrder != rightOrder { - return leftOrder < rightOrder - } - return $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending - } - ) - } - - private static func orderedNewTables(_ tables: [DatabaseSchemaTableSnapshot]) -> [DatabaseSchemaTableSnapshot] { - var remaining = Dictionary(uniqueKeysWithValues: tables.map { ($0.name, $0) }) - var result: [DatabaseSchemaTableSnapshot] = [] - while !remaining.isEmpty { - let ready = remaining.values.filter { table in - table.foreignKeys.allSatisfy { remaining[$0.referencedTable] == nil } - }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - if ready.isEmpty { - // A cycle cannot be topologically ordered. SQLite permits these - // declarations during table creation; other engines present the - // generated migration for the user to review. - result.append(contentsOf: remaining.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) - break - } - for table in ready { - result.append(table) - remaining.removeValue(forKey: table.name) - } - } - return result - } - - private static func migrationRank(_ kind: DatabaseSchemaDiffKind) -> Int { - switch kind { - case .addTable: 0 - case .addColumn, .changeColumn: 1 - case .addIndex: 2 - case .addForeignKey: 3 - case .dropForeignKey: 4 - case .dropIndex: 5 - case .dropColumn: 6 - case .dropTable: 7 - } - } - - private static func compareColumns( - _ source: DatabaseSchemaTableSnapshot, - _ target: DatabaseSchemaTableSnapshot, - target snapshot: DatabaseSchemaSnapshot, - items: inout [DatabaseSchemaDiffItem] - ) { - let sourceColumns = Dictionary(uniqueKeysWithValues: source.columns.map { ($0.name, $0) }) - let targetColumns = Dictionary(uniqueKeysWithValues: target.columns.map { ($0.name, $0) }) - - for column in source.columns where targetColumns[column.name] == nil { - items.append(DatabaseSchemaDiffItem( - id: "add-column:\(source.name):\(column.name)", - kind: .addColumn, - table: source.name, - detail: "Add column \(column.name)", - sql: addColumnSQL(table: source.name, column: column, kind: snapshot.kind, schema: snapshot.schema) - )) - } - for column in target.columns where sourceColumns[column.name] == nil { - items.append(DatabaseSchemaDiffItem( - id: "drop-column:\(source.name):\(column.name)", - kind: .dropColumn, - table: source.name, - detail: "Drop column \(column.name)", - sql: "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) DROP COLUMN \(quote(column.name, kind: snapshot.kind))" - )) - } - for sourceColumn in source.columns { - guard let targetColumn = targetColumns[sourceColumn.name] else { continue } - guard sourceColumn.dataType.caseInsensitiveCompare(targetColumn.dataType) != .orderedSame - || sourceColumn.isNullable != targetColumn.isNullable - || sourceColumn.defaultValue != targetColumn.defaultValue else { continue } - let type = sourceColumn.dataType.isEmpty ? targetColumn.dataType : sourceColumn.dataType - let nullable = sourceColumn.isNullable ? "NULL" : "NOT NULL" - let defaultSQL = sourceColumn.defaultValue.map { " DEFAULT \($0)" } ?? "" - let sql: String - switch snapshot.kind { - case .mysql, .mariadb: - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) MODIFY COLUMN \(quote(sourceColumn.name, kind: snapshot.kind)) \(type) \(nullable)\(defaultSQL)" - case .postgresql: - // PostgreSQL requires separate statements for type/nullability. - // The first statement is the deterministic, non-destructive part; - // the complete migration text below retains all requested clauses. - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) ALTER COLUMN \(quote(sourceColumn.name, kind: snapshot.kind)) TYPE \(type); ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) ALTER COLUMN \(quote(sourceColumn.name, kind: snapshot.kind)) \(sourceColumn.isNullable ? "DROP NOT NULL" : "SET NOT NULL")\(defaultSQL.isEmpty ? "" : "; ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) ALTER COLUMN \(quote(sourceColumn.name, kind: snapshot.kind)) SET DEFAULT \(String(defaultSQL.dropFirst(9)))")" - case .sqlserver: - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) ALTER COLUMN \(quote(sourceColumn.name, kind: snapshot.kind)) \(type) \(nullable)" - case .sqlite, .mongodb, .redis, .nacos: - sql = "-- SQLite requires a table rebuild to change column definition: \(source.name).\(sourceColumn.name)" - } - items.append(DatabaseSchemaDiffItem( - id: "change-column:\(source.name):\(sourceColumn.name)", - kind: .changeColumn, - table: source.name, - detail: "Change column \(sourceColumn.name) (\(targetColumn.dataType) -> \(sourceColumn.dataType))", - sql: sql - )) - } - } - - private static func compareIndexes( - _ source: DatabaseSchemaTableSnapshot, - _ target: DatabaseSchemaTableSnapshot, - target snapshot: DatabaseSchemaSnapshot, - items: inout [DatabaseSchemaDiffItem] - ) { - let sourceNames = Set(source.indexes.map(\.name)) - let targetNames = Set(target.indexes.map(\.name)) - for index in source.indexes where !targetNames.contains(index.name) { - let sql = index.definition.isEmpty - ? "-- Create index \(index.name) on \(source.name) from the source definition" - : normalizeDefinition(index.definition, target: snapshot) - items.append(DatabaseSchemaDiffItem(id: "add-index:\(source.name):\(index.name)", kind: .addIndex, table: source.name, detail: "Add index \(index.name)", sql: sql)) - } - for index in target.indexes where !sourceNames.contains(index.name) { - let sql = snapshot.kind == .mysql || snapshot.kind == .mariadb - ? "DROP INDEX \(quote(index.name, kind: snapshot.kind)) ON \(qualified(snapshot.schema, source.name, kind: snapshot.kind))" - : "DROP INDEX \(qualified(snapshot.schema, index.name, kind: snapshot.kind))" - items.append(DatabaseSchemaDiffItem(id: "drop-index:\(source.name):\(index.name)", kind: .dropIndex, table: source.name, detail: "Drop index \(index.name)", sql: sql)) - } - } - - private static func compareForeignKeys( - _ source: DatabaseSchemaTableSnapshot, - _ target: DatabaseSchemaTableSnapshot, - target snapshot: DatabaseSchemaSnapshot, - items: inout [DatabaseSchemaDiffItem] - ) { - let sourceGroups = foreignKeyGroups(source.foreignKeys) - let targetGroups = foreignKeyGroups(target.foreignKeys) - let sourceSignatures = Set(sourceGroups.map(foreignKeySignature)) - let targetSignatures = Set(targetGroups.map(foreignKeySignature)) - - for group in sourceGroups where !targetSignatures.contains(foreignKeySignature(group)) { - let sql: String - if snapshot.kind == .sqlite { - sql = "-- SQLite requires a table rebuild to add foreign key \(group.name) to \(source.name)" - } else { - let clause = foreignKeyClause(group, kind: snapshot.kind, includeConstraintName: true) - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) ADD \(clause)" - } - items.append(DatabaseSchemaDiffItem(id: "add-fk:\(source.name):\(group.name)", kind: .addForeignKey, table: source.name, detail: "Add foreign key \(group.name)", sql: sql)) - } - for group in targetGroups where !sourceSignatures.contains(foreignKeySignature(group)) { - let sql: String - if snapshot.kind == .sqlite { - sql = "-- SQLite requires a table rebuild to drop foreign key \(group.name) from \(source.name)" - } else if snapshot.kind == .mysql || snapshot.kind == .mariadb { - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) DROP FOREIGN KEY \(quote(group.name, kind: snapshot.kind))" - } else { - sql = "ALTER TABLE \(qualified(snapshot.schema, source.name, kind: snapshot.kind)) DROP CONSTRAINT \(quote(group.name, kind: snapshot.kind))" - } - items.append(DatabaseSchemaDiffItem(id: "drop-fk:\(source.name):\(group.name)", kind: .dropForeignKey, table: source.name, detail: "Drop foreign key \(group.name)", sql: sql)) - } - } - - private static func createTableSQL(_ table: DatabaseSchemaTableSnapshot, kind: DatabaseKind, schema: String) -> String { - let definitions = table.columns.map { column in - var definition = "\(quote(column.name, kind: kind)) \(column.dataType.isEmpty ? "TEXT" : column.dataType)" - if !column.isNullable { definition += " NOT NULL" } - if let defaultValue = column.defaultValue, !defaultValue.isEmpty { definition += " DEFAULT \(defaultValue)" } - return definition - } - let primaryKeys = table.columns.filter(\.isPrimaryKey).map { quote($0.name, kind: kind) } - let primarySQL = primaryKeys.isEmpty ? [] : ["PRIMARY KEY (\(primaryKeys.joined(separator: ", ")))" ] - let foreignKeys = foreignKeyGroups(table.foreignKeys).map { - foreignKeyClause($0, kind: kind, includeConstraintName: !(kind == .sqlite && Int($0.name) != nil)) - } - let body = (definitions + primarySQL + foreignKeys).joined(separator: ", ") - return "CREATE TABLE \(qualified(schema, table.name, kind: kind)) (\(body))" - } - - private static func foreignKeyGroups(_ keys: [DatabaseSchemaForeignKeySnapshot]) -> [DatabaseForeignKeyGroup] { - Dictionary(grouping: keys) { "\($0.name)\u{0}\($0.referencedTable)" } - .values - .compactMap { entries -> DatabaseForeignKeyGroup? in - guard let first = entries.first else { return nil } - return DatabaseForeignKeyGroup( - name: first.name, - referencedTable: first.referencedTable, - columns: entries.sorted { $0.column.localizedCaseInsensitiveCompare($1.column) == .orderedAscending } - ) - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - } - - private static func foreignKeySignature(_ group: DatabaseForeignKeyGroup) -> String { - "\(group.name)|\(group.referencedTable)|\(group.columns.map { "\($0.column)>\($0.referencedColumn)" }.joined(separator: ","))" - } - - private static func foreignKeyClause(_ group: DatabaseForeignKeyGroup, kind: DatabaseKind, includeConstraintName: Bool) -> String { - let constraint = includeConstraintName ? "CONSTRAINT \(quote(group.name, kind: kind)) " : "" - let columns = group.columns.map { quote($0.column, kind: kind) }.joined(separator: ", ") - let references = group.columns.map { quote($0.referencedColumn, kind: kind) }.joined(separator: ", ") - return "\(constraint)FOREIGN KEY (\(columns)) REFERENCES \(quote(group.referencedTable, kind: kind)) (\(references))" - } - - private static func addColumnSQL(table: String, column: DatabaseSchemaColumnSnapshot, kind: DatabaseKind, schema: String) -> String { - var sql = "ALTER TABLE \(qualified(schema, table, kind: kind)) ADD COLUMN \(quote(column.name, kind: kind)) \(column.dataType.isEmpty ? "TEXT" : column.dataType)" - if !column.isNullable { sql += " NOT NULL" } - if let defaultValue = column.defaultValue, !defaultValue.isEmpty { sql += " DEFAULT \(defaultValue)" } - return sql - } - - private static func normalizeDefinition(_ definition: String, target: DatabaseSchemaSnapshot) -> String { - // PostgreSQL already returns a complete CREATE INDEX definition. For - // other engines the source definition is still the safest reviewable SQL. - definition.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ";$", with: "", options: .regularExpression) - } - - private static func qualified(_ schema: String, _ name: String, kind: DatabaseKind) -> String { - let quotedName = quote(name, kind: kind) - guard !schema.isEmpty, kind != .mysql, kind != .mariadb else { return quotedName } - return "\(quote(schema, kind: kind)).\(quotedName)" - } - - private static func quote(_ value: String, kind: DatabaseKind) -> String { - switch kind { - case .mysql, .mariadb: return "`\(value.replacingOccurrences(of: "`", with: "``"))`" - case .sqlserver: return "[\(value.replacingOccurrences(of: "]", with: "]]"))]" - case .postgresql, .sqlite, .mongodb, .redis, .nacos: return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" - } - } -} diff --git a/macos/Sources/LitheDatabaseModule/Module/DatabaseModule.swift b/macos/Sources/LitheDatabaseModule/Module/DatabaseModule.swift deleted file mode 100644 index 4b6e4ccd9..000000000 --- a/macos/Sources/LitheDatabaseModule/Module/DatabaseModule.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import LitheModuleAPI - -@MainActor -public final class DatabaseModuleCapability: NSObject { - package let feature: DatabaseFeatureModel - package init(feature: DatabaseFeatureModel) { self.feature = feature } -} - -@MainActor -public final class DatabaseModule: LitheModule { - public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .database) - public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .database)! - - public let manifest = moduleManifest - private let processRunner: any DatabaseProcessRunning - private let executableURL: URL? - private let preferenceStore: any DatabasePreferenceStore - private let secureStore: any DatabaseSecureStore - private let recoveryStore: any DatabaseRecoveryStoring - private let fileStorage: any DatabaseFileStorage - private var capability: DatabaseModuleCapability? - - package init( - processRunner: any DatabaseProcessRunning, - executableURL: URL?, - preferenceStore: any DatabasePreferenceStore, - secureStore: any DatabaseSecureStore, - recoveryStore: any DatabaseRecoveryStoring, - fileStorage: any DatabaseFileStorage - ) { - self.processRunner = processRunner - self.executableURL = executableURL - self.preferenceStore = preferenceStore - self.secureStore = secureStore - self.recoveryStore = recoveryStore - self.fileStorage = fileStorage - } - - public func activate(context: ModuleContext) async throws { - guard capability == nil else { return } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: processRunner, executableURL: executableURL), - connectionStore: DatabaseConnectionStore(store: preferenceStore, secureStore: secureStore), - recoveryStore: recoveryStore, - fileStorage: fileStorage - ) - context.resources.register(DatabaseFeatureResource(feature: feature)) - capability = DatabaseModuleCapability(feature: feature) - } - - public func prepareForSleep() async throws { - guard capability?.feature.hasActiveModuleWork != true else { throw DatabaseModuleSleepError.activeWork } - } - - public func sleep() async { releaseFeature() } - public func shutdown() async { releaseFeature() } - - public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { - guard let capability else { return [:] } - return [.databaseWorkspace: capability] - } - - public func contributions() -> [ModuleContribution] { - Self.moduleContributions - } - - private func releaseFeature() { - capability?.feature.prepareForModuleRelease() - capability = nil - } -} - -public enum DatabaseModuleSleepError: LocalizedError, Sendable { - case activeWork - public var errorDescription: String? { "Database operations or scheduled backup work are still active." } -} - -@MainActor -private final class DatabaseFeatureResource: ModuleResource { - let feature: DatabaseFeatureModel - init(feature: DatabaseFeatureModel) { self.feature = feature } - var moduleResourceKind: String { "database-timer-and-operations" } - var isModuleResourceActive: Bool { feature.hasActiveModuleWork } - func stopModuleResource() async { feature.prepareForModuleRelease() } -} diff --git a/macos/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift b/macos/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift deleted file mode 100644 index 07667b624..000000000 --- a/macos/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -package struct DatabaseProcessRequest: Sendable { - package let executablePath: String - package let environment: [String: String]? - package let standardInput: Data? - package let timeoutMilliseconds: Int? - - package init(executablePath: String, environment: [String: String]?, standardInput: Data?, timeoutMilliseconds: Int?) { - self.executablePath = executablePath - self.environment = environment - self.standardInput = standardInput - self.timeoutMilliseconds = timeoutMilliseconds - } -} - -package struct DatabaseProcessResult: Sendable { - package let output: String - package let exitCode: Int32 - package var succeeded: Bool { exitCode == 0 } - - package init(output: String, exitCode: Int32) { - self.output = output - self.exitCode = exitCode - } -} - -package protocol DatabaseProcessRunning: Sendable { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult -} - -package protocol DatabasePreferenceStore: Sendable { - func data(forKey key: String) -> Data? - func set(_ value: Any?, forKey key: String) -} - -package protocol DatabaseSecureStore: Sendable { - func read(key: String) -> String? - func write(_ value: String, key: String) throws - func delete(key: String) throws -} - -package protocol DatabaseFileStorage: Sendable { - func temporaryDirectory() -> URL - func fileExists(at url: URL) -> Bool - func readData(from url: URL) throws -> Data - func copyItem(at sourceURL: URL, to destinationURL: URL) throws - func removeItem(at url: URL) throws -} - -package struct UnavailableDatabaseFileStorage: DatabaseFileStorage { - private let root = URL(fileURLWithPath: "/unavailable") - package func temporaryDirectory() -> URL { root } - package func fileExists(at url: URL) -> Bool { false } - package func readData(from url: URL) throws -> Data { throw CocoaError(.fileReadNoSuchFile) } - package func copyItem(at sourceURL: URL, to destinationURL: URL) throws { throw CocoaError(.fileNoSuchFile) } - package func removeItem(at url: URL) throws { throw CocoaError(.fileNoSuchFile) } -} diff --git a/macos/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift b/macos/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift deleted file mode 100644 index c9f06e2f4..000000000 --- a/macos/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift +++ /dev/null @@ -1,151 +0,0 @@ -import Foundation - -package struct DatabaseBackupSchedule: Codable, Equatable, Identifiable, Sendable { - package let profileID: UUID - package var isEnabled: Bool - package var intervalHours: Int - package var retentionCount: Int - package var nextRunAt: Date - - package var id: UUID { profileID } - - package init(profileID: UUID, isEnabled: Bool = true, intervalHours: Int = 24, retentionCount: Int = 14, nextRunAt: Date = Date()) { - self.profileID = profileID - self.isEnabled = isEnabled - self.intervalHours = max(1, intervalHours) - self.retentionCount = max(1, retentionCount) - self.nextRunAt = nextRunAt - } -} - -package struct DatabaseRecoveryPoint: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package let profileID: UUID - package let reason: String - package let createdAt: Date - package let byteCount: Int - package let fileName: String - package let originalByteCount: Int - package let isCompressed: Bool - package let sha256: String - - private enum CodingKeys: String, CodingKey { case id, profileID, reason, createdAt, byteCount, fileName, originalByteCount, isCompressed, sha256 } - - package init(id: UUID, profileID: UUID, reason: String, createdAt: Date, byteCount: Int, fileName: String, originalByteCount: Int, isCompressed: Bool, sha256: String = "") { - self.id = id - self.profileID = profileID - self.reason = reason - self.createdAt = createdAt - self.byteCount = byteCount - self.fileName = fileName - self.originalByteCount = originalByteCount - self.isCompressed = isCompressed - self.sha256 = sha256 - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - profileID = try container.decode(UUID.self, forKey: .profileID) - reason = try container.decode(String.self, forKey: .reason) - createdAt = try container.decode(Date.self, forKey: .createdAt) - byteCount = try container.decode(Int.self, forKey: .byteCount) - fileName = try container.decode(String.self, forKey: .fileName) - originalByteCount = try container.decodeIfPresent(Int.self, forKey: .originalByteCount) ?? byteCount - isCompressed = try container.decodeIfPresent(Bool.self, forKey: .isCompressed) ?? false - sha256 = try container.decodeIfPresent(String.self, forKey: .sha256) ?? "" - } -} - -package struct DatabaseAuditEntry: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package let profileID: UUID - package let action: String - package let summary: String - package let createdAt: Date - package let recoveryPointID: UUID? - package let rowsAffected: UInt64? - package let succeeded: Bool - package let errorMessage: String? -} - -package enum DatabaseExecutionSource: String, Codable, Equatable, Sendable { - case sql - case redis - case nacos -} - -package enum DatabaseExecutionStatus: String, Codable, Equatable, Sendable { - case succeeded - case failed - case cancelled -} - -package struct DatabaseExecutionEvent: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package let profileID: UUID - package let profileName: String - package let source: DatabaseExecutionSource - package let operation: String - package let startedAt: Date - package let durationMilliseconds: Int - package let status: DatabaseExecutionStatus - package let rowsReturned: Int? - package let rowsAffected: UInt64? - package let errorMessage: String? -} - -package protocol DatabaseRecoveryStoring: AnyObject, Sendable { - func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint - func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String, progress: ((Double) -> Void)?) throws -> DatabaseRecoveryPoint - func recoveryPoints(for profileID: UUID?) -> [DatabaseRecoveryPoint] - func data(for point: DatabaseRecoveryPoint) throws -> Data - func fileURL(for point: DatabaseRecoveryPoint) throws -> URL - func delete(_ point: DatabaseRecoveryPoint) throws - func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int) throws - func auditEntries(for profileID: UUID?) -> [DatabaseAuditEntry] - func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int) throws - func executionEvents(for profileID: UUID?) -> [DatabaseExecutionEvent] - func deleteExecutionEvents(for profileID: UUID) throws -} - -final class UnavailableDatabaseRecoveryStore: DatabaseRecoveryStoring, @unchecked Sendable { - private let error = CocoaError(.featureUnsupported) - package func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint { throw error } - package func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String, progress: ((Double) -> Void)?) throws -> DatabaseRecoveryPoint { throw error } - package func recoveryPoints(for profileID: UUID?) -> [DatabaseRecoveryPoint] { [] } - package func data(for point: DatabaseRecoveryPoint) throws -> Data { throw error } - package func fileURL(for point: DatabaseRecoveryPoint) throws -> URL { throw error } - package func delete(_ point: DatabaseRecoveryPoint) throws { throw error } - package func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int) throws { throw error } - package func auditEntries(for profileID: UUID?) -> [DatabaseAuditEntry] { [] } - package func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int) throws { throw error } - package func executionEvents(for profileID: UUID?) -> [DatabaseExecutionEvent] { [] } - package func deleteExecutionEvents(for profileID: UUID) throws { throw error } -} - -package extension DatabaseRecoveryStoring { - func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String = "", progress: ((Double) -> Void)? = nil) throws -> DatabaseRecoveryPoint { - try createRecoveryPoint(profileID: profileID, reason: reason, fileURL: fileURL, expectedSHA256: expectedSHA256, progress: progress) - } - - func recoveryPoints(for profileID: UUID? = nil) -> [DatabaseRecoveryPoint] { - recoveryPoints(for: profileID) - } - - func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int = 500) throws { - try appendAudit(entry, maximumEntries: maximumEntries) - } - - func auditEntries(for profileID: UUID? = nil) -> [DatabaseAuditEntry] { - auditEntries(for: profileID) - } - - func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int = 1_000) throws { - try appendExecutionEvent(event, maximumEntries: maximumEntries) - } - - func executionEvents(for profileID: UUID? = nil) -> [DatabaseExecutionEvent] { - executionEvents(for: profileID) - } -} diff --git a/macos/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift b/macos/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift deleted file mode 100644 index a2d374df1..000000000 --- a/macos/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift +++ /dev/null @@ -1,202 +0,0 @@ -import Foundation - -package enum DatabasePasswordPersistence: String, Codable, CaseIterable, Sendable { - case untilRestart - case forever -} - -package struct DatabaseProfile: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package var name: String - package var kind: DatabaseKind - package var host: String - package var port: UInt16 - package var passwordPersistence: DatabasePasswordPersistence = .forever - package var username: String - package var database: String - /// nil follows all accessible databases, while [] explicitly hides all databases. - package var visibleDatabases: [String]? - package var path: String - package var ssl: Bool - /// Legacy display grouping. New profiles use folderID; retain this field so - /// older saved profiles can be migrated without losing the user's grouping. - package var group: String - package var folderID: UUID? - package var colorHex: String - package var readOnly: Bool - package var productionProtection: Bool - package var maskSensitiveFields: Bool - package var sensitiveColumnPatterns: [String] - package var caCertificatePath: String - package var serverName: String - package var sshHost: String - package var sshPort: UInt16 - package var sshUsername: String - package var sshKeyPath: String - package var sshLocalPort: UInt16 - package var proxyURL: String - - private enum CodingKeys: String, CodingKey { - case id, name, kind, host, port, passwordPersistence, username, database, visibleDatabases, path, ssl, group, folderID, colorHex - case readOnly, productionProtection, maskSensitiveFields, sensitiveColumnPatterns - case caCertificatePath, serverName, sshHost, sshPort, sshUsername, sshKeyPath, sshLocalPort, proxyURL - } - - package init(id: UUID = UUID(), name: String, kind: DatabaseKind, host: String = "127.0.0.1", port: UInt16 = 0, username: String = "", database: String = "", path: String = "", ssl: Bool = false, group: String = "", folderID: UUID? = nil, colorHex: String = "", readOnly: Bool = false, productionProtection: Bool = false, maskSensitiveFields: Bool = false, sensitiveColumnPatterns: [String] = ["password", "secret", "token", "api_key"], caCertificatePath: String = "", serverName: String = "", sshHost: String = "", sshPort: UInt16 = 0, sshUsername: String = "", sshKeyPath: String = "", sshLocalPort: UInt16 = 0, proxyURL: String = "") { - self.id = id; self.name = name; self.kind = kind; self.host = host; self.port = port - self.username = username; self.database = database; self.path = path; self.ssl = ssl - self.group = group; self.folderID = folderID; self.colorHex = colorHex; self.readOnly = readOnly; self.productionProtection = productionProtection; self.maskSensitiveFields = maskSensitiveFields; self.sensitiveColumnPatterns = sensitiveColumnPatterns - self.caCertificatePath = caCertificatePath; self.serverName = serverName; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUsername = sshUsername; self.sshKeyPath = sshKeyPath; self.sshLocalPort = sshLocalPort; self.proxyURL = proxyURL - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - kind = try container.decode(DatabaseKind.self, forKey: .kind) - host = try container.decodeIfPresent(String.self, forKey: .host) ?? "127.0.0.1" - port = try container.decodeIfPresent(UInt16.self, forKey: .port) ?? 0 - username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" - database = try container.decodeIfPresent(String.self, forKey: .database) ?? "" - visibleDatabases = try container.decodeIfPresent([String].self, forKey: .visibleDatabases) - path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" - passwordPersistence = try container.decodeIfPresent(DatabasePasswordPersistence.self, forKey: .passwordPersistence) ?? .forever - ssl = try container.decodeIfPresent(Bool.self, forKey: .ssl) ?? false - group = try container.decodeIfPresent(String.self, forKey: .group) ?? "" - folderID = try container.decodeIfPresent(UUID.self, forKey: .folderID) - colorHex = try container.decodeIfPresent(String.self, forKey: .colorHex) ?? "" - readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false - productionProtection = try container.decodeIfPresent(Bool.self, forKey: .productionProtection) ?? false - maskSensitiveFields = try container.decodeIfPresent(Bool.self, forKey: .maskSensitiveFields) ?? false - sensitiveColumnPatterns = try container.decodeIfPresent([String].self, forKey: .sensitiveColumnPatterns) ?? ["password", "secret", "token", "api_key"] - caCertificatePath = try container.decodeIfPresent(String.self, forKey: .caCertificatePath) ?? "" - serverName = try container.decodeIfPresent(String.self, forKey: .serverName) ?? "" - sshHost = try container.decodeIfPresent(String.self, forKey: .sshHost) ?? "" - sshPort = try container.decodeIfPresent(UInt16.self, forKey: .sshPort) ?? 0 - sshUsername = try container.decodeIfPresent(String.self, forKey: .sshUsername) ?? "" - sshKeyPath = try container.decodeIfPresent(String.self, forKey: .sshKeyPath) ?? "" - sshLocalPort = try container.decodeIfPresent(UInt16.self, forKey: .sshLocalPort) ?? 0 - proxyURL = try container.decodeIfPresent(String.self, forKey: .proxyURL) ?? "" - } -} - -package struct DatabaseConnectionFolder: Codable, Equatable, Identifiable, Sendable { - package let id: UUID - package var name: String - package var parentID: UUID? - - package init(id: UUID = UUID(), name: String, parentID: UUID? = nil) { - self.id = id; self.name = name; self.parentID = parentID - } -} - -package final class DatabaseConnectionStore: @unchecked Sendable { - private static let profilesKey = "database.profiles.v1" - private static let foldersKey = "database.connection-folders.v1" - private static let sqlHistoryKey = "database.sql-history.v1" - private static let backupSchedulesKey = "database.backup-schedules.v1" - private static let maximumHistoryEntries = 100 - private let store: any DatabasePreferenceStore - private let secureStore: any DatabaseSecureStore - - package init(store: any DatabasePreferenceStore, secureStore: any DatabaseSecureStore) { - self.store = store - self.secureStore = secureStore - } - - package func load() -> [DatabaseProfile] { - guard let data = store.data(forKey: Self.profilesKey) else { return [] } - return (try? JSONDecoder().decode([DatabaseProfile].self, from: data)) ?? [] - } - - package func save(_ profiles: [DatabaseProfile]) throws { - store.set(try JSONEncoder().encode(profiles), forKey: Self.profilesKey) - } - - package func saveOrganization(profiles: [DatabaseProfile], folders: [DatabaseConnectionFolder]) throws { - // Encode both values before publishing either preference; no credential storage is involved. - let profileData = try JSONEncoder().encode(profiles) - let folderData = try JSONEncoder().encode(folders) - store.set(folderData, forKey: Self.foldersKey) - store.set(profileData, forKey: Self.profilesKey) - } - - package func loadFolders() -> [DatabaseConnectionFolder] { - guard let data = store.data(forKey: Self.foldersKey) else { return [] } - return (try? JSONDecoder().decode([DatabaseConnectionFolder].self, from: data)) ?? [] - } - - package func saveFolders(_ folders: [DatabaseConnectionFolder]) throws { - store.set(try JSONEncoder().encode(folders), forKey: Self.foldersKey) - } - - package func loadSQLHistory() -> [DatabaseSQLHistoryEntry] { - guard let data = store.data(forKey: Self.sqlHistoryKey) else { return [] } - return (try? JSONDecoder().decode([DatabaseSQLHistoryEntry].self, from: data)) ?? [] - } - - package func appendSQLHistory(_ entry: DatabaseSQLHistoryEntry) throws { - var entries = loadSQLHistory().filter { $0.id != entry.id } - entries.insert(entry, at: 0) - store.set(try JSONEncoder().encode(Array(entries.prefix(Self.maximumHistoryEntries))), forKey: Self.sqlHistoryKey) - } - - package func deleteSQLHistory(for profileID: UUID) throws { - let remaining = loadSQLHistory().filter { $0.profileID != profileID } - store.set(try JSONEncoder().encode(remaining), forKey: Self.sqlHistoryKey) - } - - package func loadBackupSchedules() -> [DatabaseBackupSchedule] { - guard let data = store.data(forKey: Self.backupSchedulesKey) else { return [] } - return (try? JSONDecoder().decode([DatabaseBackupSchedule].self, from: data)) ?? [] - } - - package func saveBackupSchedules(_ schedules: [DatabaseBackupSchedule]) throws { - store.set(try JSONEncoder().encode(schedules), forKey: Self.backupSchedulesKey) - } - - package func deleteBackupSchedule(for profileID: UUID) throws { - try saveBackupSchedules(loadBackupSchedules().filter { $0.profileID != profileID }) - } - - // Process-owned secrets survive module reloads but never reach disk. UUIDs isolate profiles. - private static let passwordLock = NSLock() - nonisolated(unsafe) private static var temporaryPasswords: [UUID: String] = [:] - - package func password(for id: UUID) -> String { - Self.passwordLock.withLock { - if let value = Self.temporaryPasswords[id] { return value } - if persistence(for: id) == .untilRestart { return "" } - return secureStore.read(key: passwordKey(id)) ?? "" - } - } - package func hasPassword(for id: UUID) -> Bool { - Self.passwordLock.withLock { - Self.temporaryPasswords[id] != nil || - (persistence(for: id) == .forever && secureStore.read(key: passwordKey(id)) != nil) - } - } - private func persistence(for id: UUID) -> DatabasePasswordPersistence { - load().first { $0.id == id }?.passwordPersistence ?? .forever - } - package func savePassword(_ password: String, for id: UUID, persistence: DatabasePasswordPersistence? = nil) throws { - // ponytail: synchronous credential IO is serialized; use a dedicated credential actor if it becomes contended. - try Self.passwordLock.withLock { - switch persistence ?? self.persistence(for: id) { - case .untilRestart: - try secureStore.delete(key: passwordKey(id)) - Self.temporaryPasswords[id] = password - case .forever: - try secureStore.write(password, key: passwordKey(id)) - Self.temporaryPasswords.removeValue(forKey: id) - } - } - } - package func deletePassword(for id: UUID) throws { - try Self.passwordLock.withLock { - try secureStore.delete(key: passwordKey(id)) - Self.temporaryPasswords.removeValue(forKey: id) - } - } - private func passwordKey(_ id: UUID) -> String { "database.connection.\(id.uuidString).password" } -} diff --git a/macos/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift b/macos/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift deleted file mode 100644 index 9dba16198..000000000 --- a/macos/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift +++ /dev/null @@ -1,353 +0,0 @@ -import CryptoKit -import Foundation - -package struct DatabaseDBXImportFolder: Equatable, Identifiable, Sendable { - package let id: UUID - package let name: String - package let parentID: UUID? -} - -package struct DatabaseDBXImportCandidate: Equatable, Identifiable, Sendable { - package let sourceID: String - package var profile: DatabaseProfile - package let password: String - package let warnings: [String] - package let isDuplicate: Bool - - package var id: UUID { profile.id } -} - -package struct DatabaseDBXImportPlan: Equatable, Sendable { - package let candidates: [DatabaseDBXImportCandidate] - package let folders: [DatabaseDBXImportFolder] - package let unsupportedTypes: [String: Int] - package let wasEncrypted: Bool - - package var importableCount: Int { candidates.count { !$0.isDuplicate } } - package var duplicateCount: Int { candidates.count { $0.isDuplicate } } - package var unsupportedCount: Int { unsupportedTypes.values.reduce(0, +) } -} - -package enum DatabaseDBXImportError: LocalizedError, Equatable { - case invalidFile - case passphraseRequired - case wrongPassphrase - case unsupportedEncryptedFormat - - package var errorDescription: String? { - switch self { - case .invalidFile: "The selected file is not a valid DBX connection export." - case .passphraseRequired: "Enter the DBX export password to read this file." - case .wrongPassphrase: "The DBX export password is incorrect." - case .unsupportedEncryptedFormat: "This DBX encrypted export version is not supported." - } - } -} - -package struct DatabaseDBXImportService: Sendable { - package init() {} - package func isEncrypted(_ data: Data) -> Bool { - guard let envelope = try? JSONDecoder().decode(DBXEncryptedEnvelope.self, from: data) else { return false } - return envelope.format == "dbx-encrypted" - } - - package func parse( - data: Data, - passphrase: String?, - existingProfiles: [DatabaseProfile] - ) throws -> DatabaseDBXImportPlan { - let encrypted = isEncrypted(data) - let plaintext: Data - if encrypted { - guard let passphrase, !passphrase.isEmpty else { throw DatabaseDBXImportError.passphraseRequired } - plaintext = try decrypt(data, passphrase: passphrase) - } else { - plaintext = data - } - - let export = try decodeExport(plaintext) - let layout = buildLayout(export.layout) - var unsupportedTypes: [String: Int] = [:] - var candidates: [DatabaseDBXImportCandidate] = [] - - for connection in export.connections { - guard let kind = databaseKind(connection.dbType) else { - unsupportedTypes[connection.dbType, default: 0] += 1 - continue - } - let folderID = layout.connectionFolderIDs[connection.id] - let firstSSH = connection.transportLayers?.first { $0.type == "ssh" && $0.enabled != false } - let firstProxy = connection.transportLayers?.first { $0.type == "proxy" && $0.enabled != false } - var warnings: [String] = [] - let activeLayers = connection.transportLayers?.filter { $0.enabled != false } ?? [] - if activeLayers.filter({ $0.type == "ssh" }).count > 1 { - warnings.append("Only the first SSH tunnel was imported.") - } - if firstSSH?.password?.nonEmpty != nil { - warnings.append("The SSH password could not be imported; select an SSH key or enter it again.") - } - if activeLayers.contains(where: { $0.type == "http_tunnel" }) { - warnings.append("DBX HTTP tunnel settings are not supported.") - } - if firstProxy != nil { - warnings.append("DBX proxy settings require manual review.") - } - if connection.connectionString?.nonEmpty != nil { - warnings.append("The DBX connection string requires manual review.") - } - if connection.clientCertPath?.nonEmpty != nil || connection.clientKeyPath?.nonEmpty != nil { - warnings.append("Client certificate settings require manual review.") - } - if connection.urlParams?.nonEmpty != nil { - warnings.append("DBX URL parameters require manual review.") - } - if kind == .redis, connection.redisConnectionMode != nil, connection.redisConnectionMode != "standalone" { - warnings.append("Redis Sentinel or Cluster settings require manual review.") - } - - let path = kind == .sqlite ? connection.host : "" - let host = kind == .sqlite ? "" : connection.host - let profile = DatabaseProfile( - name: connection.name.nonEmpty ?? connection.id, - kind: kind, - host: host, - port: UInt16(clamping: connection.port), - username: connection.username, - database: kind == .sqlite ? "" : (connection.database ?? ""), - path: path, - ssl: connection.ssl ?? false, - folderID: folderID, - colorHex: connection.color ?? "", - readOnly: connection.readOnly ?? false, - productionProtection: connection.isProduction ?? false, - caCertificatePath: connection.caCertPath ?? "", - sshHost: firstSSH?.host ?? "", - sshPort: UInt16(clamping: firstSSH?.port ?? 0), - sshUsername: firstSSH?.user ?? "", - sshKeyPath: firstSSH?.keyPath ?? "" - ) - let duplicate = existingProfiles.contains { existing in - existing.name == profile.name && existing.host == profile.host && existing.port == profile.port && existing.path == profile.path - } - candidates.append(DatabaseDBXImportCandidate( - sourceID: connection.id, - profile: profile, - password: connection.password, - warnings: warnings, - isDuplicate: duplicate - )) - } - - return DatabaseDBXImportPlan( - candidates: candidates, - folders: layout.folders, - unsupportedTypes: unsupportedTypes, - wasEncrypted: encrypted - ) - } - - private func decrypt(_ data: Data, passphrase: String) throws -> Data { - let envelope: DBXEncryptedEnvelope - do { envelope = try JSONDecoder().decode(DBXEncryptedEnvelope.self, from: data) } - catch { throw DatabaseDBXImportError.invalidFile } - guard envelope.format == "dbx-encrypted", envelope.version == 1 else { - throw DatabaseDBXImportError.unsupportedEncryptedFormat - } - guard let salt = Data(base64Encoded: envelope.salt), - let nonceData = Data(base64Encoded: envelope.iv), - let sealedData = Data(base64Encoded: envelope.data), - nonceData.count == 12, - sealedData.count >= 16 else { - throw DatabaseDBXImportError.invalidFile - } - let keyData = pbkdf2SHA256(password: Data(passphrase.utf8), salt: salt, iterations: 100_000, keyLength: 32) - let ciphertext = sealedData.dropLast(16) - let tag = sealedData.suffix(16) - do { - let box = try AES.GCM.SealedBox( - nonce: AES.GCM.Nonce(data: nonceData), - ciphertext: ciphertext, - tag: tag - ) - return try AES.GCM.open(box, using: SymmetricKey(data: keyData)) - } catch { - throw DatabaseDBXImportError.wrongPassphrase - } - } - - private func pbkdf2SHA256(password: Data, salt: Data, iterations: Int, keyLength: Int) -> Data { - let key = SymmetricKey(data: password) - var derived = Data() - var blockIndex: UInt32 = 1 - while derived.count < keyLength { - var blockSalt = salt - var bigEndian = blockIndex.bigEndian - blockSalt.append(Data(bytes: &bigEndian, count: MemoryLayout.size)) - var u = Data(HMAC.authenticationCode(for: blockSalt, using: key)) - var block = u - if iterations > 1 { - for _ in 2...iterations { - u = Data(HMAC.authenticationCode(for: u, using: key)) - for index in block.indices { block[index] ^= u[index] } - } - } - derived.append(block) - blockIndex += 1 - } - return derived.prefix(keyLength) - } - - private func decodeExport(_ data: Data) throws -> DBXExport { - let decoder = JSONDecoder() - if let export = try? decoder.decode(DBXExport.self, from: data) { return export } - if let connections = try? decoder.decode([DBXConnection].self, from: data) { - return DBXExport(connections: connections, layout: nil) - } - throw DatabaseDBXImportError.invalidFile - } - - private func databaseKind(_ value: String) -> DatabaseKind? { - switch value.lowercased() { - case "mysql": .mysql - case "mariadb": .mariadb - case "postgres", "postgresql": .postgresql - case "sqlite": .sqlite - case "sqlserver", "sql-server": .sqlserver - case "mongodb", "mongo": .mongodb - case "redis": .redis - case "nacos": .nacos - default: nil - } - } - - private func buildLayout(_ layout: DBXLayout?) -> DBXResolvedLayout { - guard let layout else { return .init(folders: [], connectionFolderIDs: [:]) } - let names = Dictionary(uniqueKeysWithValues: layout.groups.map { ($0.id, $0.name) }) - var folderIDs: [String: UUID] = [:] - var folders: [DatabaseDBXImportFolder] = [] - var connectionFolderIDs: [String: UUID] = [:] - - func visit(_ entries: [DBXOrderEntry], parentID: UUID?) { - for entry in entries { - if entry.type == "connection" { - if let parentID { connectionFolderIDs[entry.id] = parentID } - continue - } - guard entry.type == "group" else { continue } - let folderID = folderIDs[entry.id] ?? UUID() - folderIDs[entry.id] = folderID - if !folders.contains(where: { $0.id == folderID }) { - folders.append(.init(id: folderID, name: names[entry.id]?.nonEmpty ?? "Imported Group", parentID: parentID)) - } - for connectionID in entry.connectionIds ?? [] { connectionFolderIDs[connectionID] = folderID } - visit(entry.children ?? [], parentID: folderID) - } - } - visit(layout.order, parentID: nil) - return .init(folders: folders, connectionFolderIDs: connectionFolderIDs) - } -} - -private struct DBXEncryptedEnvelope: Decodable { - package let format: String - package let version: Int - package let salt: String - package let iv: String - package let data: String -} - -private struct DBXExport: Decodable { - package let connections: [DBXConnection] - package let layout: DBXLayout? -} - -private struct DBXConnection: Decodable { - package let id: String - package let name: String - package let dbType: String - package let host: String - package let port: Int - package let username: String - package let password: String - package let database: String? - package let color: String? - package let readOnly: Bool? - package let isProduction: Bool? - package let ssl: Bool? - package let caCertPath: String? - package let clientCertPath: String? - package let clientKeyPath: String? - package let connectionString: String? - package let urlParams: String? - package let transportLayers: [DBXTransportLayer]? - package let redisConnectionMode: String? - - private enum CodingKeys: String, CodingKey { - case id, name, host, port, username, password, database, color, ssl - case dbType = "db_type" - case readOnly = "read_only" - case isProduction = "is_production" - case caCertPath = "ca_cert_path" - case clientCertPath = "client_cert_path" - case clientKeyPath = "client_key_path" - case connectionString = "connection_string" - case urlParams = "url_params" - case transportLayers = "transport_layers" - case redisConnectionMode = "redis_connection_mode" - } -} - -private struct DBXTransportLayer: Decodable { - package let type: String - package let enabled: Bool? - package let host: String - package let port: Int - package let user: String? - package let password: String? - package let keyPath: String? - package let proxyType: String? - - private enum CodingKeys: String, CodingKey { - case type, enabled, host, port, user, password - case keyPath = "key_path" - case proxyType = "proxy_type" - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" - enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) - host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" - port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 0 - user = try container.decodeIfPresent(String.self, forKey: .user) - password = try container.decodeIfPresent(String.self, forKey: .password) - keyPath = try container.decodeIfPresent(String.self, forKey: .keyPath) - proxyType = try container.decodeIfPresent(String.self, forKey: .proxyType) - } -} - -private struct DBXLayout: Decodable { - package let groups: [DBXGroup] - package let order: [DBXOrderEntry] -} - -private struct DBXGroup: Decodable { - package let id: String - package let name: String -} - -private struct DBXOrderEntry: Decodable { - package let type: String - package let id: String - package let children: [DBXOrderEntry]? - package let connectionIds: [String]? -} - -private struct DBXResolvedLayout { - package let folders: [DatabaseDBXImportFolder] - package let connectionFolderIDs: [String: UUID] -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift b/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift deleted file mode 100644 index eadf7f97d..000000000 --- a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift +++ /dev/null @@ -1,878 +0,0 @@ -import Foundation - -package enum DatabaseKind: String, Codable, CaseIterable, Sendable { - case mysql - case mariadb - case postgresql - case sqlite - case sqlserver - case mongodb - case redis - case nacos - - package var isSQLDatabase: Bool { - switch self { - case .mysql, .mariadb, .postgresql, .sqlite, .sqlserver: true - case .mongodb, .redis, .nacos: false - } - } - - package var supportsDataGrid: Bool { isSQLDatabase || self == .mongodb } -} - -package struct DatabaseConnection: Codable, Equatable, Sendable { - package let kind: DatabaseKind - package var host = "" - package var port: UInt16 = 0 - package var username = "" - package var password = "" - package var database = "" - package var path = "" - package var ssl = false - package var caCertificatePath = "" - package var serverName = "" - package var sshHost = "" - package var sshPort: UInt16 = 0 - package var sshUsername = "" - package var sshKeyPath = "" - package var sshLocalPort: UInt16 = 0 - package var proxyURL = "" - package var readOnly = false - package var productionProtection = false - - private enum CodingKeys: String, CodingKey { - case kind, host, port, username, password, database, path, ssl - case caCertificatePath, serverName, sshHost, sshPort, sshUsername, sshKeyPath, sshLocalPort, proxyURL - case readOnly, productionProtection - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - kind = try container.decode(DatabaseKind.self, forKey: .kind) - host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" - port = try container.decodeIfPresent(UInt16.self, forKey: .port) ?? 0 - username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" - password = try container.decodeIfPresent(String.self, forKey: .password) ?? "" - database = try container.decodeIfPresent(String.self, forKey: .database) ?? "" - path = try container.decodeIfPresent(String.self, forKey: .path) ?? "" - ssl = try container.decodeIfPresent(Bool.self, forKey: .ssl) ?? false - caCertificatePath = try container.decodeIfPresent(String.self, forKey: .caCertificatePath) ?? "" - serverName = try container.decodeIfPresent(String.self, forKey: .serverName) ?? "" - sshHost = try container.decodeIfPresent(String.self, forKey: .sshHost) ?? "" - sshPort = try container.decodeIfPresent(UInt16.self, forKey: .sshPort) ?? 0 - sshUsername = try container.decodeIfPresent(String.self, forKey: .sshUsername) ?? "" - sshKeyPath = try container.decodeIfPresent(String.self, forKey: .sshKeyPath) ?? "" - sshLocalPort = try container.decodeIfPresent(UInt16.self, forKey: .sshLocalPort) ?? 0 - proxyURL = try container.decodeIfPresent(String.self, forKey: .proxyURL) ?? "" - readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false - productionProtection = try container.decodeIfPresent(Bool.self, forKey: .productionProtection) ?? false - } - - package init( - kind: DatabaseKind, - host: String = "", - port: UInt16 = 0, - username: String = "", - password: String = "", - database: String = "", - path: String = "", - ssl: Bool = false, - caCertificatePath: String = "", - serverName: String = "", - sshHost: String = "", - sshPort: UInt16 = 0, - sshUsername: String = "", - sshKeyPath: String = "", - sshLocalPort: UInt16 = 0, - proxyURL: String = "", - readOnly: Bool = false, - productionProtection: Bool = false - ) { - self.kind = kind - self.host = host - self.port = port - self.username = username - self.password = password - self.database = database - self.path = path - self.ssl = ssl - self.caCertificatePath = caCertificatePath - self.serverName = serverName - self.sshHost = sshHost - self.sshPort = sshPort - self.sshUsername = sshUsername - self.sshKeyPath = sshKeyPath - self.sshLocalPort = sshLocalPort - self.proxyURL = proxyURL - self.readOnly = readOnly - self.productionProtection = productionProtection - } -} - -package struct DatabaseCapabilities: Codable, Equatable, Sendable { - package let protocolVersion: Int - package let databaseTypes: [String] - package let features: [String] -} - -package struct DatabaseSQLFileExportResult: Codable, Equatable, Sendable { - package let path: String - package let byteCount: Int - package let sha256: String -} - -/// Redis and Nacos are deliberately modeled as specialised workspaces rather -/// than as SQL tables. Keeping their protocol types separate prevents callers -/// from accidentally issuing SQL-style operations to a non-SQL service. -package struct RedisKeySummary: Codable, Equatable, Identifiable, Sendable { - package let key: String - package let type: String - package let ttl: Int64 - package let size: Int64 - - package var id: String { key } -} - -package struct RedisScanResult: Codable, Equatable, Sendable { - package let keys: [RedisKeySummary] - package let nextCursor: String -} - -package struct RedisHashEntry: Codable, Equatable, Identifiable, Sendable { - package let field: String - package let value: String - - package var id: String { field } - package init(field: String, value: String) { self.field = field; self.value = value } -} - -package struct RedisKeyDetail: Codable, Equatable, Sendable { - package let key: String - package let type: String - package let ttl: Int64 - package let size: Int64 - package let stringValue: String? - package let hashEntries: [RedisHashEntry] -} - -package struct NacosConfigSummary: Codable, Equatable, Identifiable, Sendable { - package let dataId: String - package let group: String - package let namespace: String - package let type: String? - package let md5: String? - - package var id: String { "\(namespace)|\(group)|\(dataId)" } -} - -package struct NacosConfigList: Codable, Equatable, Sendable { - package let items: [NacosConfigSummary] - package let totalCount: Int -} - -package struct NacosConfigDetail: Codable, Equatable, Sendable { - package let dataId: String - package let group: String - package let namespace: String - package let content: String - package let type: String? - package let md5: String? -} - -package struct NacosServiceSummary: Codable, Equatable, Identifiable, Sendable { - package let name: String - package let group: String - package let clusterCount: Int - - package var id: String { "\(group)|\(name)" } -} - -package struct NacosServiceList: Codable, Equatable, Sendable { - package let items: [NacosServiceSummary] - package let totalCount: Int -} - -package struct NacosInstanceSummary: Codable, Equatable, Identifiable, Sendable { - package let ip: String - package let port: Int - package let healthy: Bool - package let enabled: Bool - package let ephemeral: Bool - package let clusterName: String? - - package var id: String { "\(ip):\(port):\(clusterName ?? "")" } -} - -package enum DatabaseValue: Codable, Equatable, Sendable { - case null - case bool(Bool) - case integer(Int64) - case number(Double) - case decimal(String) - case string(String) - case binary(Data) - case object([String: DatabaseValue]) - case array([DatabaseValue]) - - package init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { self = .null } - else if let value = try? container.decode(Bool.self) { self = .bool(value) } - else if let value = try? container.decode(Int64.self) { self = .integer(value) } - else if let value = try? container.decode(Double.self) { self = .number(value) } - else if let value = try? container.decode(String.self) { self = .string(value) } - else if let tagged = try? container.decode([String: String].self), tagged.count == 1, let value = tagged["decimal"] { - self = .decimal(value) - } else if let tagged = try? container.decode([String: String].self), tagged.count == 1, - let encoded = tagged["binary"], - let value = Data(base64Encoded: encoded) { - self = .binary(value) - } - else if let value = try? container.decode([String: DatabaseValue].self) { self = .object(value) } - else { self = .array(try container.decode([DatabaseValue].self)) } - } - - package func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: try container.encodeNil() - case let .bool(value): try container.encode(value) - case let .integer(value): try container.encode(value) - case let .number(value): try container.encode(value) - case let .decimal(value): try container.encode(["decimal": value]) - case let .string(value): try container.encode(value) - case let .binary(value): try container.encode(["binary": value.base64EncodedString()]) - case let .object(value): try container.encode(value) - case let .array(value): try container.encode(value) - } - } - - /// A stable value representation for grids, details panels, and metadata. - /// Keeping this on the protocol value prevents an empty string from being - /// rendered like a missing value in one of the database workspaces. - package var displayText: String { - switch self { - case .null: "NULL" - case let .bool(value): value ? "true" : "false" - case let .integer(value): String(value) - case let .number(value): String(value) - case let .decimal(value): value - case let .string(value): value.isEmpty ? "\"\"" : value - case let .binary(value): "Binary (\(value.count) bytes)" - case let .object(value): Self.jsonText(.object(value)) - case let .array(value): Self.jsonText(.array(value)) - } - } - - private static func jsonText(_ value: DatabaseValue) -> String { - guard let data = try? JSONEncoder().encode(value) else { return String(describing: value) } - return String(decoding: data, as: UTF8.self) - } -} - -package typealias DatabaseRow = [String: DatabaseValue] - -package struct DatabaseQueryResult: Codable, Equatable, Sendable { - package let rows: [DatabaseRow] - package let columns: [String]? - package let truncated: Bool - package var totalRows: Int64? - - package var resolvedColumns: [String] { - columns ?? rows.reduce(into: [String]()) { result, row in - for key in row.keys.sorted() where !result.contains(key) { result.append(key) } - } - } - - package init(rows: [DatabaseRow], columns: [String]? = nil, truncated: Bool, totalRows: Int64? = nil) { - self.rows = rows - self.columns = columns - self.truncated = truncated - self.totalRows = totalRows - } - - private enum CodingKeys: String, CodingKey { case rows, columns, truncated, totalRows } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - rows = try container.decode([DatabaseRow].self, forKey: .rows) - columns = try container.decodeIfPresent([String].self, forKey: .columns) - truncated = try container.decode(Bool.self, forKey: .truncated) - totalRows = try container.decodeIfPresent(Int64.self, forKey: .totalRows) - } -} - -package struct DatabaseExecuteResult: Codable, Equatable, Sendable { - package let rowsAffected: UInt64 -} - -package enum DatabaseMutationAction: String, Codable, Sendable { case insert, update, delete } - -package struct DatabaseMutation: Codable, Equatable, Sendable { - package let action: DatabaseMutationAction - package let table: String - package var values: DatabaseRow = [:] - package var key: DatabaseRow = [:] -} - -package enum DatabaseFilterOperator: String, Codable, CaseIterable, Sendable { case equals, notEquals, greaterThan, lessThan, contains, startsWith, isNull, isNotNull } -package enum DatabaseFilterJoin: String, Codable, CaseIterable, Sendable { case and, or } -package struct DatabaseFilter: Codable, Equatable, Sendable { - package let column: String - package let `operator`: DatabaseFilterOperator - package var value: DatabaseValue = .null - package var join: DatabaseFilterJoin = .and - package init(column: String, operator: DatabaseFilterOperator = .equals, value: DatabaseValue = .null, join: DatabaseFilterJoin = .and) { - self.column = column; self.operator = `operator`; self.value = value; self.join = join - } -} -package struct DatabaseSort: Codable, Equatable, Sendable { - package let column: String - package var descending = false - package init(column: String, descending: Bool = false) { self.column = column; self.descending = descending } -} -package struct DatabaseSQLExportOptions: Codable, Equatable, Sendable { - package var schema = "" - package var selectedTables: [String] = [] - package var includeStructure = true - package var includeData = true - // A SQL backup must be complete. Zero is the sidecar protocol's explicit - // unbounded sentinel; the sidecar streams rows directly to the output. - package var limit = 0 -} - -package enum DatabaseObjectKind: String, Codable, CaseIterable, Sendable { - case tables - case views - case routines - case triggers - case sequences -} - -package struct DatabaseSchemaChange: Codable, Equatable, Sendable { - package var operation: String - package var table = "" - package var name = "" - package var oldName = "" - package var dataType = "" - package var nullable = true - package var defaultValue = "" - package var indexName = "" - package var indexColumns: [String] = [] - package var constraintName = "" - package var referencedTable = "" - package var referencedColumns: [String] = [] - package var sql = "" - package init(operation: String, table: String = "", name: String = "", oldName: String = "", dataType: String = "", nullable: Bool = true, defaultValue: String = "", indexName: String = "", indexColumns: [String] = [], constraintName: String = "", referencedTable: String = "", referencedColumns: [String] = [], sql: String = "") { - self.operation = operation; self.table = table; self.name = name; self.oldName = oldName; self.dataType = dataType; self.nullable = nullable; self.defaultValue = defaultValue; self.indexName = indexName; self.indexColumns = indexColumns; self.constraintName = constraintName; self.referencedTable = referencedTable; self.referencedColumns = referencedColumns; self.sql = sql - } -} - -package struct DatabaseTransactionStatement: Codable, Equatable, Sendable { - package let sql: String - package var values: [DatabaseValue] = [] -} - -package struct DatabaseDiagnosticsRequest: Codable, Equatable, Sendable { - package var kind = "tableSize" - package var schema = "" - package var table = "" - package init(kind: String = "tableSize", schema: String = "", table: String = "") { self.kind = kind; self.schema = schema; self.table = table } -} - -package protocol DatabaseOperations: Sendable { - func capabilities() throws -> DatabaseCapabilities - func testConnection(_ connection: DatabaseConnection) throws - func listDatabases(connection: DatabaseConnection) throws -> [String] - func listTables(connection: DatabaseConnection, schema: String) throws -> [DatabaseRow] - func describeTable(connection: DatabaseConnection, schema: String, table: String) throws -> [DatabaseRow] - func listIndexes(connection: DatabaseConnection, schema: String, table: String) throws -> [DatabaseRow] - func listForeignKeys(connection: DatabaseConnection, schema: String, table: String) throws -> [DatabaseRow] - func listObjects(connection: DatabaseConnection, schema: String, kind: DatabaseObjectKind) throws -> [DatabaseRow] - func pageTable(connection: DatabaseConnection, schema: String, table: String, limit: Int, offset: Int, filters: [DatabaseFilter], sort: [DatabaseSort]) throws -> DatabaseQueryResult - func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> DatabaseQueryResult - func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - /// Whether `execute` would reject `sql` under production protection until confirmed; does not connect. - func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool - func applyChanges(connection: DatabaseConnection, schema: String, mutations: [DatabaseMutation], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func applySchemaChange(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func explain(connection: DatabaseConnection, sql: String, format: String) throws -> DatabaseQueryResult - func diagnostics(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) throws -> DatabaseQueryResult - func transaction(connection: DatabaseConnection, statements: [DatabaseTransactionStatement], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func exportCSV(connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> Data - func exportJSON(connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> Data - func importCSV(connection: DatabaseConnection, schema: String, table: String, data: Data) throws -> DatabaseExecuteResult - func importJSON(connection: DatabaseConnection, schema: String, table: String, data: Data) throws -> DatabaseExecuteResult - func exportSQL(connection: DatabaseConnection, options: DatabaseSQLExportOptions) throws -> Data - func exportSQLToFile(connection: DatabaseConnection, options: DatabaseSQLExportOptions, outputURL: URL) throws -> DatabaseSQLFileExportResult - func importSQL(connection: DatabaseConnection, data: Data, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func importSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func restoreSQL(connection: DatabaseConnection, data: Data, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - func restoreSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult - - func redisScan(connection: DatabaseConnection, cursor: String, pattern: String, count: Int, includeSize: Bool) throws -> RedisScanResult - func redisGetKey(connection: DatabaseConnection, key: String) throws -> RedisKeyDetail - func redisSetString(connection: DatabaseConnection, key: String, value: String, ttl: Int64?, confirmed: Bool, allowWrite: Bool) throws - func redisReplaceHash(connection: DatabaseConnection, key: String, entries: [RedisHashEntry], confirmed: Bool, allowWrite: Bool) throws - func redisDeleteKey(connection: DatabaseConnection, key: String, confirmed: Bool, allowWrite: Bool) throws - func redisRenameKey(connection: DatabaseConnection, key: String, newKey: String, confirmed: Bool, allowWrite: Bool) throws - func redisSetTTL(connection: DatabaseConnection, key: String, ttl: Int64, confirmed: Bool, allowWrite: Bool) throws - func redisFlushDatabase(connection: DatabaseConnection, confirmed: Bool, allowWrite: Bool) throws - - func nacosListConfigs(connection: DatabaseConnection, dataId: String, group: String, page: Int, pageSize: Int) throws -> NacosConfigList - func nacosGetConfig(connection: DatabaseConnection, dataId: String, group: String) throws -> NacosConfigDetail - func nacosPublishConfig(connection: DatabaseConnection, dataId: String, group: String, content: String, type: String?, confirmed: Bool, allowWrite: Bool) throws - func nacosDeleteConfig(connection: DatabaseConnection, dataId: String, group: String, confirmed: Bool, allowWrite: Bool) throws - func nacosListServices(connection: DatabaseConnection, serviceName: String, group: String, page: Int, pageSize: Int) throws -> NacosServiceList - func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String) throws -> [NacosInstanceSummary] -} - -package extension DatabaseOperations { - func listDatabases(connection: DatabaseConnection) throws -> [String] { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Database selection is not available for this database service.") } - func redisScan(connection: DatabaseConnection, cursor: String, pattern: String, count: Int) throws -> RedisScanResult { - try redisScan(connection: connection, cursor: cursor, pattern: pattern, count: count, includeSize: true) - } - func redisScan(connection: DatabaseConnection, cursor: String, pattern: String, count: Int, includeSize: Bool) throws -> RedisScanResult { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisGetKey(connection: DatabaseConnection, key: String) throws -> RedisKeyDetail { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisSetString(connection: DatabaseConnection, key: String, value: String, ttl: Int64?, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisReplaceHash(connection: DatabaseConnection, key: String, entries: [RedisHashEntry], confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisDeleteKey(connection: DatabaseConnection, key: String, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisRenameKey(connection: DatabaseConnection, key: String, newKey: String, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisSetTTL(connection: DatabaseConnection, key: String, ttl: Int64, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func redisFlushDatabase(connection: DatabaseConnection, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Redis is not available in this database service.") } - func nacosListConfigs(connection: DatabaseConnection, dataId: String, group: String, page: Int, pageSize: Int) throws -> NacosConfigList { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } - func nacosGetConfig(connection: DatabaseConnection, dataId: String, group: String) throws -> NacosConfigDetail { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } - func nacosPublishConfig(connection: DatabaseConnection, dataId: String, group: String, content: String, type: String?, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } - func nacosDeleteConfig(connection: DatabaseConnection, dataId: String, group: String, confirmed: Bool, allowWrite: Bool) throws { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } - func nacosListServices(connection: DatabaseConnection, serviceName: String, group: String, page: Int, pageSize: Int) throws -> NacosServiceList { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } - func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String) throws -> [NacosInstanceSummary] { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } -} - -package enum DatabaseSidecarError: LocalizedError, Equatable { - case executableNotFound - case processFailed(exitCode: Int32, output: String) - case invalidResponse(String) - case requestFailed(code: String, message: String) - - package var errorDescription: String? { - switch self { - case .executableNotFound: return "The Lithe database helper is not installed." - case let .processFailed(exitCode, output): return "The database helper exited with code \(exitCode): \(Self.bounded(output))" - case let .invalidResponse(message): return "The database helper returned invalid JSON: \(Self.bounded(message))" - case let .requestFailed(code, message): return "Database request failed (\(code)): \(Self.bounded(message))" - } - } - - private static func bounded(_ value: String) -> String { - let normalized = value - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - return normalized.count > 500 ? "\(normalized.prefix(497))..." : normalized - } -} - -/// Executes the independently packaged database core only on demand. Connection -/// secrets are sent over stdin and are never included in arguments or logs. -package final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { - private let processRunner: any DatabaseProcessRunning - private let executableURL: URL? - private let environment: [String: String]? - - package init(processRunner: any DatabaseProcessRunning, executableURL: URL?, environment: [String: String]? = nil) { - self.processRunner = processRunner - self.executableURL = executableURL - self.environment = environment - } - - package func capabilities() throws -> DatabaseCapabilities { - try request(method: "capabilities", params: EmptyParams()) - } - - package func testConnection(_ connection: DatabaseConnection) throws { - let _: ConnectedResult = try request(method: "testConnection", params: ConnectionParams(connection: connection)) - } - - package func listDatabases(connection: DatabaseConnection) throws -> [String] { - try request(method: "listDatabases", params: ConnectionParams(connection: connection)) - } - - package func listTables(connection: DatabaseConnection, schema: String = "") throws -> [DatabaseRow] { - let result: DatabaseRowsResult = try request(method: "listTables", params: TableParams(connection: connection, schema: schema)) - return result.rows - } - - package func describeTable(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { - let result: DatabaseRowsResult = try request(method: "describeTable", params: TableParams(connection: connection, schema: schema, table: table)) - return result.rows - } - - package func listIndexes(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { - let result: DatabaseRowsResult = try request(method: "listIndexes", params: TableParams(connection: connection, schema: schema, table: table)) - return result.rows - } - - package func listForeignKeys(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { - let result: DatabaseRowsResult = try request(method: "listForeignKeys", params: TableParams(connection: connection, schema: schema, table: table)) - return result.rows - } - - package func listObjects(connection: DatabaseConnection, schema: String = "", kind: DatabaseObjectKind) throws -> [DatabaseRow] { - let result: DatabaseRowsResult = try request(method: "listObjects", params: ObjectParams(connection: connection, schema: schema, objectKind: kind.rawValue)) - return result.rows - } - - package func pageTable(connection: DatabaseConnection, schema: String = "", table: String, limit: Int = 200, offset: Int = 0, filters: [DatabaseFilter] = [], sort: [DatabaseSort] = []) throws -> DatabaseQueryResult { - try request(method: "pageTable", params: TableParams(connection: connection, schema: schema, table: table, limit: limit, offset: offset, filters: filters, sort: sort)) - } - - package func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 200) throws -> DatabaseQueryResult { - try request(method: "query", params: QueryParams(connection: connection, sql: sql, values: values, limit: limit)) - } - - package func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "execute", params: QueryParams(connection: connection, sql: sql, values: values, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool { - let result: WriteProtectionResult = try request(method: "checkWriteProtection", params: QueryParams(connection: connection, sql: sql)) - return result.requiresConfirmation - } - - package func applyChanges(connection: DatabaseConnection, schema: String = "", mutations: [DatabaseMutation], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "applyChanges", params: MutationParams(connection: connection, schema: schema, mutations: mutations, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func applySchemaChange(connection: DatabaseConnection, schema: String = "", change: DatabaseSchemaChange, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "schemaChange", params: SchemaChangeParams(connection: connection, schema: schema, change: change, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func explain(connection: DatabaseConnection, sql: String, format: String = "json") throws -> DatabaseQueryResult { - let result: ExplainResult = try request(method: "explain", params: ExplainParams(connection: connection, sql: sql, explainFormat: format)) - return DatabaseQueryResult(rows: result.rows, truncated: result.truncated, totalRows: nil) - } - - package func diagnostics(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) throws -> DatabaseQueryResult { - let result: DiagnosticsResult = try self.request(method: "diagnostics", params: DiagnosticsParams(connection: connection, request: request)) - return DatabaseQueryResult(rows: result.rows, truncated: result.truncated, totalRows: nil) - } - - package func transaction(connection: DatabaseConnection, statements: [DatabaseTransactionStatement], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "transaction", params: TransactionParams(connection: connection, statements: statements, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func exportCSV(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { - try export(method: "exportCsv", connection: connection, sql: sql, values: values, limit: limit) - } - - package func exportJSON(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { - try export(method: "exportJson", connection: connection, sql: sql, values: values, limit: limit) - } - - package func importCSV(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { - try request(method: "importCsv", params: ImportParams(connection: connection, schema: schema, table: table, data: data.base64EncodedString())) - } - - package func importJSON(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { - try request(method: "importJson", params: ImportParams(connection: connection, schema: schema, table: table, data: data.base64EncodedString())) - } - - package func exportSQL(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions()) throws -> Data { - let result: ExportResult = try request(method: "exportSql", params: SQLExportParams( - connection: connection, schema: options.schema, selectedTables: options.selectedTables, - includeStructure: options.includeStructure, includeData: options.includeData, limit: options.limit - ), timeoutMilliseconds: 120_000) - guard result.encoding == "base64", let data = Data(base64Encoded: result.data) else { - throw DatabaseSidecarError.invalidResponse("Invalid SQL backup payload") - } - return data - } - - package func exportSQLToFile(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions(), outputURL: URL) throws -> DatabaseSQLFileExportResult { - try request(method: "exportSqlToFile", params: SQLFileExportParams( - connection: connection, schema: options.schema, selectedTables: options.selectedTables, - includeStructure: options.includeStructure, includeData: options.includeData, - limit: options.limit, outputPath: outputURL.path - ), timeoutMilliseconds: 120_000) - } - - package func importSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "importSql", params: SQLImportParams(connection: connection, data: data.base64EncodedString(), confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) - } - - package func importSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "importSqlFile", params: SQLFileImportParams(connection: connection, outputPath: fileURL.path, confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) - } - - package func restoreSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "restoreSql", params: SQLImportParams(connection: connection, data: data.base64EncodedString(), confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) - } - - package func restoreSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { - try request(method: "restoreSqlFile", params: SQLFileImportParams(connection: connection, outputPath: fileURL.path, confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) - } - - package func redisScan(connection: DatabaseConnection, cursor: String = "0", pattern: String = "*", count: Int = 100, includeSize: Bool = true) throws -> RedisScanResult { - try request(method: "redisScan", params: RedisScanParams(connection: connection, cursor: cursor, pattern: pattern, count: count, includeSize: includeSize)) - } - - package func redisGetKey(connection: DatabaseConnection, key: String) throws -> RedisKeyDetail { - try request(method: "redisGetKey", params: RedisKeyParams(connection: connection, key: key)) - } - - package func redisSetString(connection: DatabaseConnection, key: String, value: String, ttl: Int64? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisSetString", params: RedisWriteParams(connection: connection, key: key, value: value, ttl: ttl, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func redisReplaceHash(connection: DatabaseConnection, key: String, entries: [RedisHashEntry], confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisReplaceHash", params: RedisWriteParams(connection: connection, key: key, entries: entries, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func redisDeleteKey(connection: DatabaseConnection, key: String, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisDeleteKey", params: RedisWriteParams(connection: connection, key: key, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func redisRenameKey(connection: DatabaseConnection, key: String, newKey: String, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisRenameKey", params: RedisWriteParams(connection: connection, key: key, newKey: newKey, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func redisSetTTL(connection: DatabaseConnection, key: String, ttl: Int64, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisSetTTL", params: RedisWriteParams(connection: connection, key: key, ttl: ttl, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func redisFlushDatabase(connection: DatabaseConnection, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "redisFlushDatabase", params: RedisWriteParams(connection: connection, key: "", confirmed: confirmed, allowWrite: allowWrite)) - } - - package func nacosListConfigs(connection: DatabaseConnection, dataId: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosConfigList { - try request(method: "nacosListConfigs", params: NacosParams(connection: connection, dataId: dataId, group: group, page: page, pageSize: pageSize)) - } - - package func nacosGetConfig(connection: DatabaseConnection, dataId: String, group: String) throws -> NacosConfigDetail { - try request(method: "nacosGetConfig", params: NacosParams(connection: connection, dataId: dataId, group: group)) - } - - package func nacosPublishConfig(connection: DatabaseConnection, dataId: String, group: String, content: String, type: String? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "nacosPublishConfig", params: NacosParams(connection: connection, dataId: dataId, group: group, content: content, type: type ?? "", confirmed: confirmed, allowWrite: allowWrite)) - } - - package func nacosDeleteConfig(connection: DatabaseConnection, dataId: String, group: String, confirmed: Bool = false, allowWrite: Bool = false) throws { - let _: EmptyResult = try request(method: "nacosDeleteConfig", params: NacosParams(connection: connection, dataId: dataId, group: group, confirmed: confirmed, allowWrite: allowWrite)) - } - - package func nacosListServices(connection: DatabaseConnection, serviceName: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosServiceList { - try request(method: "nacosListServices", params: NacosParams(connection: connection, group: group, serviceName: serviceName, page: page, pageSize: pageSize)) - } - - package func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String = "") throws -> [NacosInstanceSummary] { - try request(method: "nacosListInstances", params: NacosParams(connection: connection, group: group, serviceName: serviceName)) - } - - private func export(method: String, connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> Data { - let result: ExportResult = try request(method: method, params: QueryParams(connection: connection, sql: sql, values: values, limit: limit)) - guard result.encoding == "base64", let data = Data(base64Encoded: result.data) else { - throw DatabaseSidecarError.invalidResponse("Invalid CSV payload") - } - return data - } - - private func request(method: String, params: Params, timeoutMilliseconds: Int = 30_000) throws -> Result { - guard let executableURL else { throw DatabaseSidecarError.executableNotFound } - let requestID = UUID().uuidString - let body = RequestEnvelope(id: requestID, method: method, params: params) - let input: Data - do { input = try JSONEncoder().encode(body) } - catch { throw DatabaseSidecarError.invalidResponse(error.localizedDescription) } - - let process = processRunner.runDatabaseProcess(DatabaseProcessRequest( - executablePath: executableURL.path, - environment: environment, - standardInput: input, - timeoutMilliseconds: timeoutMilliseconds - )) - let data = Data(process.output.trimmingCharacters(in: .whitespacesAndNewlines).utf8) - let envelope: ResponseEnvelope - do { envelope = try JSONDecoder().decode(ResponseEnvelope.self, from: data) } - catch { - if !process.succeeded { throw DatabaseSidecarError.processFailed(exitCode: process.exitCode, output: process.output) } - throw DatabaseSidecarError.invalidResponse(error.localizedDescription) - } - guard envelope.id == requestID else { throw DatabaseSidecarError.invalidResponse("Response ID did not match request") } - if let error = envelope.error { throw DatabaseSidecarError.requestFailed(code: error.code, message: error.message) } - guard envelope.ok, let result = envelope.result else { throw DatabaseSidecarError.invalidResponse("Missing result") } - return result - } - -} - -private struct EmptyParams: Codable {} -private struct ConnectionParams: Codable { let connection: DatabaseConnection } -private struct ConnectedResult: Codable { let connected: Bool } -private struct EmptyResult: Codable {} -private struct WriteProtectionResult: Codable { let requiresConfirmation: Bool } -private struct DatabaseRowsResult: Decodable { - package let rows: [DatabaseRow] - - package init(from decoder: Decoder) throws { - if let rows = try? decoder.singleValueContainer().decode([DatabaseRow].self) { - self.rows = rows - return - } - let container = try decoder.container(keyedBy: CodingKeys.self) - rows = try container.decode([DatabaseRow].self, forKey: .rows) - } - - private enum CodingKeys: String, CodingKey { case rows } -} -private struct ExportResult: Codable { let encoding: String; let data: String } -private struct ExplainResult: Codable { let format: String; let rows: [DatabaseRow]; let truncated: Bool } -private struct DiagnosticsResult: Codable { let rows: [DatabaseRow]; let truncated: Bool } -private struct TableParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var table = "" - package var limit = 200 - package var offset = 0 - package var filters: [DatabaseFilter] = [] - package var sort: [DatabaseSort] = [] -} -private struct ObjectParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var objectKind = "" -} -private struct QueryParams: Codable { - package let connection: DatabaseConnection - package let sql: String - package var values: [DatabaseValue] = [] - package var limit = 200 - package var confirmed = false - package var allowWrite = false -} -private struct MutationParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package let mutations: [DatabaseMutation] - package var confirmed = false - package var allowWrite = false -} -private struct SchemaChangeParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var operation: String - package var table = "" - package var name = "" - package var oldName = "" - package var dataType = "" - package var nullable = true - package var defaultValue = "" - package var indexName = "" - package var indexColumns: [String] = [] - package var constraintName = "" - package var referencedTable = "" - package var referencedColumns: [String] = [] - package var sql = "" - package var confirmed = false - package var allowWrite = false - - package init(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) { - self.connection = connection - self.schema = schema - operation = change.operation - table = change.table - name = change.name - oldName = change.oldName - dataType = change.dataType - nullable = change.nullable - defaultValue = change.defaultValue - indexName = change.indexName - indexColumns = change.indexColumns - constraintName = change.constraintName - referencedTable = change.referencedTable - referencedColumns = change.referencedColumns - sql = change.sql - self.confirmed = confirmed - self.allowWrite = allowWrite - } -} -private struct ExplainParams: Codable { - package let connection: DatabaseConnection - package let sql: String - package var explainFormat = "json" -} -private struct DiagnosticsParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var table = "" - package var diagnosticKind = "tableSize" - - package init(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) { - self.connection = connection - schema = request.schema - table = request.table - diagnosticKind = request.kind - } -} -private struct TransactionParams: Codable { - package let connection: DatabaseConnection - package let statements: [DatabaseTransactionStatement] - package var confirmed = false - package var allowWrite = false -} -private struct ImportParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package let table: String - package let data: String -} -private struct SQLExportParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var selectedTables: [String] = [] - package var includeStructure = true - package var includeData = true - package var limit = 0 -} -private struct SQLFileExportParams: Codable { - package let connection: DatabaseConnection - package var schema = "" - package var selectedTables: [String] = [] - package var includeStructure = true - package var includeData = true - package var limit = 0 - package let outputPath: String -} -private struct SQLImportParams: Codable { let connection: DatabaseConnection; let data: String; var confirmed = false; var allowWrite = false } -private struct SQLFileImportParams: Codable { let connection: DatabaseConnection; let outputPath: String; var confirmed = false; var allowWrite = false } -private struct RedisScanParams: Codable { - package let connection: DatabaseConnection - package var cursor = "0" - package var pattern = "*" - package var count = 100 - package var includeSize = true -} -private struct RedisKeyParams: Codable { let connection: DatabaseConnection; let key: String } -private struct RedisWriteParams: Codable { - package let connection: DatabaseConnection - package let key: String - package var newKey = "" - package var value = "" - package var entries: [RedisHashEntry] = [] - package var ttl: Int64? - package var confirmed = false - package var allowWrite = false -} -private struct NacosParams: Codable { - package let connection: DatabaseConnection - package var dataId = "" - package var group = "" - package var content = "" - package var type = "" - package var serviceName = "" - package var page = 1 - package var pageSize = 100 - package var confirmed = false - package var allowWrite = false -} -private struct RequestEnvelope: Encodable { let id: String; let method: String; let params: Params } -private struct ResponseEnvelope: Decodable { let id: String; let ok: Bool; let result: Result?; let error: ResponseError? } -private struct ResponseError: Decodable { let code: String; let message: String } diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index b720ba3c9..f044f23c5 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -23,16 +23,6 @@ public enum BuiltInModuleCatalog { sleepPolicy: .whenIdle(afterSeconds: 5 * 60), providedCapabilities: [.aiCommitMessage, .aiPullRequestDescription] ), - ModuleManifest( - id: .database, - displayName: "Database", - scope: .workspace, - defaultState: .enabled, - activationPolicy: .onDemand, - sleepPolicy: .whenIdle(afterSeconds: 10 * 60), - dependencies: [.module(.workspace)], - providedCapabilities: [.databaseWorkspace] - ), ModuleManifest( id: .debug, displayName: "Debug", @@ -115,10 +105,6 @@ public enum BuiltInModuleCatalog { ModuleContribution(id: "ai.pull-request-description", kind: .command, title: "Generate Pull Request Description", icon: "wand.and.stars"), ModuleContribution(id: "ai.settings", kind: .settings, title: "AI Assistance", icon: "wand.and.stars") ], - .database: [ - ModuleContribution(id: "database.workspace", kind: .toolWindow, title: "Database", icon: "cylinder", placement: .rightSidebar, order: 150, actionID: "database.toggle", rendererID: "database.workspace"), - ModuleContribution(id: "database.services", kind: .toolWindow, title: "Services", icon: "server.rack", order: 450, actionID: "database.services.toggle", rendererID: "database.services") - ], .debug: [ ModuleContribution(id: "debug.session", kind: .toolWindow, title: "Debug", icon: "ladybug", order: 700, actionID: "debug.toggle", rendererID: "debug.session") ], @@ -196,7 +182,6 @@ public enum BuiltInPluginCatalog { switch id { case .aiAssistance: "LitheAIAssistanceModule" case .agentConversation: "LitheAgentConversationModule" - case .database: "LitheDatabaseModule" case .debug: "LitheDebugModule" case .execution: "LitheExecutionModule" case .git: "LitheGitModule" diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index e5338d15f..fd4325654 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -293,7 +293,6 @@ public extension ModuleID { static let execution = ModuleID("dev.lithe.execution") static let debug = ModuleID("dev.lithe.debug") static let terminal = ModuleID("dev.lithe.terminal") - static let database = ModuleID("dev.lithe.database") static let aiAssistance = ModuleID("dev.lithe.ai-assistance") static let agentConversation = ModuleID("dev.lithe.agent-conversation") @@ -331,7 +330,6 @@ public extension ModuleCapabilityID { static let executionWorkspace = ModuleCapabilityID("dev.lithe.capability.execution-workspace") static let debugWorkspace = ModuleCapabilityID("dev.lithe.capability.debug-workspace") static let terminalWorkspace = ModuleCapabilityID("dev.lithe.capability.terminal-workspace") - static let databaseWorkspace = ModuleCapabilityID("dev.lithe.capability.database-workspace") static let aiCommitMessage = ModuleCapabilityID("dev.lithe.capability.ai-commit-message") static let aiPullRequestDescription = ModuleCapabilityID("dev.lithe.capability.ai-pull-request-description") static let agentConversation = ModuleCapabilityID("dev.lithe.capability.agent-conversation") diff --git a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift index 5c4790772..fa01bd00c 100644 --- a/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift +++ b/macos/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift @@ -11,18 +11,18 @@ struct ModuleRuntimeTests { let runtime = ModuleRuntime() try runtime.register( testFactory( - id: .database, + id: .search, defaultState: .disabled, recorder: recorder ) ) - await #expect(throws: ModuleRuntimeError.moduleDisabled(.database)) { - _ = try await runtime.activate(.database) + await #expect(throws: ModuleRuntimeError.moduleDisabled(.search)) { + _ = try await runtime.activate(.search) } #expect(recorder.factoryCalls == []) - #expect(try runtime.snapshot(for: .database).state == .disabled) - #expect(try !runtime.snapshot(for: .database).isInstantiated) + #expect(try runtime.snapshot(for: .search).state == .disabled) + #expect(try !runtime.snapshot(for: .search).isInstantiated) } @Test @@ -222,31 +222,31 @@ struct ModuleRuntimeTests { func sleepStopsResourcesReleasesInstanceAndWakeReconstructsIt() async throws { let recorder = ModuleTestRecorder() let runtime = ModuleRuntime() - try runtime.register(testFactory(id: .database, recorder: recorder)) + try runtime.register(testFactory(id: .search, recorder: recorder)) let firstReference = WeakReference() - var first: TestModule? = try #require(try await runtime.activate(.database) as? TestModule) + var first: TestModule? = try #require(try await runtime.activate(.search) as? TestModule) firstReference.value = first #expect(first?.resource?.isModuleResourceActive == true) first = nil - try await runtime.sleep(.database) + try await runtime.sleep(.search) - #expect(recorder.resourceStops == [.database]) - #expect(try runtime.snapshot(for: .database).state == .sleeping) - #expect(try !runtime.snapshot(for: .database).isInstantiated) + #expect(recorder.resourceStops == [.search]) + #expect(try runtime.snapshot(for: .search).state == .sleeping) + #expect(try !runtime.snapshot(for: .search).isInstantiated) #expect(firstReference.value == nil) - let second = try #require(try await runtime.activate(.database) as? TestModule) - #expect(recorder.factoryCalls == [.database, .database]) + let second = try #require(try await runtime.activate(.search) as? TestModule) + #expect(recorder.factoryCalls == [.search, .search]) #expect(second !== firstReference.value) - #expect(try runtime.snapshot(for: .database).state == .active) + #expect(try runtime.snapshot(for: .search).state == .active) } @Test func activeResourceCannotUnregisterBeforeItStops() async { let recorder = ModuleTestRecorder() - let scope = ModuleResourceScope(moduleID: .database) - let resource = TestResource(moduleID: .database, recorder: recorder) + let scope = ModuleResourceScope(moduleID: .search) + let resource = TestResource(moduleID: .search, recorder: recorder) let resourceID = scope.register(resource) scope.unregisterResource(id: resourceID) @@ -264,13 +264,13 @@ struct ModuleRuntimeTests { let recorder = ModuleTestRecorder() let runtime = ModuleRuntime() try runtime.register(testFactory(id: .terminal, recorder: recorder)) - try runtime.register(testFactory(id: .database, recorder: recorder)) + try runtime.register(testFactory(id: .search, recorder: recorder)) _ = try await runtime.activate(.terminal) - _ = try await runtime.activate(.database) + _ = try await runtime.activate(.search) await runtime.shutdownAll() - #expect(Set(recorder.shutdowns) == Set([.terminal, .database])) + #expect(Set(recorder.shutdowns) == Set([.terminal, .search])) #expect(runtime.snapshots().allSatisfy { !$0.isInstantiated }) #expect(runtime.snapshots().allSatisfy { $0.activity.activeResourceCount == 0 }) } @@ -324,7 +324,7 @@ struct ModuleRuntimeTests { .map(\.manifest) for manifest in manifests { let registeredManifest: ModuleManifest - if manifest.id == .database { + if manifest.id == .search { registeredManifest = ModuleManifest( id: manifest.id, displayName: manifest.displayName, @@ -348,8 +348,8 @@ struct ModuleRuntimeTests { } #expect(throws: PluginCatalogError.moduleFactoryMismatch( - plugin: BuiltInPluginCatalog.manifest(forModule: .database)!.id, - module: .database + plugin: BuiltInPluginCatalog.manifest(forModule: .search)!.id, + module: .search )) { try registry.validate() } diff --git a/macos/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift b/macos/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift deleted file mode 100644 index 1ed186cf7..000000000 --- a/macos/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation -import LitheApplicationKernel -@testable import LitheDatabaseModule -import LitheModuleAPI -import Testing - -@MainActor -struct DatabaseModuleTests { - @Test - func disabledDatabaseDoesNotConstructFactoryOrPorts() async throws { - let recorder = Recorder() - let runtime = ModuleRuntime() - try runtime.register(workspaceFactory()) - try runtime.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { - recorder.factoryCalls += 1 - return makeModule(recorder: recorder) - }, enabled: false) - - await #expect(throws: ModuleRuntimeError.moduleDisabled(.database)) { - _ = try await runtime.activateCapability(.databaseWorkspace) - } - #expect(recorder.factoryCalls == 0) - #expect(recorder.portGraphCalls == 0) - #expect(try !runtime.snapshot(for: .database).isInstantiated) - } - - @Test - func sleepReleasesTimerFeatureAndWakeReconstructsGraph() async throws { - let recorder = Recorder() - let runtime = ModuleRuntime() - try runtime.register(workspaceFactory()) - try runtime.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { - recorder.factoryCalls += 1 - return makeModule(recorder: recorder) - }) - try await runtime.setEnabled(true, for: .database) - - var first: DatabaseFeatureModel? = try #require( - (try await runtime.activateCapability(.databaseWorkspace) as? DatabaseModuleCapability)?.feature - ) - weak var released = first - first = nil - try await runtime.sleep(.database) - - #expect(released == nil) - #expect(runtime.capability(.databaseWorkspace) == nil) - #expect(try runtime.snapshot(for: .database).activity.activeResourceCount == 0) - - let second = try #require( - (try await runtime.activateCapability(.databaseWorkspace) as? DatabaseModuleCapability)?.feature - ) - #expect(second !== released) - #expect(recorder.factoryCalls == 2) - #expect(recorder.portGraphCalls == 2) - } - - @Test - func releaseCancelsScheduledBackupWithoutAdvancingSchedule() async throws { - let preferences = TestPreferences() - let secrets = TestSecrets() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Scheduled", kind: .sqlite, path: "/tmp/test.sqlite") - let dueAt = Date(timeIntervalSince1970: 1) - try store.save([profile]) - try store.saveBackupSchedules([ - DatabaseBackupSchedule(profileID: profile.id, nextRunAt: dueAt) - ]) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: TestProcessRunner(), executableURL: nil), - connectionStore: store - ) - - feature.runScheduledBackups(now: Date(timeIntervalSince1970: 2)) - #expect(feature.hasActiveModuleWork) - - feature.prepareForModuleRelease() - #expect(!feature.hasActiveModuleWork) - await Task.yield() - #expect(feature.backupSchedules.first?.nextRunAt == dueAt) - } - - private func makeModule(recorder: Recorder) -> DatabaseModule { - recorder.portGraphCalls += 1 - return DatabaseModule( - processRunner: TestProcessRunner(), executableURL: nil, - preferenceStore: TestPreferences(), secureStore: TestSecrets(), - recoveryStore: UnavailableDatabaseRecoveryStore(), - fileStorage: UnavailableDatabaseFileStorage() - ) - } - - private func workspaceFactory() -> ModuleFactory { - ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { - EmptyWorkspaceModule() - } - } -} - -@MainActor private final class Recorder { var factoryCalls = 0; var portGraphCalls = 0 } -@MainActor private final class EmptyWorkspaceModule: LitheModule { - let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) - func activate(context: ModuleContext) async throws {} - func prepareForSleep() async throws {} - func sleep() async {} - func shutdown() async {} - func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } -} -private struct TestProcessRunner: DatabaseProcessRunning { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { DatabaseProcessResult(output: "", exitCode: 0) } -} -private final class TestPreferences: DatabasePreferenceStore, @unchecked Sendable { - private var values: [String: Data] = [:] - func data(forKey key: String) -> Data? { values[key] } - func set(_ value: Any?, forKey key: String) { values[key] = value as? Data } -} -private struct TestSecrets: DatabaseSecureStore { - func read(key: String) -> String? { nil } - func write(_ value: String, key: String) throws {} - func delete(key: String) throws {} -} diff --git a/macos/Tests/LitheTests/DatabaseGridAppearanceTests.swift b/macos/Tests/LitheTests/DatabaseGridAppearanceTests.swift deleted file mode 100644 index 209e56a6b..000000000 --- a/macos/Tests/LitheTests/DatabaseGridAppearanceTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import AppKit -import SwiftUI -import Testing -@testable import Lithe - -@MainActor -struct DatabaseGridAppearanceTests { - @Test func databaseCanvasTracksTheSharedWorkbenchPalette() throws { - let previousTheme = AppThemeRuntime.shared.activeTheme - defer { AppThemeRuntime.shared.activate(previousTheme) } - // Structural database backgrounds must not introduce their own theme/appearance override. - for theme in AppColorTheme.allCases { - AppThemeRuntime.shared.activate(theme) - for name in [NSAppearance.Name.aqua, .darkAqua] { - let appearance = try #require(NSAppearance(named: name)) - var canvas: NSColor? - var editor: NSColor? - var sidebar: NSColor? - appearance.performAsCurrentDrawingAppearance { - canvas = NSColor(LitheTheme.DatabaseGrid.background).usingColorSpace(.deviceRGB) - editor = NSColor(LitheTheme.editor).usingColorSpace(.deviceRGB) - sidebar = NSColor(LitheTheme.sidebar).usingColorSpace(.deviceRGB) - } - #expect(try #require(canvas) == #require(editor)) - #expect(try #require(canvas) == #require(sidebar)) - } - } - } - - @Test func duplicateQueryColumnNamesKeepFiniteWidths() { - let widths = LitheTheme.DatabaseGrid.columnWidths(["x", "x", "name"], availableWidth: 600, rowHeaderWidth: 24) { _ in ["2"] } - #expect(widths.count == 2) - #expect(widths.values.allSatisfy { $0.isFinite && $0 >= 40 }) - #expect(abs((widths["x"] ?? 0) * 2 + (widths["name"] ?? 0) - 576) < 1) - } - - @Test func databaseMenuInitiallySelectsOnlyTheExplicitCurrentDatabase() throws { - var invoked = "" - let other = LitheContextMenuItem.action("other") { invoked = "other" } - let current = LitheContextMenuItem.action("current", initiallySelected: true) { invoked = "current" } - let presenter = LitheContextMenuPresenter() - defer { presenter.dismiss() } - presenter.show(items: [other, current], at: NSPoint(x: 100, y: 100), appearance: NSAppearance(named: .aqua), locale: Locale(identifier: "en")) - let panel = try #require(NSApplication.shared.windows.first { $0.delegate === presenter }) - let enter = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, - windowNumber: panel.windowNumber, context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) - panel.sendEvent(enter) - #expect(invoked == "current") - } - - @Test func mutationHighlightResolvesTheCurrentAppearanceAndPreservesAlpha() throws { - let source = Color(nsColor: NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - ? NSColor(deviceRed: 248.0 / 255, green: 1.0 / 255, blue: 239.0 / 255, alpha: 0.5) - : NSColor(deviceRed: 8.0 / 255, green: 32.0 / 255, blue: 248.0 / 255, alpha: 0.5) - }) - // Resolve the same dynamic highlight in both appearances; do not capture the first one. - let highlighted = LitheTheme.DatabaseGrid.mutationBackground(source, highlighted: true) - let normal = LitheTheme.DatabaseGrid.mutationBackground(source, highlighted: false) - for name in [NSAppearance.Name.aqua, .darkAqua] { - let appearance = try #require(NSAppearance(named: name)) - var highlight: NSColor? - var original: NSColor? - var unchanged: NSColor? - appearance.performAsCurrentDrawingAppearance { - highlight = NSColor(highlighted).usingColorSpace(.deviceRGB) - original = NSColor(source).usingColorSpace(.deviceRGB) - unchanged = NSColor(normal).usingColorSpace(.deviceRGB) - } - let rgb = try #require(highlight) - #expect(original == unchanged) - #expect(abs(rgb.alphaComponent - 0.5) < 0.001) - let expected: [CGFloat] = name == .darkAqua ? [1, 17.0 / 255, 1] : [0, 16.0 / 255, 232.0 / 255] - for (actual, expected) in zip([rgb.redComponent, rgb.greenComponent, rgb.blueComponent], expected) { - #expect(abs(actual - expected) < 0.001) - } - } - } -} diff --git a/macos/Tests/LitheTests/DatabaseGridClickTests.swift b/macos/Tests/LitheTests/DatabaseGridClickTests.swift deleted file mode 100644 index 8995c2a89..000000000 --- a/macos/Tests/LitheTests/DatabaseGridClickTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import AppKit -import Testing -@testable import Lithe - -@MainActor -struct DatabaseGridClickTests { - @Test - func selectionIsDeliveredOnMouseDownWithoutWaitingForMouseUpOrSecondClick() throws { - let view = DatabaseGridCellClickView() - var clickCounts: [Int] = [] - view.onMouseDown = { clickCounts.append($0) } - func event(_ type: NSEvent.EventType, count: Int, modifiers: NSEvent.ModifierFlags = []) throws -> NSEvent { - try #require(NSEvent.mouseEvent(with: type, location: .zero, modifierFlags: modifiers, - timestamp: 0, windowNumber: 0, context: nil, - eventNumber: 0, clickCount: count, pressure: 1)) - } - // No run-loop advance, mouse-up, timer or second click is needed for selection. - view.mouseDown(with: try event(.leftMouseDown, count: 1)) - #expect(clickCounts == [1]) - view.mouseDown(with: try event(.leftMouseDown, count: 2)) - #expect(clickCounts == [1, 2]) - // Context-menu events remain owned by the existing right-click presenter. - view.mouseDown(with: try event(.rightMouseDown, count: 1)) - view.mouseDown(with: try event(.leftMouseDown, count: 1, modifiers: .control)) - #expect(clickCounts == [1, 2]) - } -} diff --git a/macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift b/macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift deleted file mode 100644 index e1fd3dd8b..000000000 --- a/macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import AppKit -import SwiftUI -import Testing -@testable import Lithe - -@MainActor -struct DatabaseToolbarAppearanceTests { - @Test(arguments: [false, true]) - func disabledToolbarLabelsRespectNativeAndInheritedState(dark: Bool) throws { - let button = Button {} label: { Color.red.frame(width: 16, height: 16) } - let variants = [ - AnyView(button.litheToolbarIconButton(isEnabled: false)), - AnyView(button.litheToolbarIconButton().disabled(true)), - AnyView(HStack(spacing: 0) { button.litheToolbarIconButton() }.disabled(true)) - ] - for variant in variants { - let rendered = try #require(ImageRenderer(content: variant - .environment(\.colorScheme, dark ? .dark : .light)).cgImage) - #expect(rendered.width == 22 && rendered.height == 22) - let color = try #require(NSBitmapImageRep(cgImage: rendered).colorAt(x: 11, y: 11)) - // All disabling routes dim once, including the enclosing toolbar group. - #expect(color.alphaComponent > 0.4 && color.alphaComponent < 0.5) - } - let textButton = Button {} label: { Color.red.frame(width: 44, height: 16) } - .litheToolbarIconButton(isIconOnly: false) - let rendered = try #require(ImageRenderer(content: textButton).cgImage) - #expect(rendered.width == 44 && rendered.height == 22) - let color = try #require(NSBitmapImageRep(cgImage: rendered).colorAt(x: 22, y: 11)) - #expect(color.alphaComponent == 1 && color.redComponent > color.greenComponent) - } - - @Test(arguments: [false, true]) - func providerAssetsUseNativeTreeCanvas(dark: Bool) throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources/DatabaseIcons") - for name in ["mysql", "mariadb", "postgres", "sqlite", "sqlserver", "mongodb", "redis"] { - let filename = name + (dark && name != "mongodb" ? "_dark" : "") + ".svg" - let image = try #require(NSImage(contentsOf: root.appendingPathComponent(filename))) - #expect(image.size == NSSize(width: 16, height: 16)) - let rendered = try #require(ImageRenderer(content: Image(nsImage: image)).cgImage) - let pixels = NSBitmapImageRep(cgImage: rendered) - var bounds = CGRect.null - for y in 0.. 0.1 { - bounds = bounds.union(CGRect(x: x, y: y, width: 1, height: 1)) - } - } - #expect(max(bounds.width, bounds.height) >= 12) - } - } - - @Test(arguments: [false, true]) - func toolbarResourcesKeepNativeSizeAndSharedButtonBounds(dark: Bool) throws { - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources/IDEAIcons") - for path in [ - "expui/general/add.svg", "expui/actions/newFolder.svg", "expui/general/import.svg", - "expui/general/collapseAll.svg", "expui/general/refresh.svg", "expui/fileTypes/shell.svg", - "expui/general/history.svg", "expui/general/previewVertically.svg", "expui/general/filter.svg", "expui/general/sortBy.svg", - "expui/toolwindows/structure.svg", "expui/actions/checked.svg", "expui/general/undo.svg", "expui/general/moreHorizontal.svg", "expui/general/previewVertically.svg", - "expui/general/upload.svg", "expui/general/reset.svg", "expui/general/chevronDown.svg", "expui/general/chevronLeft.svg", "expui/general/chevronRight.svg", - "general/arrowSplitCenterV.svg", "general/arrowUp.svg", "general/arrowDown.svg", - "expui/general/delete.svg", "expui/general/edit.svg", "expui/status/warning.svg", "expui/nodes/dataTables.svg", - "expui/general/greenCheckmark.svg", "expui/status/error.svg", - "database/expui/goldKey.svg", "database/expui/blueKey.svg", "database/expui/collection.svg", - "database/expui/schema.svg", "database/expui/objectGroup.svg", "database/expui/table.svg", "database/expui/column.svg" - ] { - let asset = dark ? LitheIcons.darkIdeaAssetPath(for: path) : path - let image = try #require(NSImage(contentsOf: root.appendingPathComponent(asset))) - #expect(image.size == NSSize(width: 16, height: 16)) - let renderer = ImageRenderer(content: Button {} label: { - Image(nsImage: image) - }.litheToolbarIconButton().environment(\.colorScheme, dark ? .dark : .light)) - let rendered = try #require(renderer.cgImage) - #expect(rendered.width == 22) - #expect(rendered.height == 22) - } - } -} diff --git a/macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift b/macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift deleted file mode 100644 index 13a4a67f1..000000000 --- a/macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift +++ /dev/null @@ -1,3084 +0,0 @@ -import AppKit -import Darwin -import LitheTestDesktopIsolation -import LitheApplicationKernel -@testable import LitheDatabaseModule -import LitheModuleAPI -import SwiftUI -import Testing -@testable import Lithe - -@Suite("Database workspace lifecycle") -@MainActor -struct DatabaseWorkspaceLifecycleTests { - @Test func newConnectionNamesUseDatabaseAndHostWithoutCredentials() { - #expect(DatabaseConnectionEditor.defaultConnectionName(kind: .mysql, host: "127.0.0.1", database: "", path: "") == "@127.0.0.1") - #expect(DatabaseConnectionEditor.defaultConnectionName(kind: .mysql, host: " localhost ", database: "foodmes", path: "") == "foodmes@localhost") - #expect(DatabaseConnectionEditor.defaultConnectionName(kind: .sqlite, host: "", database: "", path: "/fixture/catalog.sqlite") == "catalog.sqlite") - #expect(DatabaseConnectionEditor.defaultConnectionName(kind: .sqlite, host: "", database: "", path: "") == "SQLite") - #expect(DatabaseConnectionEditor.defaultConnectionName(kind: .mongodb, host: "mongodb://fixture:secret@db.example.test/catalog?token=secret", database: "catalog", path: "") == "catalog@db.example.test") - } - - @Test func passwordPersistenceMigratesWithoutWritingTemporarySecrets() throws { - let preferences = DatabaseLifecyclePreferences() - let secrets = DatabaseFolderSecrets() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - var profile = DatabaseProfile(name: "Fixture", kind: .mysql) - let key = "database.connection.\(profile.id.uuidString).password" - defer { try? store.deletePassword(for: profile.id) } - try store.save([profile]) - try store.savePassword("fixture-permanent", for: profile.id) - #expect(secrets.read(key: key) == "fixture-permanent") - secrets.rejectChanges(true) - #expect(throws: (any Error).self) { try store.savePassword("fixture-temporary", for: profile.id, persistence: .untilRestart) } - #expect(store.password(for: profile.id) == "fixture-permanent") - secrets.rejectChanges(false) - profile.passwordPersistence = .untilRestart - try store.savePassword("fixture-temporary", for: profile.id, persistence: .untilRestart) - try store.save([profile]) - #expect(secrets.read(key: key) == nil) - let reopened = DatabaseConnectionStore(store: preferences, secureStore: secrets) - #expect(reopened.password(for: profile.id) == "fixture-temporary") - #expect(reopened.hasPassword(for: profile.id)) - var restartProfile = DatabaseProfile(name: "Restarted", kind: .mysql) - restartProfile.passwordPersistence = .untilRestart - try store.save([profile, restartProfile]) - try secrets.write("obsolete-keychain-secret", key: "database.connection.\(restartProfile.id.uuidString).password") - let keychainReads = secrets.readCount - #expect(reopened.password(for: restartProfile.id).isEmpty) - #expect(!reopened.hasPassword(for: restartProfile.id)) - #expect(secrets.readCount == keychainReads) - let encoded = try JSONEncoder().encode(profile) - #expect(!String(decoding: encoded, as: UTF8.self).contains("fixture-temporary")) - secrets.rejectChanges(true) - #expect(throws: (any Error).self) { try store.savePassword("replacement", for: profile.id, persistence: .forever) } - #expect(reopened.password(for: profile.id) == "fixture-temporary") - #expect(secrets.read(key: key) == nil) - secrets.rejectChanges(false) - try store.savePassword(reopened.password(for: profile.id), for: profile.id, persistence: .forever) - profile.passwordPersistence = .forever - try store.save([profile]) - #expect(secrets.read(key: key) == "fixture-temporary") - try store.deletePassword(for: profile.id) - #expect(!reopened.hasPassword(for: profile.id)) - var legacy = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - legacy.removeValue(forKey: "passwordPersistence") - #expect(try JSONDecoder().decode(DatabaseProfile.self, from: JSONSerialization.data(withJSONObject: legacy)).passwordPersistence == .forever) - } - - @Test - func databaseTreeRendersDarkTheme() async throws { - try await databaseTreeRendersAndExpandsDatabasesIndependently(scheme: .dark) - } - - @Test - func databaseTreeRendersLightTheme() async throws { - try await databaseTreeRendersAndExpandsDatabasesIndependently(scheme: .light) - } - - @Test(arguments: [ColorScheme.dark, .light], ["en", "zh-Hans"]) - func connectionResultBalloonUsesDialogSurfaceAndPointer(scheme: ColorScheme, language: String) throws { - MacBundledFontRegistry.registerFonts() - let summary = language == "en" ? "DBMS: MySQL\n\nConnection test duration: 11 ms" : "DBMS: MySQL\n\n连接检测耗时:11 毫秒" - let host = NSHostingView(rootView: DatabaseConnectionResultView(summary: summary, pointerX: 90) - .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: language))) - host.frame.size = host.fittingSize - host.layoutSubtreeIfNeeded() - #expect(host.bounds.width == 728) - #expect(host.bounds.height > 90 && host.bounds.height < 160) - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width - func color(_ x: CGFloat, _ y: CGFloat) throws -> NSColor { - try #require(bitmap.colorAt(x: Int(x * scale), y: Int(y * scale))?.usingColorSpace(.sRGB)) - } - func expectColor(_ hex: UInt32, x: CGFloat, y: CGFloat) throws { - let actual = try color(x, y) - #expect(abs(actual.redComponent - CGFloat((hex >> 16) & 255) / 255) < 0.01) - #expect(abs(actual.greenComponent - CGFloat((hex >> 8) & 255) / 255) < 0.01) - #expect(abs(actual.blueComponent - CGFloat(hex & 255) / 255) < 0.01) - } - let background: UInt32 = scheme == .dark ? 0x191A1C : 0xF7F8F9 - try expectColor(background, x: 400, y: host.bounds.height - 16) - // Integer-aligned upstream edges cover half the first pixel at 1x, unlike a half-point inset. - let border: UInt32 = scheme == .dark ? 0x33353B : 0xDDDFE4 - let edge = try color(400, 0) - for (component, shift) in [(edge.redComponent, 16), (edge.greenComponent, 8), (edge.blueComponent, 0)] { - let expected = scale == 1 - ? CGFloat(((background >> shift) & 255) + ((border >> shift) & 255)) / 510 - : CGFloat((border >> shift) & 255) / 255 - #expect(abs(component - expected) < 0.01) - } - // The tail is filled continuously; the surrounding bottom strip stays transparent. - try expectColor(background, x: 90, y: host.bounds.height - 5) - #expect(try color(400, host.bounds.height - 5).alphaComponent < 0.01) - #expect(try color(host.bounds.width - 1, 0).alphaComponent < 0.01) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-result-\(scheme == .dark ? "dark" : "light")-\(language).png")) - } - } - - @Test(arguments: [ColorScheme.dark, .light]) - func connectionEditorTabsAndTestAction(scheme: ColorScheme) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: TableEditsWorkspaceRunner(), - executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - let app = NSApplication.shared - let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") - let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false - app.accessibilitySetValue(true, forAttribute: attribute) - defer { app.accessibilitySetValue(previous, forAttribute: attribute) } - let host = NSHostingView(rootView: DatabaseConnectionEditor(isPresented: .constant(true), profile: DatabaseProfile(name: "Nacos", kind: .nacos, port: 8848, username: "", path: "/nacos")) - .environmentObject(feature).environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "en"))) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 760, height: 580), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) - window.contentView = host; window.orderFront(nil) - defer { window.contentView = nil; window.close() } - func elements(_ element: Any) -> [AnyObject] { - let accessible = element as AnyObject - return [accessible] + (accessible.accessibilityChildren?() ?? []).flatMap(elements) - } - func element(_ label: String) -> AnyObject? { - (elements(host) + (window.childWindows ?? []).flatMap { $0.contentView.map(elements) ?? [] }).first { - let title: String = $0.accessibilityLabel?() ?? $0.accessibilityTitle?() ?? "" - // SwiftUI AX nodes expose the selector without adopting the public static-text protocol. - let object = $0 as? NSObject - let selector = NSSelectorFromString("accessibilityValue") - let value = object?.responds(to: selector) == true ? object?.perform(selector)?.takeUnretainedValue() as? String : nil - return title == label || value == label - } - } - func waitFor(_ condition: () -> Bool) async -> Bool { - let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) - repeat { - host.layoutSubtreeIfNeeded() - if condition() { return true } - await Task.yield() - } while clock.now < deadline - return condition() - } - func capture(_ tab: String) throws { - guard let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return } - host.cacheDisplay(in: host.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-editor-\(tab)-\(scheme == .dark ? "dark" : "light").png")) - } - try #require(await waitFor { element("API context path") != nil }) - let generalFrame = host.frame - let fields = elements(host).filter { $0.accessibilityRole?() == .textField } - func inputFrame(_ label: String) throws -> NSRect { - try #require(fields.first { $0.accessibilityLabel?() == label }?.accessibilityFrame?()) - } - let hostInput = try inputFrame("Host") - for label in ["Username (optional)", "Password"] { - let frame = try inputFrame(label) - #expect(abs(frame.minX - hostInput.minX) < 1) - #expect(abs(frame.width - hostInput.width) < 1) - } - #expect(try #require(element("Test Connection")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") != nil }) - #expect(store.load().isEmpty && feature.selectedProfileID == nil) - #expect(element("Copy") != nil) - let resultWindow = try #require(window.childWindows?.first(where: { $0.isVisible })) - #expect(resultWindow.hasShadow == false) - try #require(await waitFor { LitheTestRequestedWindowAlpha(resultWindow) == 1 }) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let popup = window.childWindows?.first(where: { $0.isVisible }), let content = popup.contentView, - let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) { - content.cacheDisplay(in: content.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-connection-success-\(scheme == .dark ? "dark" : "light").png")) - } - let pasteboard = NSPasteboard.general - let savedClipboard = (pasteboard.pasteboardItems ?? []).map { item in - item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { result, type in result[type] = item.data(forType: type) } - } - defer { - pasteboard.clearContents() - pasteboard.writeObjects(savedClipboard.map { values in - let item = NSPasteboardItem() - for (type, data) in values { item.setData(data, forType: type) } - return item - }) - } - #expect(try #require(element("Copy")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") == nil }) - #expect(pasteboard.string(forType: .string)?.hasPrefix("DBMS: Nacos") == true) - #expect(try #require(element("Connection test result")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") != nil }) - #expect(try #require(element("Connection test result")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") == nil }) - #expect(try #require(element("Connection test result")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") != nil }) - #expect(try #require(element("Connection test result")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Connection successful") == nil }) - try capture("general") - #expect(try #require(element("SSH / TLS")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Use TLS") != nil && element("API context path") == nil }) - #expect(host.frame == generalFrame) - let nameLabelFrame = try #require(element("Name")?.accessibilityFrame?()) - let tlsLabelFrame = try #require(element("Use TLS")?.accessibilityFrame?()) - #expect(abs(nameLabelFrame.minX - tlsLabelFrame.minX) < 1) - try capture("network") - #expect(try #require(element("Safety and Labels")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("Read-only connection") != nil }) - #expect(host.frame == generalFrame) - try capture("safety") - #expect(try #require(element("General")).accessibilityPerformPress?() == true) - try #require(await waitFor { element("API context path") != nil }) - } - - private func databaseTreeRendersAndExpandsDatabasesIndependently(scheme: ColorScheme) async throws { - let runner = DatabaseTreeRunner() - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let folder = DatabaseConnectionFolder(name: "Development") - let profile = DatabaseProfile(name: "Local MySQL", kind: .mysql, folderID: folder.id) - let sqlite = DatabaseProfile(name: "SQLite cache", kind: .sqlite, path: "/fixture/cache.db") - try store.save([profile, sqlite]); try store.saveFolders([folder]) - let recoveryRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store, recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot)) - defer { feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - let application = NSApplication.shared - let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") - let previousAccessibility = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false - application.accessibilitySetValue(true, forAttribute: attribute) - defer { application.accessibilitySetValue(previousAccessibility, forAttribute: attribute) } - if ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"] != nil { - MacBundledFontRegistry.registerFonts() - #expect(NSFont(name: "Inter-Regular", size: 13) != nil) - for kind in DatabaseKind.allCases { - let filename = kind.brandIconFilename as NSString - let url = try #require(Bundle.main.url(forResource: filename.deletingPathExtension, - withExtension: filename.pathExtension, subdirectory: "DatabaseIcons")) - #expect(NSImage(contentsOf: url) != nil) - } - } - let host = NSHostingView(rootView: DatabaseSidebarView() - .environmentObject(feature).environmentObject(model).environmentObject(settings) - .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "en")) - .background(LitheTheme.sidebar).frame(width: 320, height: 620)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 620), styleMask: [.titled], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) - window.contentView = host; window.orderFront(nil) - defer { window.contentView = nil; window.close() } - let originalWindows = Set(NSApp.windows.map(ObjectIdentifier.init)) - defer { - for popup in NSApp.windows where !originalWindows.contains(ObjectIdentifier(popup)) { - if let menu = popup.delegate as? LitheContextMenuPresenter { menu.dismiss() } - else if let centered = popup.delegate as? LitheCenteredPopupPresenter { centered.close() } - } - } - func buttons(_ element: Any) -> [AnyObject] { - let accessible = element as AnyObject - return (accessible.accessibilityRole?() == .button ? [accessible] : []) - + (accessible.accessibilityChildren?() ?? []).flatMap { buttons($0) } - } - func button(_ label: String) -> AnyObject? { - buttons(host).first { - let title = $0.accessibilityLabel?() ?? $0.accessibilityTitle?() ?? "" - return title == label || (["Local MySQL", "SQLite cache"].contains(label) && title.contains(label)) - } - } - func waitForView(_ condition: () -> Bool) async -> Bool { - let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(3)) - repeat { - host.layoutSubtreeIfNeeded() - if condition() { return true } - await Task.yield() - } while clock.now < deadline - return condition() - } - #expect(await waitForView { button("Expand connection") != nil }) - let connectionButton = try #require(button("Expand connection")) - #expect(connectionButton.accessibilityPerformPress?() == true) - #expect(await waitForView { button("Collapse connection") != nil }) - #expect(feature.selectedProfileID == nil) // Disclosure must not navigate or open the editor. - window.makeKeyAndOrderFront(nil) - var tablesExpanded = true - func activateRow(_ label: String) throws { - let disclosure: String - switch label { - case "Development": - disclosure = button("Collapse Folder Development") != nil - ? "Collapse Folder Development" : "Expand Folder Development" - case "Local MySQL", "SQLite cache": - disclosure = button("Collapse connection") != nil ? "Collapse connection" : "Expand connection" - case "alpha", "beta", "Default database": - disclosure = button("Collapse database \(label)") != nil - ? "Collapse database \(label)" : "Expand database \(label)" - case "Tables": - disclosure = tablesExpanded ? "Collapse Tables" : "Expand Tables" - tablesExpanded.toggle() - default: - disclosure = button("Collapse \(label)") != nil ? "Collapse \(label)" : "Expand \(label)" - } - let candidate: AnyObject? - if label == "SQLite cache" { - candidate = buttons(host).last { ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == disclosure } - } else { - candidate = button(disclosure) - } - let row = try #require(candidate, - "Missing \(disclosure) for \(label); visible buttons: \(buttons(host).compactMap { $0.accessibilityLabel?() })") - #expect(row.accessibilityPerformPress?() == true) - } - #expect(try #require(button("Development")).accessibilityPerformPress?() == true) - #expect(await waitForView { button("Local MySQL") != nil }) - try activateRow("Development") - #expect(await waitForView { button("Local MySQL") == nil }) - try activateRow("Development") - #expect(await waitForView { button("Local MySQL") != nil }) - try activateRow("Local MySQL") - #expect(await waitForView { button("Expand connection") != nil && button("Expand database alpha") == nil }) - try activateRow("Local MySQL") - #expect(await waitForView { button("Collapse connection") != nil && button("Expand database alpha") != nil }) - for database in ["alpha", "beta"] { - #expect(await waitForView { button("Expand database \(database)") != nil }) - let disclosure = try #require(button("Expand database \(database)")) - #expect(disclosure.accessibilityPerformPress?() == true) - } - #expect(await waitForView { feature.databaseTrees[profile.id]?.databases["alpha"]?.state == .ready }) - #expect(feature.databaseTrees[profile.id]?.databases["alpha"]?.state == .ready) - #expect(feature.databaseTrees[profile.id]?.databases["beta"]?.state == .ready) - #expect(feature.databaseTrees[profile.id]?.databases["alpha"]?.tables.contains("alpha_only") == true) - #expect(feature.databaseTrees[profile.id]?.databases["beta"]?.tables.contains("beta_only") == true) - #expect(await waitForView { button("alpha_only") != nil && button("beta_only") != nil }) - for _ in 0..<2 { - #expect(try #require(button("alpha")).accessibilityPerformPress?() == true) - #expect(button("alpha_only") != nil) - try activateRow("alpha") - #expect(await waitForView { button("alpha_only") == nil && button("beta_only") != nil }) - try activateRow("alpha") - #expect(await waitForView { button("alpha_only") != nil }) - try activateRow("Tables") - #expect(await waitForView { button("alpha_only") == nil && button("beta_only") != nil }) - try activateRow("Tables") - #expect(await waitForView { button("alpha_only") != nil }) - } - #expect(buttons(host).filter { ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == "shared" }.count == 2) - #expect(feature.selectedTable == nil) // Connection double-click must not open table data. - func selectedRowCount() throws -> Int { - host.layoutSubtreeIfNeeded() - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - // Compare against the same native capture path; NSBitmapImageRep applies the display color profile. - let referenceHeight = LitheTheme.Tree.rowHeight * 2 - let reference = NSHostingView(rootView: VStack(spacing: 0) { - Color.clear.frame(height: LitheTheme.Tree.rowHeight) - .modifier(LitheTreeListRowSurface(isSelected: true, isFocused: false)) - Color.clear.frame(height: LitheTheme.Tree.rowHeight) - .modifier(LitheTreeListRowSurface(isSelected: true, isFocused: true)) - }.environment(\.colorScheme, scheme).environment(\.controlActiveState, .key) - .frame(width: 320, height: referenceHeight)) - let referenceWindow = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: referenceHeight), styleMask: [.borderless], backing: .buffered, defer: false) - referenceWindow.isReleasedWhenClosed = false - referenceWindow.appearance = window.appearance - referenceWindow.contentView = reference - defer { referenceWindow.contentView = nil; referenceWindow.close() } - reference.layoutSubtreeIfNeeded() - let referenceBitmap = try #require(reference.bitmapImageRepForCachingDisplay(in: reference.bounds)) - reference.cacheDisplay(in: reference.bounds, to: referenceBitmap) - let referenceX = Int(280 * CGFloat(referenceBitmap.pixelsWide) / reference.bounds.width) - let colors = try [1, 3].map { quarter in - try #require(referenceBitmap.colorAt(x: referenceX, y: referenceBitmap.pixelsHigh * quarter / 4)?.usingColorSpace(.sRGB)) - } - let x = Int(280 * CGFloat(bitmap.pixelsWide) / host.bounds.width) - let rowPixels = Int(LitheTheme.Tree.rowHeight * CGFloat(bitmap.pixelsWide) / host.bounds.width) - var runs = 0, runLength = 0 - func finishBand() { - // The light header separator shares the inactive color; only full-height row bands are selections. - if runLength >= rowPixels - 1 { runs += Int((Double(runLength) / Double(rowPixels)).rounded()) } - runLength = 0 - } - for y in 0.. 0.99 - } - if selected { runLength += 1 } - else { finishBand() } - } - finishBand() - return runs - } - let serverRow = try #require(button("Local MySQL")) - #expect(serverRow.accessibilityPerformPress?() == true) - #expect(await waitForView { feature.selectedProfileID == profile.id && !feature.isLoading }) - #expect(try selectedRowCount() == 1) - let databaseRow = try #require(button("beta")) - #expect(databaseRow.accessibilityPerformPress?() == true) - #expect(await waitForView { !feature.isLoading }) - #expect(feature.selectedProfile?.database == profile.database) // Tree selection must not navigate the editor. - #expect(try selectedRowCount() == 1) // The parent connection must relinquish selection. - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"] { - // Screenshots are accepted only with the real bundled SVGs and fonts staged at build time. - #expect(LitheIcons.ideaImage(resourcePath: "database/expui/schema.svg") != nil) - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let png = try #require(bitmap.representation(using: .png, properties: [:])) - let destination = URL(fileURLWithPath: directory, isDirectory: true) - try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) - try png.write(to: destination.appendingPathComponent("database-tree-\(scheme == .dark ? "dark" : "light").png")) - } - let tableRow = try #require(button("beta_only")) - #expect(tableRow.accessibilityPerformPress?() == true) - #expect(await waitForView { button("beta_only") != nil }) - #expect(feature.selectedTable == nil) - #expect(try selectedRowCount() == 1) // Neither ancestor may stay selected when the table is selected. - let beforeRefresh = runner.requests.count - #expect(try #require(button("Refresh selected structure")).accessibilityPerformPress?() == true) - let betaKey = DatabaseTreeTableKey(profileID: profile.id, database: "beta", table: "beta_only") - #expect(await waitForView { feature.tableTrees[betaKey]?.state == .ready }) - #expect(runner.requests.dropFirst(beforeRefresh).allSatisfy { - $0.database == "beta" && ["describeTable", "listIndexes", "listForeignKeys"].contains($0.method) - }) - let requestCount = runner.requests.count - runner.blockedMethod = "describeTable" - runner.blocks = true - defer { runner.release.open() } - let expandTable = try #require(button("Expand table alpha_only")) - #expect(expandTable.accessibilityPerformPress?() == true) - #expect(await runner.entered.waitUntilOpen()) - let metadataKey = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "alpha_only") - #expect(feature.tableTrees[metadataKey]?.state == .loading) - #expect(feature.selectedProfile?.database == profile.database && feature.selectedTable == nil) - func hasLoadingRow(_ element: Any) -> Bool { - let accessible = element as AnyObject - return accessible.accessibilityLabel?() == "Loading…" - || (accessible.accessibilityChildren?() ?? []).contains { hasLoadingRow($0) } - } - #expect(await waitForView { button("Collapse table alpha_only") != nil && hasLoadingRow(host) }) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"] { - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - let png = try #require(bitmap.representation(using: .png, properties: [:])) - try png.write(to: URL(fileURLWithPath: directory, isDirectory: true) - .appendingPathComponent("database-tree-loading-\(scheme == .dark ? "dark" : "light").png")) - } - #expect(!runner.requests.dropFirst(requestCount).contains { $0.method == "pageTable" }) - let collapseTable = try #require(button("Collapse table alpha_only")) - #expect(collapseTable.accessibilityPerformPress?() == true) - #expect(await waitForView { button("Expand table alpha_only") != nil && !hasLoadingRow(host) }) - runner.release.open() - #expect(await waitForView { feature.tableTrees[metadataKey]?.state == .idle }) - // Collapsing cancels in-flight metadata; a late response cannot repopulate its cache. - #expect(button("Collapse table alpha_only") == nil) - let completedRequests = runner.requests.count - let reopen = try #require(button("Expand table alpha_only")) - #expect(reopen.accessibilityPerformPress?() == true) - #expect(await waitForView { - button("Collapse table alpha_only") != nil && feature.tableTrees[metadataKey]?.state == .ready - }) - #expect(runner.requests.count > completedRequests) - let collapse = try #require(button("Collapse database alpha")) - #expect(collapse.accessibilityPerformPress?() == true) - #expect(await waitForView { button("alpha_only") == nil && button("beta_only") != nil }) - #expect(button("Expand table beta_only") != nil) - try activateRow("Local MySQL") - #expect(await waitForView { button("beta_only") == nil }) - try activateRow("SQLite cache") - #expect(await waitForView { button("Default database") != nil && button("_only") != nil }) - for _ in 0..<2 { - try activateRow("Default database") - #expect(await waitForView { button("_only") == nil }) - try activateRow("Default database") - #expect(await waitForView { button("_only") != nil }) - #expect(try #require(button("Tables")).accessibilityPerformPress?() == true) - #expect(button("_only") != nil) - try activateRow("Tables") - #expect(await waitForView { button("_only") == nil }) - try activateRow("Tables") - #expect(await waitForView { button("_only") != nil }) - } - runner.includesObjects = true - await feature.refreshTables() - for title in ["Views", "Routines", "Triggers", "Sequences"] { - #expect(await waitForView { button(title) != nil }) - #expect(try #require(button(title)).accessibilityPerformPress?() == true) - for _ in 0..<2 { - #expect(button("Collapse \(title)") != nil) - try activateRow(title) - #expect(await waitForView { button("Expand \(title)") != nil }) - try activateRow(title) - #expect(await waitForView { button("Collapse \(title)") != nil }) - } - } - func popupButton(_ label: String) -> AnyObject? { - NSApp.windows.filter { $0.isVisible && $0 !== window }.flatMap { candidate in - candidate.contentView?.layoutSubtreeIfNeeded() - return buttons(candidate) - }.first { ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == label } - } - func rightClick(at point: NSPoint) throws { - func captures(_ view: NSView) -> [NSView] { - (String(describing: type(of: view)).contains("LitheRightClickCaptureView") ? [view] : []) - + view.subviews.flatMap(captures) - } - let matching = captures(host).filter { - $0.bounds.contains($0.convert(point, from: nil)) - } - let capture = try #require(matching.min { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }) - // SwiftPM has no application event loop; dispatch to the existing native capture at this row. - capture.rightMouseDown(with: try #require(NSEvent.mouseEvent(with: .rightMouseDown, location: point, - modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, - context: nil, eventNumber: 0, clickCount: 1, pressure: 0))) - } - func openMovePicker() async throws { - let name = try #require(button("SQLite cache")) - let rect = try #require(name.accessibilityFrame?()) - try rightClick(at: window.convertPoint(fromScreen: NSPoint(x: rect.midX, y: rect.midY))) - #expect(await waitForView { popupButton("Move") != nil }) - #expect(try #require(popupButton("Move")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupButton("To Folder…") != nil }) - #expect(try #require(popupButton("To Folder…")).accessibilityPerformPress?() == true) - } - @discardableResult - func submitName(_ name: String, confirm: Bool = true) async throws -> NSWindow { - #expect(await waitForView { NSApp.windows.contains { $0.isVisible && $0.firstResponder is NSTextView } }) - let popup = try #require(NSApp.windows.first { $0.isVisible && $0.firstResponder is NSTextView }) - let editor = try #require(popup.firstResponder as? NSTextView) - editor.selectAll(nil); editor.insertText(name, replacementRange: editor.selectedRange()) - await Task.yield() - popup.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: popup.windowNumber, context: nil, - characters: confirm ? "\r" : "\u{1B}", charactersIgnoringModifiers: confirm ? "\r" : "\u{1B}", - isARepeat: false, keyCode: confirm ? 36 : 53))) - return popup - } - try await openMovePicker() - #expect(await waitForView { popupButton("Development") != nil }) - #expect(try #require(popupButton("Development")).accessibilityPerformPress?() == true) - #expect(await waitForView { feature.profiles.first { $0.id == sqlite.id }?.folderID == folder.id }) - try await openMovePicker() - #expect(await waitForView { popupButton("New Folder…") != nil }) - #expect(try #require(popupButton("New Folder…")).accessibilityPerformPress?() == true) - try await submitName("QA/Shared") - #expect(await waitForView { - guard let target = feature.folders.first(where: { $0.name == "Shared" }) else { return false } - return feature.profiles.first { $0.id == sqlite.id }?.folderID == target.id - }) - #expect(feature.folders.contains { $0.name == "QA" }) - let folderCount = feature.folders.count - #expect(try #require(button("New Folder")).accessibilityPerformPress?() == true) - let rejectedPopup = try await submitName("QA/Shared") - #expect(await waitForView { feature.errorMessage == "A folder with this name already exists." }) - #expect(await waitForView { rejectedPopup.frame.height > 78 }) // Inline validation must fit below the name field. - #expect(feature.folders.count == folderCount) - try await submitName("Cancelled", confirm: false) - #expect(await waitForView { !rejectedPopup.isVisible }) - #expect(feature.folders.count == folderCount) - func popupElement(_ label: String) -> AnyObject? { - func elements(_ element: Any) -> [AnyObject] { - let accessible = element as AnyObject - return [accessible] + (accessible.accessibilityChildren?() ?? []).flatMap(elements) - } - return NSApp.windows.filter { $0.isVisible && $0 !== window }.flatMap { popup in - popup.contentView?.layoutSubtreeIfNeeded() - return elements(popup) - }.first { ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == label } - } - func schemaPopupKey(_ keyCode: UInt16, characters typed: String? = nil) throws { - let popup = try #require(NSApp.windows.first { $0.isVisible && $0.parent === window && $0.delegate is LitheContextMenuPresenter }) - let characters = typed ?? (keyCode == 53 ? "\u{1B}" : "\r") - popup.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: popup.windowNumber, context: nil, - characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode))) - } - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("All schemas") != nil }) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let popup = NSApp.windows.first(where: { $0.isVisible && $0 !== window && $0.parent === window }), - let content = popup.contentView, let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) { - content.cacheDisplay(in: content.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-schema-selector-\(scheme == .dark ? "dark" : "light").png")) - } - // The unified checkbox row is clickable even in the empty area beyond its label. - let scopePopup = try #require(NSApp.windows.first { $0.isVisible && $0.parent === window && $0.delegate is LitheContextMenuPresenter }) - let betaCheckbox = try #require(popupElement("beta")) - let betaFrame = try #require(betaCheckbox.accessibilityFrame?()) - #expect(betaFrame.width >= LitheDropdownMetrics.maximumWidth - 2 * LitheTheme.Tree.horizontalInset - LitheTheme.Tree.indent) - #expect(betaFrame.height == LitheDropdownMetrics.rowHeight) - let rowEnd = scopePopup.convertPoint(fromScreen: NSPoint(x: betaFrame.maxX - 4, y: betaFrame.midY)) - for expected in [0, 1] { - for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { - scopePopup.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: rowEnd, modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: scopePopup.windowNumber, - context: nil, eventNumber: 0, clickCount: 1, pressure: 0))) - } - #expect(await waitForView { - guard let checkbox = popupElement("beta") as? NSObject, - checkbox.responds(to: NSSelectorFromString("accessibilityValue")) else { return false } - return (checkbox.perform(NSSelectorFromString("accessibilityValue"))?.takeUnretainedValue() as? NSNumber)?.intValue == expected - }) - } - #expect(feature.visibleDatabaseNames(for: profile.id) == ["alpha", "beta"]) // Still a draft until applied. - // A native key event reveals the initially collapsed search field and filters the list. - try schemaPopupKey(11, characters: "b") - #expect(await waitForView { popupElement("alpha") == nil && popupElement("beta") != nil }) - try schemaPopupKey(53) - #expect(await waitForView { popupElement("All schemas") == nil }) - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("alpha") != nil && popupElement("beta") != nil }) - #expect(try #require(popupElement("beta")).accessibilityPerformPress?() == true) - try schemaPopupKey(36) - #expect(await waitForView { feature.visibleDatabaseNames(for: profile.id) == ["alpha"] }) - #expect(store.load().first { $0.id == profile.id }?.visibleDatabases == ["alpha"]) - // Exercise the rendered badge in Chinese: its count must not use the global ordinal translation. - host.rootView = DatabaseSidebarView() - .environmentObject(feature).environmentObject(model).environmentObject(settings) - .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "zh-Hans")) - .background(LitheTheme.sidebar).frame(width: 320, height: 620) - func scopeBadge() -> NSObject? { - buttons(host).first { ($0.accessibilityIdentifier?() ?? "") == "database-schema-scope" } as? NSObject - } - #expect(await waitForView { scopeBadge() != nil }) - let badge = try #require(scopeBadge()) - let valueSelector = NSSelectorFromString("accessibilityValue") - #expect(badge.responds(to: valueSelector)) - #expect(badge.perform(valueSelector)?.takeUnretainedValue() as? String == "1 of 2") - let badgeFrame = try #require((badge as AnyObject).accessibilityFrame?()) - #expect(abs(badgeFrame.height - LitheTheme.Tree.rowHeight) <= 1) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) { - host.cacheDisplay(in: host.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-scope-chinese-\(scheme == .dark ? "dark" : "light").png")) - } - host.rootView = DatabaseSidebarView() - .environmentObject(feature).environmentObject(model).environmentObject(settings) - .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "en")) - .background(LitheTheme.sidebar).frame(width: 320, height: 620) - #expect(await waitForView { button("Select databases") != nil }) - try activateRow("Local MySQL") - #expect(await waitForView { button("alpha") != nil && button("beta") == nil }) - // Escape cancels the draft selection rather than changing the stored scope. - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("All schemas") != nil }) - #expect(try #require(popupElement("All schemas")).accessibilityPerformPress?() == true) - try schemaPopupKey(53) - #expect(await waitForView { popupElement("All schemas") == nil }) - #expect(feature.visibleDatabaseNames(for: profile.id) == ["alpha"]) - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("All schemas") != nil }) - #expect(try #require(popupElement("All schemas")).accessibilityPerformPress?() == true) - try schemaPopupKey(36) - #expect(await waitForView { popupElement("All schemas") == nil && feature.visibleDatabaseNames(for: profile.id) == ["alpha", "beta"] }) - // Clicking away applies the draft. Escape above must still cancel it. - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("beta") != nil }) - #expect(popupButton("Apply") == nil && popupButton("Cancel") == nil) - #expect(try #require(popupElement("beta")).accessibilityPerformPress?() == true) - window.makeKeyAndOrderFront(nil) - #expect(await waitForView { popupElement("All schemas") == nil && feature.visibleDatabaseNames(for: profile.id) == ["alpha"] }) - #expect(store.load().first { $0.id == profile.id }?.visibleDatabases == ["alpha"]) - // The trigger owns its own close; a second click must not reset the draft and reopen it. - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("All schemas") != nil }) - #expect(try #require(popupElement("All schemas")).accessibilityPerformPress?() == true) - #expect(try #require(button("Select databases")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupElement("All schemas") == nil && feature.visibleDatabaseNames(for: profile.id) == ["alpha", "beta"] }) - // The plus opens the shared New menu; engine selection configures a new, unsaved connection. - #expect(try #require(button("New")).accessibilityPerformPress?() == true) - try #require(await waitForView { popupButton("Data Source") != nil && popupButton("Query Console") != nil }) - #expect(popupButton("Folder") != nil && popupButton("Import Data Sources…") != nil) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let menu = NSApp.windows.first(where: { $0.isVisible && $0.delegate is LitheContextMenuPresenter }), - let content = menu.contentView, let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) { - content.cacheDisplay(in: content.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-new-menu-\(scheme == .dark ? "dark" : "light").png")) - } - #expect(try #require(popupButton("Data Source")).accessibilityPerformPress?() == true) - try #require(await waitForView { popupButton("SQLite") != nil }) - for name in ["MySQL", "MariaDB", "PostgreSQL", "SQLite", "SQL Server", "MongoDB", "Redis", "Nacos"] { - #expect(popupButton(name) != nil) - } - #expect(try #require(popupButton("SQLite")).accessibilityPerformPress?() == true) - func connectionPanel() -> NSWindow? { - NSApp.windows.first { $0.isVisible && $0.parent === window && $0.delegate is LitheCenteredPopupPresenter } - } - try #require(await waitForView { connectionPanel() != nil }) - let editorPanel = try #require(connectionPanel()) - let connectionContent = try #require(editorPanel.contentView) - func editableFields(_ view: NSView) -> [NSTextField] { - let own = (view as? NSTextField).map { $0.isEditable ? [$0] : [] } ?? [] - return own + view.subviews.flatMap(editableFields) - } - // SQLite asks only for a name and a file path, not host/port/credentials. - try #require(await waitForView { - connectionContent.layoutSubtreeIfNeeded() - return editableFields(connectionContent).count == 2 && connectionContent.frame.height == 580 - }) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let bitmap = connectionContent.bitmapImageRepForCachingDisplay(in: connectionContent.bounds) { - connectionContent.cacheDisplay(in: connectionContent.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-new-sqlite-\(scheme == .dark ? "dark" : "light").png")) - } - editorPanel.performClose(nil) - try #require(await waitForView { !editorPanel.isVisible }) - #expect(feature.profiles.count == 2) - // A zero-schema scope must still expand through the shared tree activation path. - func hasEmptyScopeHint(_ element: Any) -> Bool { - let accessible = element as AnyObject - return accessible.accessibilityIdentifier?() == "database-no-schemas-selected" - || (accessible.accessibilityChildren?() ?? []).contains(where: hasEmptyScopeHint) - } - #expect(feature.setVisibleDatabases([], for: profile.id)) - try #require(await waitForView { hasEmptyScopeHint(host) }) - #expect(button("alpha") == nil && button("beta") == nil) - let emptyScopeBadge = try #require(scopeBadge()) - #expect(emptyScopeBadge.perform(valueSelector)?.takeUnretainedValue() as? String == "0 of 2") - try activateRow("Local MySQL") - try #require(await waitForView { !hasEmptyScopeHint(host) }, "Activation must collapse the connection") - try activateRow("Local MySQL") - try #require(await waitForView { hasEmptyScopeHint(host) }, "Activation must expand with no schemas selected") - #expect(popupElement("All schemas") == nil) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"], - let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) { - host.cacheDisplay(in: host.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-empty-scope-\(scheme == .dark ? "dark" : "light").png")) - } - #expect(try #require(button("Collapse all")).accessibilityPerformPress?() == true) - #expect(await waitForView { button("SQLite cache") == nil }) - // Stay inside the tree viewport, above the optional bottom progress indicator. - let blank = NSPoint(x: host.bounds.midX, y: host.isFlipped ? host.bounds.maxY - 60 : host.bounds.minY + 60) - try rightClick(at: host.convert(blank, to: nil)) - #expect(await waitForView { popupButton("New") != nil && popupButton("Import connections from DBX") != nil }) - #expect(try #require(popupButton("New")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupButton("Folder") != nil }) - #expect(try #require(popupButton("Folder")).accessibilityPerformPress?() == true) - let backgroundPopup = try await submitName("Cancelled", confirm: false) - #expect(await waitForView { !backgroundPopup.isVisible }) - #expect(feature.folders.count == folderCount) - for connection in feature.profiles { #expect(feature.remove(connection)) } - for group in feature.folders { feature.removeFolder(group) } - #expect(await waitForView { feature.profiles.isEmpty && feature.folders.isEmpty }) - try rightClick(at: host.convert(blank, to: nil)) - #expect(await waitForView { popupButton("New") != nil }) - #expect(try #require(popupButton("New")).accessibilityPerformPress?() == true) - #expect(await waitForView { popupButton("Folder") != nil }) - #expect(try #require(popupButton("Folder")).accessibilityPerformPress?() == true) - let emptyPopup = try await submitName("First folder") - #expect(await waitForView { !emptyPopup.isVisible && feature.folders.contains { $0.name == "First folder" } }) - await model.shutdownProjectSession() - } - - @Test - func databaseSchemaScopePersistsAndRefreshSkipsHiddenDatabases() async throws { - let runner = DatabaseTreeRunner(), secrets = DatabaseFolderSecrets() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: secrets) - let profile = DatabaseProfile(name: "Server", kind: .mysql, database: "beta") - try store.save([profile]); try store.savePassword("unchanged", for: profile.id) - #expect(store.load().first?.visibleDatabases == nil) // Legacy profiles keep showing all accessible names. - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - await feature.loadDatabaseTree("beta", for: profile.id) - feature.selectedProfileID = profile.id; feature.selectedTable = "shared" - let before = runner.requests.count - #expect(feature.setVisibleDatabases(["alpha", "alpha"], for: profile.id)) - #expect(feature.visibleDatabaseNames(for: profile.id) == ["alpha"]) - #expect(store.load().first?.visibleDatabases == ["alpha"]) - #expect(feature.databaseTrees[profile.id]?.databases["beta"] == nil) - #expect(runner.requests.count == before) // Scope changes never test connections or load rows. - #expect(feature.selectedProfile?.database == "beta" && feature.selectedTable == "shared") - #expect(store.password(for: profile.id) == "unchanged") - let restarted = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { restarted.prepareForModuleRelease() } - await restarted.loadDatabaseTree(for: profile.id) - #expect(restarted.visibleDatabaseNames(for: profile.id) == ["alpha"]) - // An editor can still load a hidden database; connection refresh must respect the tree's scope. - for name in ["alpha", "beta"] { await feature.loadDatabaseTree(name, for: profile.id) } - runner.names = ["alpha", "beta", "new_database"] - let refreshStart = runner.requests.count - await feature.refreshDatabaseTree(for: profile.id, force: true) - #expect(feature.visibleDatabaseNames(for: profile.id) == ["alpha"]) - #expect(runner.requests.dropFirst(refreshStart).filter { $0.method != "listDatabases" }.allSatisfy { $0.database == "alpha" }) - #expect(feature.setVisibleDatabases([], for: profile.id)) - #expect(feature.visibleDatabaseNames(for: profile.id).isEmpty && store.load().first?.visibleDatabases == []) - #expect(!feature.setVisibleDatabases(["not_permitted"], for: profile.id)) - #expect(!feature.setVisibleDatabases(nil, for: UUID())) - #expect(store.load().first?.visibleDatabases == []) - #expect(feature.setVisibleDatabases(nil, for: profile.id)) - runner.names.append("another_new_database") - await feature.loadDatabaseTree(for: profile.id, force: true) - #expect(feature.visibleDatabaseNames(for: profile.id) == runner.names.sorted()) - #expect(store.load().first?.visibleDatabases == nil) - // Hiding a database invalidates an in-flight structure response. - runner.blocks = true - let pending = Task { await feature.loadDatabaseTree("alpha", for: profile.id, force: true) } - defer { pending.cancel(); runner.release.open() } - #expect(await runner.entered.waitUntilOpen()) - #expect(feature.setVisibleDatabases(["beta"], for: profile.id)) - runner.release.open(); await pending.value - #expect(feature.databaseTrees[profile.id]?.databases["alpha"] == nil) - #expect(feature.visibleDatabaseNames(for: profile.id) == ["beta"]) - #expect(!feature.hasActiveModuleWork) - } - - @Test - func databaseFolderCreationAndMovePersistWithoutChangingTheConnection() throws { - let preferences = DatabaseLifecyclePreferences(), secrets = DatabaseFolderSecrets() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Local", kind: .sqlite, path: "/fixture/cache.db") - try store.save([profile]); try store.savePassword("fixture-password", for: profile.id) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: DatabaseTreeRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - #expect(feature.createFolder(name: "Team/QA", movingProfileID: profile.id)) - let team = try #require(feature.folders.first { $0.name == "Team" }) - let qa = try #require(feature.folders.first { $0.name == "QA" }) - #expect(qa.parentID == team.id && feature.profiles.first?.folderID == qa.id) - #expect(store.loadFolders().count == feature.folders.count && store.loadFolders().allSatisfy { feature.folders.contains($0) } && store.load() == feature.profiles) - #expect(store.password(for: profile.id) == "fixture-password") - #expect(feature.createFolder(name: "team/Backend")) - #expect(feature.folders.filter { $0.name == "Team" }.count == 1) - #expect(feature.move(profile, toFolder: nil)) - #expect(feature.profiles.first?.folderID == nil) - #expect(feature.createFolder(name: "Other")) - let other = try #require(feature.folders.first { $0.name == "Other" }) - #expect(feature.moveFolder(team.id, toParent: other.id)) - #expect(feature.folders.first { $0.id == qa.id }?.parentID == team.id) - #expect(!feature.moveFolder(other.id, toParent: qa.id)) - #expect(!feature.moveFolder(team.id, toParent: team.id)) - let before = feature.folders - #expect(!feature.createFolder(name: "Other/Team/QA/New", movingFolderID: other.id)) - #expect(feature.folders == before && store.loadFolders().count == before.count && store.loadFolders().allSatisfy { before.contains($0) }) - #expect(feature.createFolder(name: "Fresh/Nested", movingFolderID: team.id)) - let nested = try #require(feature.folders.first { $0.name == "Nested" }) - #expect(feature.folders.first { $0.id == team.id }?.parentID == nested.id) - #expect(feature.moveFolder(team.id, toParent: nil)) - #expect(feature.createFolder(name: "Destination/Team")) - let destination = try #require(feature.folders.first { $0.name == "Destination" }) - #expect(!feature.moveFolder(team.id, toParent: destination.id)) - #expect(feature.profiles.first?.id == profile.id && feature.profiles.first?.path == profile.path) - } - - @Test - func databaseFolderInvalidPathsAndStaleTargetsDoNotPersistPartialGroups() throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: DatabaseTreeRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - for name in ["", " ", "Team//QA", "/Team", "Team/"] { - #expect(!feature.createFolder(name: name)) - #expect(feature.folders.isEmpty && store.loadFolders().isEmpty) - } - #expect(!feature.createFolder(name: "Team/QA", movingProfileID: UUID())) - #expect(!feature.createFolder(name: "Team/QA", parentID: UUID())) - #expect(feature.folders.isEmpty) - #expect(feature.createFolder(name: "Team/QA")) - let before = feature.folders - #expect(!feature.createFolder(name: "team/qa")) - #expect(feature.folders == before && store.loadFolders().count == before.count && store.loadFolders().allSatisfy { before.contains($0) }) - } - - @Test - func databaseTreeTableMetadataSharesRequestsWithEditorAndDoesNotFetchRows() async throws { - let runner = DatabaseTreeRunner() - runner.blocks = true - runner.blockedMethod = "describeTable" - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql, database: "alpha") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - let key = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared") - let tree = Task { await feature.loadTableTree(key) } - defer { tree.cancel(); runner.release.open() } - #expect(await runner.entered.waitUntilOpen()) - #expect(feature.tableTrees[key]?.state == .loading) - #expect(feature.selectedProfileID == nil && !feature.isLoading) - #expect(!runner.requests.contains { $0.method == "pageTable" }) - let otherKey = DatabaseTreeTableKey(profileID: profile.id, database: "beta", table: "shared") - await feature.loadTableTree(otherKey) - #expect(feature.tableTrees[otherKey]?.state == .ready) - feature.activateProfile(profile) - let editor = Task { await feature.openTable("shared") } - defer { editor.cancel(); runner.release.open() } - #expect(await runner.pageEntered.waitUntilOpen()) - #expect(feature.isLoading) - feature.cancelTableTreeLoad(key) // Collapsing the tree must preserve the editor's shared metadata request. - runner.release.open() - await tree.value - await editor.value - #expect(feature.tableTrees[key]?.columns.count == 2) - #expect(feature.columns == ["id", "name"]) - #expect(runner.requests.filter { $0.method == "describeTable" && $0.database == "alpha" }.count == 1) - await feature.select(profile) - #expect(runner.requests.filter { $0.method == "describeTable" && $0.database == "alpha" }.count == 1) - let count = runner.requests.count - await feature.loadTableTree(key) - #expect(runner.requests.count == count) - await feature.loadTableTree(key, force: true) - #expect(runner.requests.filter { $0.method == "describeTable" && $0.database == "alpha" }.count == 2) - #expect(!feature.hasActiveModuleWork) - } - - @Test - func databaseTreeTableMetadataFailureStaysOnItsNodeAndCanRetry() async throws { - let runner = DatabaseTreeRunner() - runner.fails = true - runner.failureMethod = "describeTable" - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql, database: "alpha") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - feature.errorMessage = "Existing editor error" - let key = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared") - await feature.loadTableTree(key) - guard case .failed = feature.tableTrees[key]?.state else { Issue.record("Metadata failure must be retryable on its node"); return } - #expect(feature.errorMessage == "Existing editor error") - runner.fails = false - await feature.loadTableTree(key, force: true) - #expect(feature.tableTrees[key]?.state == .ready) - #expect(feature.tableTrees[key]?.columns.count == 2) - #expect(!feature.hasActiveModuleWork) - } - - @Test - func databaseTreeForcedTableRefreshReplacesAnInFlightRequest() async throws { - let runner = DatabaseTreeRunner() - runner.blocks = true - runner.blockedMethod = "describeTable" - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql, database: "alpha") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - let key = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared") - let old = Task { await feature.loadTableTree(key) } - defer { old.cancel() } - #expect(await runner.entered.waitUntilOpen()) - runner.columnNames = ["fresh"] - runner.blocks = false - await feature.loadTableTree(key, force: true) - #expect(feature.tableTrees[key]?.columns.compactMap { $0["column_name"] } == [.string("fresh")]) - #expect(runner.requests.filter { $0.method == "describeTable" }.count == 2) - runner.release.open() - await old.value - #expect(feature.tableTrees[key]?.columns.compactMap { $0["column_name"] } == [.string("fresh")]) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: [DatabaseKind.mysql, .mariadb]) - func databaseTreeKeepsMultipleDatabasesAndConnectionsWithoutMovingEditor(kind: DatabaseKind) async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let folder = DatabaseConnectionFolder(name: "Development") - let first = DatabaseProfile(name: "Server", kind: kind, database: "alpha", folderID: folder.id) - let second = DatabaseProfile(name: "Other server", kind: kind, database: "beta", folderID: folder.id) - let sqlite = DatabaseProfile(name: "Cache", kind: .sqlite, path: "/fixture/cache.db", folderID: folder.id) - try store.save([first, second, sqlite]); try store.saveFolders([folder]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - await feature.select(sqlite) - let initialTableRequests = runner.requests.filter { $0.method == "listTables" }.count - feature.selectedTable = "keep_this_selection" - await feature.loadDatabaseTree(for: first.id) - #expect(feature.databaseTrees[first.id]?.names == ["alpha", "beta"]) - #expect(runner.requests.filter { $0.method == "listTables" }.count == initialTableRequests) - await feature.loadDatabaseTree("alpha", for: first.id) - await feature.loadDatabaseTree("beta", for: first.id) - await feature.loadDatabaseTree(for: second.id) - await feature.loadDatabaseTree("beta", for: second.id) - #expect(feature.databaseTrees[first.id]?.databases["alpha"]?.tables == ["alpha_only", "shared"]) - #expect(feature.databaseTrees[first.id]?.databases["beta"]?.tables == ["beta_only", "shared"]) - #expect(feature.databaseTrees[second.id]?.databases["beta"]?.state == .ready) - #expect(feature.selectedProfileID == sqlite.id) - #expect(feature.selectedTable == "keep_this_selection") - #expect(feature.folders == [folder] && feature.profiles.allSatisfy { $0.folderID == folder.id }) - let count = runner.requests.count - await feature.loadDatabaseTree("alpha", for: first.id) - await feature.loadDatabaseTree("not_permitted", for: first.id) - #expect(runner.requests.count == count) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: [ColorScheme.dark, .light], [false, true]) - func databaseTableEmptyStateUsesSharedInstruction(scheme: ColorScheme, hasConnection: Bool) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: DatabaseTreeRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - if hasConnection { feature.selectedProfileID = profile.id } - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") - let application = NSApplication.shared - let previous = application.accessibilityAttributeValue(attribute) as? Bool ?? false - application.accessibilitySetValue(true, forAttribute: attribute) - defer { application.accessibilitySetValue(previous, forAttribute: attribute) } - let host = NSHostingView(rootView: DatabaseTableView().environmentObject(feature).environmentObject(model) - .environmentObject(settings).environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: "en")) - .frame(width: 540, height: 410)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 540, height: 410), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false; window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) - window.contentView = host; window.orderFront(nil) - defer { window.contentView = nil; window.close() } - func texts(_ element: AnyObject) -> [String] { - // SwiftUI's private AX nodes expose the value selector without conforming to NSAccessibilityStaticText. - let object = element as? NSObject - let selector = NSSelectorFromString("accessibilityValue") - let value = object?.responds(to: selector) == true ? object?.perform(selector)?.takeUnretainedValue() as? String : nil - return [element.accessibilityLabel?(), element.accessibilityTitle?(), value].compactMap { $0 } - + (element.accessibilityChildren?() ?? []).flatMap { texts($0 as AnyObject) } - } - let message = hasConnection ? "Double-click a table to view data" : "Select a connection in the database tree" - let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(3)) - repeat { - host.layoutSubtreeIfNeeded() - if texts(host).contains(message) { break } - await Task.yield() - } while clock.now < deadline - #expect(texts(host).contains(message)) - #expect(!texts(host).contains("No Table Selected")) - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_TREE_SCREENSHOTS"] { - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-empty-\(hasConnection ? "connection" : "none")-\(scheme == .dark ? "dark" : "light").png")) - } - } - - @Test - func databaseTreeRefreshScopesAndForceRefreshPreserveEditor() async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - for database in ["alpha", "beta"] { await feature.loadDatabaseTree(database, for: profile.id) } - let alpha = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared") - let beta = DatabaseTreeTableKey(profileID: profile.id, database: "beta", table: "shared") - await feature.loadTableTree(alpha); await feature.loadTableTree(beta) - feature.selectedProfileID = profile.id - feature.selectedTable = "unchanged" - let before = runner.requests.count - await feature.refreshDatabaseTree(for: profile.id, database: "beta") - let scoped = Array(runner.requests.dropFirst(before)) - #expect(!scoped.isEmpty && scoped.allSatisfy { $0.database == "beta" }) - #expect(scoped.contains { $0.method == "listTables" } && scoped.contains { $0.method == "describeTable" }) - #expect(!scoped.contains { $0.method == "pageTable" || $0.method == "listDatabases" }) - let beforeTable = runner.requests.count - await feature.loadTableTree(beta, force: true) - #expect(runner.requests.dropFirst(beforeTable).allSatisfy { - $0.database == "beta" && ["describeTable", "listIndexes", "listForeignKeys"].contains($0.method) - }) - // A full reload supersedes an old worker even if its synchronous process cannot be cancelled. - runner.blocks = true; runner.blockedMethod = "describeTable" - let old = Task { await feature.loadTableTree(alpha, force: true) } - defer { old.cancel(); runner.release.open() } - #expect(await runner.entered.waitUntilOpen()) - let oldID = feature.tableTrees[alpha]?.requestID - runner.blocks = false - await feature.refreshDatabaseTree(for: profile.id, force: true) - let refreshedID = try #require(feature.tableTrees[alpha]?.requestID) - #expect(refreshedID != oldID && feature.tableTrees[alpha]?.state == .ready) - runner.release.open(); await old.value - #expect(feature.tableTrees[alpha]?.requestID == refreshedID) - runner.names = ["beta"] - await feature.refreshDatabaseTree(for: profile.id) - #expect(feature.databaseTrees[profile.id]?.databases["alpha"] == nil && feature.tableTrees[alpha] == nil) - #expect(feature.selectedTable == "unchanged" && feature.rows.isEmpty) - #expect(!runner.requests.contains { $0.method == "pageTable" }) - #expect(!feature.hasActiveModuleWork) - } - - @Test - func databaseTreeActionsSelectTheDatabaseOwningAnIdenticallyNamedTable() async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql, database: "alpha") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - await feature.loadDatabaseTree("alpha", for: profile.id) - var target = profile; target.database = "beta" - #expect(await feature.selectDatabaseContext(target)) - await feature.openTable("shared") - #expect(feature.selectedProfile?.database == "beta") - #expect(runner.requests.last(where: { $0.method == "pageTable" })?.database == "beta") - #expect(feature.databaseTrees[profile.id]?.databases["alpha"]?.tables == ["alpha_only", "shared"]) - #expect(feature.databaseTrees[profile.id]?.databases["beta"]?.tables == ["beta_only", "shared"]) - target.database = "not_permitted" - #expect(await !feature.selectDatabaseContext(target)) - #expect(feature.selectedProfile?.database == "beta") - } - - @Test - func databaseTreeCanBrowseServerWithoutADefaultDatabase() async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - await feature.select(profile) - #expect(feature.databaseTrees[profile.id]?.names == ["alpha", "beta"]) - #expect(feature.errorMessage == nil && !feature.isLoading) - #expect(runner.requests.allSatisfy { $0.method == "listDatabases" }) - } - - @Test - func databaseTreeRetriesFailureWithoutLosingOtherDatabases() async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - await feature.loadDatabaseTree("alpha", for: profile.id) - runner.fails = true - await feature.loadDatabaseTree("beta", for: profile.id) - guard case .failed = feature.databaseTrees[profile.id]?.databases["beta"]?.state else { - Issue.record("A denied database must expose a retryable failure"); return - } - #expect(feature.databaseTrees[profile.id]?.databases["alpha"]?.state == .ready) - runner.fails = false - await feature.loadDatabaseTree("beta", for: profile.id, force: true) - #expect(feature.databaseTrees[profile.id]?.databases["beta"]?.state == .ready) - runner.names = ["beta"] - await feature.loadDatabaseTree(for: profile.id, force: true) - #expect(feature.databaseTrees[profile.id]?.databases["alpha"] == nil) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: ["disconnect", "remove", "release"], [false, true]) - func databaseTreeRejectsLateResultsAfterInvalidation(action: String, tableMetadata: Bool) async throws { - let runner = DatabaseTreeRunner() - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let recoveryRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store, recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot)) - defer { runner.release.open(); feature.prepareForModuleRelease() } - await feature.loadDatabaseTree(for: profile.id) - runner.blocks = true - runner.blockedMethod = tableMetadata ? "describeTable" : "listTables" - let key = DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared") - let task = Task { - if tableMetadata { await feature.loadTableTree(key) } - else { await feature.loadDatabaseTree("alpha", for: profile.id) } - } - defer { task.cancel(); runner.release.open() } - #expect(await runner.entered.waitUntilOpen()) - #expect(feature.hasActiveModuleWork) - // Another database can finish while alpha remains blocked, without moving the editor. - await feature.loadDatabaseTree("beta", for: profile.id) - #expect(feature.databaseTrees[profile.id]?.databases["beta"]?.state == .ready) - switch action { - case "disconnect": feature.disconnect(profile) - case "remove": #expect(feature.remove(profile)) - default: feature.prepareForModuleRelease() - } - runner.release.open() - await task.value - #expect(feature.databaseTrees[profile.id] == nil) - #expect(feature.tableTrees[key] == nil) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: [ - ("listDatabases", "cancel"), ("listDatabases", "collapse"), ("listDatabases", "release"), - ("listTables", "collapse"), ("listTables", "disconnect"), ("listTables", "forceRefresh"), - ("describeTable", "release"), ("describeTable", "tableCollapse"), ("describeTable", "tableForceRefresh") - ]) - func databaseTreeCancellationStopsSidecarWithoutReleasingARequest(scenario: (String, String)) async throws { - let (method, action) = scenario - let directory = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-tree-cancel-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let executable = directory.appendingPathComponent("blocked-sidecar.sh") - let pidFile = directory.appendingPathComponent("sidecar.pid") - try "#!/bin/sh\nprintf '%s' \"$$\" > \"$LITHE_TEST_PID_FILE\"\ntrap '' TERM\nwhile :; do :; done\n" - .write(to: executable, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) - let runner = CancellableTreeSidecarRunner(blockedMethod: method, pidFile: pidFile) - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Server", kind: .mysql) - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: executable), connectionStore: store) - defer { feature.prepareForModuleRelease() } - if method != "listDatabases" { await feature.loadDatabaseTree(for: profile.id) } - if method == "describeTable" { await feature.loadDatabaseTree("alpha", for: profile.id) } - let loading = Task { @MainActor in - switch method { - case "describeTable": await feature.loadTableTree(DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared")) - case "listTables": await feature.loadDatabaseTree("alpha", for: profile.id) - default: await feature.loadDatabaseTree(for: profile.id) - } - } - defer { loading.cancel() } - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(5)) - var processID: Int32? - while clock.now < deadline { - processID = (try? String(contentsOf: pidFile, encoding: .utf8)).flatMap { Int32($0) } - if processID != nil { break } - // test-stability: allow(swift-real-sleep) reason: sidecar startup exposes only a PID file, so poll it with a local deadline. - try? await Task.sleep(for: .milliseconds(10)) - } - let pid = try #require(processID) - defer { if Darwin.kill(pid, 0) == 0 { _ = Darwin.kill(pid, SIGKILL) } } - let started = clock.now - switch action { - case "cancel": loading.cancel() - case "release": feature.prepareForModuleRelease() - case "collapse": feature.cancelDatabaseTreeLoad(for: profile.id, database: method == "listTables" ? "alpha" : nil) - case "tableCollapse": feature.cancelTableTreeLoad(DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared")) - case "tableForceRefresh": - runner.shouldBlock = false - await feature.loadTableTree(DatabaseTreeTableKey(profileID: profile.id, database: "alpha", table: "shared"), force: true) - case "disconnect": feature.disconnect(profile) - default: - runner.shouldBlock = false - await feature.refreshDatabaseTree(for: profile.id, force: true) - } - await loading.value - #expect(clock.now - started < .seconds(2)) - errno = 0 - #expect(Darwin.kill(pid, 0) == -1 && errno == ESRCH) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: [false, true], [false, true]) - func formattedMiddleSelectionKeepsTheWholeStatement(dark: Bool, wholeScript: Bool) throws { - let original = "select '😀';\nselect 1;\nselect 2;" - let selected = (original as NSString).range(of: "select 1;") - let formatted = wholeScript ? DatabaseSQLFormatter.format(original) : "select '😀';\nselect\n 1;\nselect 2;" - let settings = AppSettings(store: DatabaseLifecyclePreferences()) - func view(_ sql: String) -> some View { - SQLSyntaxEditor(text: .constant(sql), completions: [], onRun: {}, onSelectionChange: { _ in }) - .environmentObject(settings).environment(\.colorScheme, dark ? .dark : .light) - } - let host = NSHostingView(rootView: view(original)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 500, height: 240), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false; window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - defer { window.contentView = nil; window.close() } - window.contentView = host; host.layoutSubtreeIfNeeded() - func editor(_ view: NSView) -> SQLTextView? { - if let text = view as? SQLTextView { return text } - return view.subviews.lazy.compactMap { editor($0) }.first - } - let text = try #require(editor(host)) - text.setSelectedRange(selected) - host.rootView = view(formatted); host.layoutSubtreeIfNeeded() - #expect(editor(host) === text) - #expect((text.string as NSString).substring(with: text.selectedRange()) == "select\n 1;") - #expect(text.selectedRange() == (formatted as NSString).range(of: "select\n 1;")) - #expect(SQLSyntaxEditor.selection(NSRange(location: original.utf16.count, length: 0), replacing: original, with: formatted) - == NSRange(location: formatted.utf16.count, length: 0)) - #expect(SQLSyntaxEditor.selection(NSRange(location: 0, length: original.utf16.count), replacing: original, with: formatted) - == NSRange(location: 0, length: formatted.utf16.count)) - } - - @Test(arguments: [false, true]) - func multilineStatementOutlineEndsAtFinalLineInsteadOfWidestLine(dark: Bool) throws { - let sql = "SELECT\n c.name, SUM(o.total) AS total_spent FROM customers c\n JOIN orders o ON o.customer_id = c.id\nGROUP BY c.name;" - let range = NSRange(location: 0, length: sql.utf16.count) - let record = DatabaseSQLStatementExecution(id: UUID(), sql: sql, sourceSQL: sql, sourceRange: range, - startedAt: Date(timeIntervalSince1970: 0), batchStartedAt: Date(timeIntervalSince1970: 0), durationMilliseconds: 74, - result: nil, rowsReturned: 3, rowsAffected: nil, errorMessage: nil) - let host = NSHostingView(rootView: SQLSyntaxEditor(text: .constant(sql), completions: [], onRun: {}, onSelectionChange: { _ in }, executions: [record]) - .environmentObject(AppSettings(store: DatabaseLifecyclePreferences())).environment(\.colorScheme, dark ? .dark : .light) - .background(LitheTheme.editor)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 240), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false; window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - defer { window.contentView = nil; window.close() } - window.contentView = host; host.layoutSubtreeIfNeeded() - func editor(_ view: NSView) -> SQLTextView? { - if let text = view as? SQLTextView { return text } - return view.subviews.lazy.compactMap { editor($0) }.first - } - let text = try #require(editor(host)) - let lines = text.statementLineRects(range) - #expect(lines.count == 4) - let last = try #require(lines.last) - let box = text.statementRect(range), outline = text.statementOutline(range) - #expect(last.maxX < box.maxX - 80) - #expect(outline.contains(NSPoint(x: box.maxX - 4, y: lines[1].midY))) - #expect(!outline.contains(NSPoint(x: box.maxX - 4, y: last.midY))) - // Rendered right edge must step inward on GROUP BY; the old union rectangle colored the widest X on every row. - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - func color(x: CGFloat, y: CGFloat) throws -> NSColor { - let point = text.convert(NSPoint(x: x, y: y), to: host) - let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width - return try #require(bitmap.colorAt(x: Int(point.x * scale), y: Int((host.isFlipped ? point.y : host.bounds.height - point.y) * scale))?.usingColorSpace(.sRGB)) - } - let border = try color(x: floor(last.maxX) + 0.5, y: last.midY) - let reference = try color(x: floor(box.maxX) + 0.5, y: lines[1].midY) - let outside = try color(x: floor(box.maxX) + 0.5, y: last.midY) - let background = try color(x: box.maxX - 5, y: last.midY) - #expect(abs(border.redComponent - reference.redComponent) < 0.01 && abs(border.greenComponent - reference.greenComponent) < 0.01 - && abs(border.blueComponent - reference.blueComponent) < 0.01) - #expect(abs(outside.redComponent - background.redComponent) < 0.01 && abs(outside.greenComponent - background.greenComponent) < 0.01 - && abs(outside.blueComponent - background.blueComponent) < 0.01) - #expect(text.statementLineRects(NSRange(location: NSNotFound, length: 1)).isEmpty) - } - - @Test(arguments: [false, true], [false, true]) - func queryOutputTracksStatementsSelectionAndFailures(fails: Bool, dark: Bool) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Output fixture", kind: .sqlite, database: "fixture") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: QueryOutputRunner(fails: fails), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - feature.activateProfile(profile) - let id = try #require(feature.selectedSQLTabID) - let sql = "SELECT '😀;';\nSELECT '😀;';" - feature.updateSQL(sql, in: id) - let selection = (sql as NSString).range(of: "SELECT '😀;';", options: .backwards) - await feature.runSQL(in: id, scope: .selection("SELECT '😀;';"), selectionRange: selection) - let record = try #require(feature.selectedSQLTab?.statementExecutions.first) - #expect(record.sourceRange == NSRange(location: selection.location, length: "SELECT\n '😀;';".utf16.count)) - #expect(feature.selectedSQLTab?.sql == "SELECT '😀;';\nSELECT\n '😀;';") - #expect(record.result?.rows.count == 1) - #expect(record.durationMilliseconds >= 0) - #expect(DatabaseQueryResult(rows: [["b": .integer(2)], ["a": .integer(1)]], truncated: false).resolvedColumns == ["b", "a"]) - feature.updateSQL("SELECT 1; SELECT missing_table;", in: id) - await feature.runSQL(in: id) - let tab = try #require(feature.selectedSQLTab) - #expect(tab.statementExecutions.count == 3) - #expect(tab.execution != nil) // Both success and failure retain actual elapsed time. - #expect((tab.errorMessage != nil) == fails) - #expect((tab.statementExecutions.last?.errorMessage != nil) == fails) - #expect(feature.sqlHistory.count == 2) // Failed executions are browsable too. - #expect(tab.statementExecutions[1].sourceRange == NSRange(location: 0, length: 13)) - #expect(tab.statementExecutions[2].sourceRange == NSRange(location: 14, length: 25)) - feature.clearSQLOutput(in: id) - #expect(feature.selectedSQLTab?.statementExecutions == tab.statementExecutions) - #expect(feature.selectedSQLTab?.outputHiddenThroughExecutionID == tab.statementExecutions.last?.id) - feature.dismissSQLError(in: id) - #expect(feature.selectedSQLTab?.errorMessage == nil) - feature.updateSQL("INSERT INTO customers VALUES(3, 'C', 'D');", in: id) - await feature.runSQL(in: id, confirmedRisk: true) // Unavailable recovery storage fails before provider execution. - let preparationFailure = try #require(feature.selectedSQLTab?.statementExecutions.last) - #expect(preparationFailure.errorMessage != nil) - #expect(preparationFailure.sourceRange == nil) - #expect(preparationFailure.result == nil) - - let host = NSHostingView(rootView: SQLSyntaxEditor(text: .constant(tab.sql), completions: [], onRun: {}, onSelectionChange: { _ in }, executions: tab.statementExecutions, wrapsLines: true) - .environmentObject(AppSettings(store: DatabaseLifecyclePreferences())) - .environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.editor)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - defer { window.contentView = nil; window.close() } - window.contentView = host - host.layoutSubtreeIfNeeded() - func editor(_ view: NSView) -> SQLTextView? { - if let text = view as? SQLTextView { return text } - return view.subviews.lazy.compactMap { editor($0) }.first - } - let text = try #require(editor(host)) - let statementBox = text.statementRect(NSRange(location: 0, length: tab.sql.utf16.count)) - #expect(statementBox.width < text.bounds.width * 0.9) - // The newline's viewport-wide advance must not widen a multiline statement. - text.string = "SELECT\n '😀';" - let multilineBox = text.statementRect(NSRange(location: 0, length: text.string.utf16.count)) - #expect(multilineBox.width < text.bounds.width * 0.9) - text.string = tab.sql - #expect(text.textContainer?.widthTracksTextView == true) - #expect(!text.isHorizontallyResizable) - // Native caches inherit the display's HDR profile; calibrate against the same native color in-window. - let reference = NSBox(frame: NSRect(x: host.bounds.maxX - 10, y: host.bounds.maxY - 10, width: 8, height: 8)) - reference.boxType = .custom; reference.titlePosition = .noTitle; reference.borderType = .noBorder - reference.fillColor = NSColor(LitheTheme.DatabaseConsole.statementOutline) - host.addSubview(reference) - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) // Draw the real statement boxes and inline timings. - let firstRange = try #require(tab.statementExecutions.first { $0.sourceSQL == tab.sql && $0.sourceRange != nil }?.sourceRange) - let firstBox = text.statementRect(firstRange) - let leftEdge = text.convert(NSPoint(x: 0.5, y: firstBox.midY), to: host) - let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width - let pixelY = host.isFlipped ? leftEdge.y : host.bounds.height - leftEdge.y - let actualBorder = try #require(bitmap.colorAt(x: Int(leftEdge.x * scale), y: Int(pixelY * scale))?.usingColorSpace(.sRGB)) - var expectedBorder = NSColor.black - window.appearance!.performAsCurrentDrawingAppearance { expectedBorder = NSColor(LitheTheme.DatabaseConsole.statementOutline).usingColorSpace(.sRGB)! } - #expect(abs(expectedBorder.redComponent - 61.0 / 255) < 0.00001) - #expect(abs(expectedBorder.greenComponent - 122.0 / 255) < 0.00001) - #expect(abs(expectedBorder.blueComponent - 73.0 / 255) < 0.00001) - let referencePoint = NSPoint(x: reference.frame.midX, y: reference.frame.midY) - let referenceY = host.isFlipped ? referencePoint.y : host.bounds.height - referencePoint.y - let renderedReference = try #require(bitmap.colorAt(x: Int(referencePoint.x * scale), y: Int(referenceY * scale))?.usingColorSpace(.sRGB)) - #expect(abs(actualBorder.redComponent - renderedReference.redComponent) < 0.01) - #expect(abs(actualBorder.greenComponent - renderedReference.greenComponent) < 0.01) - #expect(abs(actualBorder.blueComponent - renderedReference.blueComponent) < 0.01) - text.string = String(repeating: "SELECT 'long value' ", count: 100) - text.layoutManager?.ensureLayout(for: text.textContainer!) - var fragments = 0 - text.layoutManager?.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: text.layoutManager?.numberOfGlyphs ?? 0)) { _, _, _, _, _ in fragments += 1 } - #expect(fragments > 1) - #expect(!text.string.contains("\n")) // Soft wraps never rewrite SQL or its logical line count. - text.string = "select * from t;" - text.setSelectedRange(NSRange(location: text.string.utf16.count, length: 0)) - let formatted = "select\n * from t;" - host.rootView = SQLSyntaxEditor(text: .constant(formatted), completions: [], onRun: {}, onSelectionChange: { _ in }, executions: [], wrapsLines: true) - .environmentObject(AppSettings(store: DatabaseLifecyclePreferences())) - .environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.editor) - host.layoutSubtreeIfNeeded() - #expect(editor(host) === text) - #expect(text.selectedRange() == NSRange(location: formatted.utf16.count, length: 0)) - } - - @Test(arguments: [false, true]) - func executionMarkersFollowUnchangedStatementsAndExposeStripePositions(dark: Bool) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Marker fixture", kind: .sqlite, database: "fixture") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: QueryOutputRunner(fails: true), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - feature.activateProfile(profile) - let id = try #require(feature.selectedSQLTabID) - feature.updateSQL("select '😀';", in: id) - await feature.runSQL(in: id) - let first = try #require(feature.selectedSQLTab?.statementExecutions.first) - let appended = try #require(feature.selectedSQLTab?.sql) + "\n\nselect * from missing_table;" - feature.updateSQL(appended, in: id) - #expect(feature.selectedSQLTab?.statementExecutions.first?.sourceRange == first.sourceRange) - let selection = (appended as NSString).range(of: "select * from missing_table;") - await feature.runSQL(in: id, scope: .selection("select * from missing_table;"), selectionRange: selection) - let tab = try #require(feature.selectedSQLTab) - #expect(tab.statementExecutions.count == 2) - #expect(tab.statementExecutions.first?.sql == first.sql) // Output retains the actual executed SQL. - #expect(tab.statementExecutions.first?.sourceSQL == tab.sql) - #expect(tab.statementExecutions.first?.sourceRange == first.sourceRange) - - let settings = AppSettings(store: DatabaseLifecyclePreferences()) - func view(_ tab: DatabaseSQLTab) -> some View { - SQLSyntaxEditor(text: .constant(tab.sql), completions: [], onRun: {}, onSelectionChange: { _ in }, executions: tab.statementExecutions) - .environmentObject(settings).environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.editor) - } - let host = NSHostingView(rootView: view(tab)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 500, height: 240), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false; window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - defer { window.contentView = nil; window.close() } - window.contentView = host; host.layoutSubtreeIfNeeded() - func editor(_ view: NSView) -> SQLTextView? { - if let text = view as? SQLTextView { return text } - return view.subviews.lazy.compactMap { editor($0) }.first - } - let text = try #require(editor(host)) - let stripe = try #require(text.executionStripe) - #expect(text.visibleExecutions.count == 2) - #expect(text.gutter?.consoleExecutionMarkers[1] == "expui/general/greenCheckmark.svg") - #expect(text.gutter?.consoleExecutionMarkers[4] == "expui/status/error.svg") - let firstRange = try #require(tab.statementExecutions[0].sourceRange) - let lastRange = try #require(tab.statementExecutions[1].sourceRange) - let success = stripe.markerRect(firstRange), failure = stripe.markerRect(lastRange) - #expect(success.height == 2 && success.width == 14) - #expect(success.minY == floor(text.statementRect(firstRange).maxY)) // Short documents are not stretched. - #expect(failure.minY > success.maxY) - let markerHit = stripe.convert(NSPoint(x: success.midX, y: success.midY), to: stripe.superview) - #expect(stripe.hitTest(markerHit) === stripe) - let blankHit = stripe.convert(NSPoint(x: success.midX, y: failure.maxY + 20), to: stripe.superview) - #expect(stripe.hitTest(blankHit) == nil) - #expect(stripe.wantsLayer) // Text-view repaint must not erase its transparent sibling's ticks. - #expect((stripe.accessibilityValue() as? String)?.contains("Succeeded, line 1") == true) - #expect((stripe.accessibilityValue() as? String)?.contains("Failed, line 4") == true) - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - - let prefix = String(repeating: "\n", count: 1000) - feature.updateSQL(prefix + tab.sql, in: id) - let long = try #require(feature.selectedSQLTab) - #expect(long.statementExecutions[0].sourceRange?.location == firstRange.location + prefix.utf16.count) - host.rootView = view(long); host.layoutSubtreeIfNeeded() - let compressed = stripe.markerRect(try #require(long.statementExecutions[1].sourceRange)) - #expect(compressed.maxY <= stripe.bounds.height) - #expect(compressed.minY > stripe.bounds.height * 0.9) - // Editing only the failed statement keeps the earlier check; editing a boundary removes stale status. - feature.updateSQL(long.sql.replacingOccurrences(of: "missing_table", with: "customers"), in: id) - #expect(feature.selectedSQLTab?.statementExecutions[0].sourceRange != nil) - #expect(feature.selectedSQLTab?.statementExecutions[1].sourceRange == nil) - let beforeJoin = try #require(feature.selectedSQLTab) - let insertion = try #require(beforeJoin.statementExecutions[0].sourceRange).location - feature.updateSQL((beforeJoin.sql as NSString).replacingCharacters(in: NSRange(location: insertion, length: 0), with: "x"), in: id) - #expect(feature.selectedSQLTab?.statementExecutions[0].sourceRange == nil) - } - - @Test(arguments: [false, true]) - func queryCompletionKeepsOnlyUnchangedExecutionMarkers(editedStatement: Bool) async throws { - let runner = GatedDatabaseWorkspaceRunner(fails: false, gatedMethod: "query") - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Pending marker fixture", kind: .sqlite, database: "fixture") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - feature.activateProfile(profile) - let id = try #require(feature.selectedSQLTabID) - feature.updateSQL("select 1;", in: id) - let task = Task { await feature.runSQL(in: id) } - #expect(await runner.entered.waitUntilOpen()) - let running = feature.selectedSQLTab?.sql ?? "" - #expect(!running.isEmpty) - feature.updateSQL(editedStatement ? running.replacingOccurrences(of: "1", with: "2") : running + "\nselect 2;", in: id) - runner.release.open() - await task.value - let tab = try #require(feature.selectedSQLTab) - let record = try #require(tab.statementExecutions.last) - #expect(record.sql == "select\n 1") - #expect(record.sourceSQL == tab.sql) - #expect((record.sourceRange == nil) == editedStatement) - } - - @Test(arguments: [false, true]) - func querySyntaxColorsProtectQuotedIdentifiersAndLiterals(dark: Bool) throws { - let source = #"select * from "from" where n>=1.25e-2 and value=E'it\'s SELECT -- text' and body=$tag$WHERE 12$tag$ -- SELECT 9"# - let storage = NSTextStorage(string: source) - let appearance = NSAppearance(named: dark ? .darkAqua : .aqua)! - appearance.performAsCurrentDrawingAppearance { - SQLSyntaxHighlighter.apply(to: storage, font: LitheTheme.editorFont(size: 13), isDark: dark) - } - func expectColor(_ text: String, _ color: Color) throws { - let range = (source as NSString).range(of: text) - let actual = try #require(storage.attribute(.foregroundColor, at: range.location, effectiveRange: nil) as? NSColor) - var expected = NSColor.black - var resolved = NSColor.black - appearance.performAsCurrentDrawingAppearance { - expected = NSColor(color).usingColorSpace(.sRGB)! - resolved = actual.usingColorSpace(.sRGB)! - } - #expect(resolved == expected) - } - try expectColor("select", LitheTheme.DatabaseConsole.keyword) - try expectColor("*", LitheTheme.DatabaseConsole.keyword) - try expectColor(">=", LitheTheme.DatabaseConsole.keyword) - try expectColor("1.25e-2", LitheTheme.DatabaseConsole.number) - try expectColor(#""from""#, LitheTheme.DatabaseGrid.text) - try expectColor(#"SELECT -- text'"#, LitheTheme.DatabaseConsole.string) - try expectColor("WHERE 12", LitheTheme.DatabaseConsole.string) - try expectColor("-- SELECT 9", LitheTheme.DatabaseConsole.comment) - #expect(storage.string == source) - } - - @Test func executionFormatsOnlyTheSelectedSQLWithNativeRange() async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Format fixture", kind: .sqlite, database: "fixture") - try store.save([profile]) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: QueryOutputRunner(fails: false), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - feature.activateProfile(profile) - let id = try #require(feature.selectedSQLTabID) - let statement = " select '😀' from customers where id>=1; " - let original = statement + "\n" + statement - let range = (original as NSString).range(of: statement, options: .backwards) - feature.updateSQL(original, in: id) - await feature.runSQL(in: id, scope: .selection(statement), selectionRange: range) - let tab = try #require(feature.selectedSQLTab) - #expect(tab.sql == statement + "\n select\n '😀' from customers\nwhere id>=1; ") - let record = try #require(tab.statementExecutions.last) - #expect(record.sql == "select\n '😀' from customers\nwhere id>=1") - #expect((tab.sql as NSString).substring(with: try #require(record.sourceRange)) == record.sql + ";") - // A string-only caller cannot replace the wrong copy of repeated text. - feature.updateSQL(original, in: id) - await feature.runSQL(in: id, scope: .selection(statement)) - #expect(feature.selectedSQLTab?.sql == original) - #expect(feature.selectedSQLTab?.statementExecutions.last?.sourceRange == nil) - feature.updateSQL("select * from customers where id>=1;", in: id) - await feature.runSQL(in: id) - #expect(feature.selectedSQLTab?.sql == "select\n * from customers\nwhere id>=1;") - } - - @Test func historyPreviewUsesReadOnlyCompactNativeGutter() throws { - let host = NSHostingView(rootView: SQLSyntaxEditor(text: .constant("SELECT\n 1;"), completions: [], onRun: {}, onSelectionChange: { _ in }, isEditable: false) - .environmentObject(AppSettings(store: DatabaseLifecyclePreferences()))) - host.frame = NSRect(x: 0, y: 0, width: 300, height: 300) - host.layoutSubtreeIfNeeded() - func editor(_ view: NSView) -> SQLTextView? { - if let text = view as? SQLTextView { return text } - return view.subviews.lazy.compactMap { editor($0) }.first - } - let text = try #require(editor(host)) - let gutter = try #require(text.gutter) - #expect(!text.isEditable) - #expect(text.isSelectable) - #expect(gutter.bounds.width == gutter.consoleLineNumberOnlyWidth) - #expect(gutter.bounds.width < EditorLayoutMetrics.standardGutterWidth) - } - - @Test(arguments: ["stay", "otherTable", "returnToTable", "otherConnection", "returnToConnection"], [false, true]) - func delayedApplyLocksDraftsAndPreservesNavigation(destination: String, fails: Bool) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Apply fixture", kind: .mongodb, database: "fixture") - let otherProfile = DatabaseProfile(name: "Other apply fixture", kind: .mongodb, database: "other_fixture") - try store.save([profile, otherProfile]) - let runner = GatedDatabaseApplyRunner(fails: fails) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - feature.activateProfile(profile) - await feature.openTable("customers") - let inserted: [DatabaseRow] = [["name": .string("A")]] - feature.tableInsertedRows = inserted - feature.tableCellDrafts = [DatabaseCellKey(row: 0, column: "name"): .string("updated")] - feature.tableDeletedRows = [1] - let submittedDrafts = feature.tableCellDrafts - let task = Task { - await feature.apply(drafts: submittedDrafts.map { DatabaseCellDraft(rowIndex: $0.key.row, column: $0.key.column, value: $0.value) }, insertedRows: inserted, deletedIndexes: [1]) - } - #expect(await runner.entered.waitUntilOpen()) - #expect(feature.isApplyingTableEdits) - // These are the shared mutation paths used by typing, TSV paste, batch edits and delete. - feature.tableInsertedRows[0]["name"] = .string("B") - feature.tableCellDrafts.removeAll() - feature.tableDeletedRows.removeAll() - #expect(feature.tableInsertedRows == inserted) - #expect(feature.tableCellDrafts == submittedDrafts) - #expect(feature.tableDeletedRows == [1]) - #expect(await feature.apply(drafts: [], insertedRows: inserted, deletedIndexes: []) == false) - await feature.loadPage(filters: [], sort: [], offset: 200) - await feature.refreshTables() - #expect(feature.currentOffset == 0) - let otherCell = DatabaseCellKey(row: 0, column: "name") - if destination == "otherConnection" || destination == "returnToConnection" { - await feature.select(otherProfile) - #expect(feature.tables.contains("customers")) - await feature.openTable("customers") - #expect(!feature.isApplyingTableEdits) - #expect(!feature.isLoading) - #expect(feature.hasActiveModuleWork) // The background connection still owns its write. - feature.tableCellDrafts[otherCell] = .string("other connection draft") - #expect(feature.tableCellDrafts[otherCell] == .string("other connection draft")) - if destination == "returnToConnection" { - await feature.select(profile) - #expect(feature.isApplyingTableEdits) - #expect(feature.isLoading) // The write owns the lock; returning queues its refresh. - } - } else if destination != "stay" { - await feature.openTable("orders") - if destination == "returnToTable" { await feature.openTable("customers") } - } - let pagesBeforeCompletion = runner.pageRequests - runner.release.open() - #expect(await task.value == !fails) - #expect(!feature.isApplyingTableEdits) - #expect(!feature.isLoading) - #expect(feature.selectedTable == (destination == "otherTable" ? "orders" : "customers")) - #expect(feature.tableApplyFailed == (fails && destination == "stay")) - if destination == "otherConnection" { - #expect(feature.selectedProfileID == otherProfile.id) - #expect(feature.tableCellDrafts[otherCell] == .string("other connection draft")) - #expect(!feature.hasActiveModuleWork) - } else if destination == "stay", fails { - #expect(feature.tableInsertedRows == inserted) - } else { - #expect(!feature.hasTableEdits) - #expect(await feature.apply(drafts: [], insertedRows: feature.tableInsertedRows, deletedIndexes: []) == true) - } - #expect(runner.applyRequests == 1) - #expect(runner.pageRequests == pagesBeforeCompletion + ((destination == "stay" && !fails) || ["otherTable", "returnToTable", "returnToConnection"].contains(destination) ? 1 : 0)) - } - - @Test(arguments: [false, true], [false, true]) - func sidebarTableSelectionDuringApplyLoadsLastSelection(returnToFirst: Bool, fails: Bool) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Sidebar apply fixture", kind: .mongodb, database: "fixture") - try store.save([profile]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let runner = GatedDatabaseApplyRunner(fails: fails) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - feature.activateProfile(profile) - await feature.openTable("customers") - let task = Task { await feature.apply(drafts: [], insertedRows: [["name": .string("A")]], deletedIndexes: []) } - #expect(await runner.entered.waitUntilOpen()) - // Use the same synchronous navigation + scheduled I/O entry as the sidebar click. - model.selectDatabaseWorkspace(profileID: profile.id, table: "orders") - if returnToFirst { model.selectDatabaseWorkspace(profileID: profile.id, table: "customers") } - let expectedTable = returnToFirst ? "customers" : "orders" - #expect(feature.selectedTable == expectedTable) - #expect(feature.rows.isEmpty) // Never display the old page under the newly selected table. - #expect(feature.isLoading && feature.isApplyingTableEdits) - runner.release.open() - #expect(await task.value == !fails) - #expect(await awaitChange(on: feature, until: { !feature.isLoading && !feature.rows.isEmpty })) - #expect(feature.selectedTable == expectedTable) - #expect(feature.rows.first?["name"] == .string(expectedTable)) - #expect(!feature.isApplyingTableEdits) - #expect(runner.applyRequests == 1) - await model.shutdownProjectSession() - } - - @Test(arguments: ["execute", "query", "batch", "partialBatch"], [(false, false), (false, true), (true, false), (true, true)]) - func sqlExecutionLockSurvivesConnectionNavigation(method: String, scenario: (Bool, Bool)) async throws { - let (returnBeforeCompletion, fails) = scenario - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "SQL A", kind: .sqlite, database: "first") - let second = DatabaseProfile(name: "SQL B", kind: .sqlite, database: "second") - try store.save([first, second]) - let recoveryRoot = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-navigation-sql-\(UUID())") - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - let isBatch = method == "batch" || method == "partialBatch" - let sentBeforeNavigation = method == "partialBatch" ? 2 : 1 - let runner = GatedSQLNavigationRunner(method: isBatch ? "execute" : method, fails: fails, gatedRequest: sentBeforeNavigation) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), - connectionStore: store, recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot), fileStorage: MacFileStorage() - ) - defer { runner.release.open(); feature.prepareForModuleRelease() } - await feature.select(first) - let statementCount = isBatch ? sentBeforeNavigation + 1 : 1 - let sql = method == "query" ? "SELECT 1" : (1...statementCount).map { "INSERT INTO items VALUES (\($0));" }.joined(separator: " ") - feature.addSQLTab(sql: sql) - let tabID = try #require(feature.selectedSQLTabID) - let task = Task { await feature.runSQL(in: tabID) } - #expect(await runner.entered.waitUntilOpen()) - await feature.select(second) - #expect(!feature.isLoading) - #expect(feature.hasActiveModuleWork) - #expect(feature.sqlTab(tabID, profileID: first.id)?.isRunning == true) - if returnBeforeCompletion { - await feature.select(first) - #expect(feature.selectedSQLTab?.isRunning == true) - await feature.runSQL(in: tabID) - #expect(runner.requests == sentBeforeNavigation) - } - runner.release.open() - await task.value - #expect(!feature.hasActiveModuleWork) - #expect(feature.sqlTab(tabID, profileID: first.id)?.isRunning == false) - if !returnBeforeCompletion { await feature.select(first) } - #expect(feature.selectedSQLTab?.isRunning == false) - #expect(feature.selectedSQLTab?.result == nil) // Stale completion never repopulates a restored page. - #expect(runner.requests == sentBeforeNavigation) - // Durable logs belong to A even when the UI is on B or A has a new generation. - let history = store.loadSQLHistory() - #expect(history.count == 1 && history.first?.profileID == first.id) - let persisted = MacDatabaseRecoveryStore(rootURL: recoveryRoot) - let events = persisted.executionEvents() - #expect(events.count == 1 && events.first?.profileID == first.id) - #expect(events.first?.status == (fails ? .failed : (isBatch ? .cancelled : .succeeded))) - let completedWrites = method == "query" ? 0 : sentBeforeNavigation - (fails ? 1 : 0) - let affected: UInt64? = completedWrites > 0 ? UInt64(completedWrites) : nil - #expect(events.first?.rowsAffected == affected) - #expect(history.first?.rowsAffected == affected) - let audit = persisted.auditEntries().filter { $0.action == "sql" } - #expect(audit.count == 1 && audit.first?.profileID == first.id) - #expect(audit.first?.succeeded == (!fails && !isBatch)) - #expect(audit.first?.rowsAffected == affected) - if isBatch, !fails { - let progress = "\(sentBeforeNavigation) of \(statementCount)" - #expect(events.first?.errorMessage?.contains(progress) == true) - #expect(audit.first?.summary.contains(progress) == true) - } - if method == "partialBatch", fails { - #expect(events.first?.errorMessage?.contains("Statement 2 failed") == true) - } - await feature.runSQL(in: tabID) - #expect(runner.requests == (sentBeforeNavigation + (fails ? 1 : statementCount))) // Only a new explicit run sends the next request. - #expect((feature.selectedSQLTab?.errorMessage != nil) == fails) - #expect(!feature.hasActiveModuleWork) - } - - @Test(arguments: [false, true], [false, true]) - func reselectingFilteredTableDiscardsDraftsBeforeReplacingKeys(sorted: Bool, fails: Bool) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Reload fixture", kind: .mongodb, database: "fixture") - try store.save([profile]) - let runner = ReloadedTableKeysRunner(fails: fails) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - await feature.select(profile) - await feature.openTable("customers") - await feature.loadPage(filters: sorted ? [] : [DatabaseFilter(column: "id", value: .integer(2))], - sort: sorted ? [DatabaseSort(column: "id", descending: true)] : [], offset: 0) - #expect(feature.rows.first?["id"] == .integer(2)) - feature.tableCellDrafts[DatabaseCellKey(row: 0, column: "name")] = .string("edited id 2") - feature.selectTable("customers") - await feature.openTable("customers") - #expect(!feature.hasTableEdits) - #expect(!feature.isLoading) - #expect((feature.errorMessage != nil) == fails) - #expect(feature.rows.first?["id"] == (fails ? nil : .integer(1))) - let drafts = feature.tableCellDrafts.map { DatabaseCellDraft(rowIndex: $0.key.row, column: $0.key.column, value: $0.value) } - #expect(await feature.apply(drafts: drafts, insertedRows: feature.tableInsertedRows, deletedIndexes: feature.tableDeletedRows)) - #expect(runner.applyRequests == 0) // Old index 0 must never become an UPDATE/DELETE for id 1. - } - - @Test(arguments: ["stay", "otherTable", "otherConnection", "newerPage"], [false, true]) - func delayedPageOnlyClearsItsOwnCurrentDrafts(destination: String, fails: Bool) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "Page fixture", kind: .mongodb, database: "first") - let second = DatabaseProfile(name: "Other page fixture", kind: .mongodb, database: "second") - try store.save([first, second]) - let runner = GatedDatabasePageRunner(fails: fails) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.release.open(); feature.prepareForModuleRelease() } - await feature.select(first) - await feature.openTable("customers") - let cell = DatabaseCellKey(row: 0, column: "name") - feature.tableCellDrafts[cell] = .string("old page draft") - let task = Task { await feature.loadPage(filters: [], sort: [], offset: 200) } - #expect(await runner.entered.waitUntilOpen()) - switch destination { - case "otherTable": await feature.openTable("orders") - case "otherConnection": - await feature.select(second) - await feature.openTable("customers") - case "newerPage": await feature.loadPage(filters: [], sort: [], offset: 400) - default: break - } - feature.tableCellDrafts[cell] = .string("current page draft") - feature.tableInsertedRows = [["name": .string("new row")]] - feature.tableDeletedRows = [1] - feature.tableApplyFailed = true - let rows = feature.rows - let offset = feature.currentOffset - let profileID = feature.selectedProfileID - let table = feature.selectedTable - runner.release.open() - await task.value - #expect(feature.selectedProfileID == profileID) - #expect(feature.selectedTable == table) - #expect(!feature.isLoading) - if destination == "stay" { - #expect(!feature.hasTableEdits) - #expect(!feature.tableApplyFailed) - #expect((feature.errorMessage != nil) == fails) - } else { - #expect(feature.tableCellDrafts[cell] == .string("current page draft")) - #expect(feature.tableInsertedRows == [["name": .string("new row")]]) - #expect(feature.tableDeletedRows == [1]) - #expect(feature.tableApplyFailed) - #expect(feature.rows == rows) - #expect(feature.currentOffset == offset) - #expect(feature.errorMessage == nil) - } - await feature.loadPage(filters: [], sort: [], offset: 0) - #expect(!feature.hasTableEdits) - #expect(!feature.tableApplyFailed) - #expect(feature.rows.count == 2) - } - - @Test(arguments: ["current", "background", "last"]) - func removingConnectionClosesOwnedTabsAndFallsBack(destination: String) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "First", kind: .sqlite, database: "first") - let second = DatabaseProfile(name: "Second", kind: .sqlite, database: "second") - try store.save(destination == "last" ? [first] : [first, second]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let recoveryRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: EmptyDatabaseWorkspaceRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store, recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot)) - defer { feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - await feature.select(first) - model.openDatabaseWorkspace() - model.openDatabaseQuery(profileID: first.id, sql: "SELECT 1") - if destination != "last" { - await feature.select(second) - model.openDatabaseWorkspace() - } - let removed = destination == "current" ? second : first - model.removeDatabaseConnection(removed) - #expect(!feature.profiles.contains(where: { $0.id == removed.id })) - #expect(!model.editorTabOrderFeature.items.contains { item in - switch item { - case .database(let id): return id == removed.id - case .databaseQuery(let id, _): return id == removed.id - default: return false - } - }) - if destination == "last" { - #expect(!model.editorTabOrderFeature.databaseSelected) - #expect(!model.isDatabaseWorkspaceSelected) - #expect(model.editorTabOrderFeature.items.isEmpty) - } else { - #expect(model.isDatabaseWorkspaceSelected) - #expect(model.editorTabOrderFeature.databaseProfileSelected == (destination == "current" ? first.id : second.id)) - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - } - await model.shutdownProjectSession() - } - - @Test - func backgroundConnectionManagementPreservesEditorSelection() async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "First", kind: .sqlite, database: "first") - let second = DatabaseProfile(name: "Second", kind: .sqlite, database: "second") - try store.save([first, second]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: TableEditsWorkspaceRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - await feature.select(first) - model.openDatabaseQuery(profileID: first.id, sql: "SELECT 1") - let active = model.activeDatabaseEditorTab - let tabs = model.editorTabItems - await feature.refresh(second) - #expect(feature.selectedProfileID == first.id) - #expect(await feature.add(DatabaseProfile(name: "Third", kind: .sqlite, database: "third"), password: "")) - #expect(feature.selectedProfileID == first.id) - #expect(model.activeDatabaseEditorTab == active) - // Even a feature-level selection cannot manufacture or select an editor tab. - await feature.select(second) - #expect(model.activeDatabaseEditorTab == active) - #expect(model.editorTabItems == tabs) - #expect(model.isDatabaseWorkspaceSelected) - await model.shutdownProjectSession() - } - - @Test(arguments: ["success", "failure", "cancel", "release"]) - func draftConnectionTestDoesNotSaveOrNavigate(outcome: String) async throws { - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseFolderSecrets()) - let saved = DatabaseProfile(name: "Saved", kind: .mysql) - try store.save([saved]) - try store.savePassword("saved-secret", for: saved.id) - let runner = GatedBackgroundSaveRunner(operation: "idle", fails: outcome == "failure") - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, - executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { runner.saveRelease.open(); feature.prepareForModuleRelease() } - feature.activateProfile(saved) - await feature.refreshTables() - let status = feature.connectionStatus(for: saved) - var draft = saved - draft.name = "Unsaved" - let probe = Task { try await feature.testConnection(draft, password: "draft-secret") } - defer { probe.cancel() } - #expect(await runner.saveEntered.waitUntilOpen()) - #expect(feature.connectionStatus(for: saved) == status) - #expect(!feature.isLoading) - if outcome == "cancel" { probe.cancel() } - if outcome == "release" { feature.prepareForModuleRelease() } - runner.saveRelease.open() - do { - try await probe.value - #expect(outcome == "success") - } catch { - #expect(outcome != "success") - if outcome == "cancel" || outcome == "release" { #expect(error is CancellationError) } - } - #expect(store.load() == [saved]) - #expect(store.password(for: saved.id) == "saved-secret") - #expect(feature.connectionStatus(for: saved) == status) - if outcome != "release" { #expect(feature.selectedProfileID == saved.id) } - #expect(feature.errorMessage == nil) - } - - @Test(arguments: ["idle", "refresh", "open", "page"], [(false, false), (false, true), (true, false), (true, true)]) - func backgroundSavePreservesCurrentLoading(operation: String, scenario: (Bool, Bool)) async throws { - let (saveFirst, fails) = scenario - let store = DatabaseConnectionStore(store: DatabaseLifecyclePreferences(), secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "First", kind: .sqlite, database: "first") - let second = DatabaseProfile(name: "Second", kind: .sqlite, database: "second") - try store.save([first, second]) - let runner = GatedBackgroundSaveRunner(operation: operation, fails: fails) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), - connectionStore: store - ) - defer { runner.loadRelease.open(); runner.saveRelease.open(); feature.prepareForModuleRelease() } - feature.activateProfile(first) - if operation == "idle" { await feature.refreshTables() } - if operation == "page" { await feature.openTable("customers") } - let load = Task { @MainActor in - switch operation { - case "refresh": await feature.refreshTables() - case "open": await feature.openTable("customers") - case "page": await feature.loadPage(filters: [], sort: [], offset: 200) - default: break - } - } - if operation != "idle" { #expect(await runner.loadEntered.waitUntilOpen()) } - else { await load.value } - #expect(feature.isLoading == (operation != "idle")) - let save = Task { @MainActor in await feature.update(second, password: nil) } - #expect(await runner.saveEntered.waitUntilOpen()) - #expect(feature.connectionStatus(for: second) == .connecting) - #expect(feature.isLoading == (operation != "idle")) - // Control both completion orders without sleeps; neither B's start nor finish owns A's loading. - if saveFirst { - runner.saveRelease.open() - #expect(await save.value == !fails) - #expect(feature.isLoading == (operation != "idle")) - runner.loadRelease.open() - await load.value - } else { - runner.loadRelease.open() - await load.value - #expect(!feature.isLoading) - runner.saveRelease.open() - #expect(await save.value == !fails) - } - #expect(!feature.isLoading) - #expect(feature.selectedProfileID == first.id) - #expect(feature.connectionStatus(for: second) == (fails ? .failed : .connected)) - } - - @Test(arguments: [false, true], [DatabaseKind.sqlite, .redis]) - func retainedViewsRenderAfterProjectSessionShutdown(hasSelectedConnection: Bool, kind: DatabaseKind) async throws { - let preferences = DatabaseLifecyclePreferences() - let connectionStore = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Lifecycle fixture", kind: kind, path: "/fixture/lifecycle.sqlite") - try connectionStore.save([profile]) - let settings = AppSettings(store: preferences) - let services = MacServiceContainer( - store: preferences, settings: settings, moduleLaunchMode: .safeMode - ).services - let model = AppModel(settings: settings, services: services) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: DatabaseLifecycleProcessRunner(), executableURL: nil), - connectionStore: connectionStore - ) - defer { feature.prepareForModuleRelease() } - #expect(DatabaseWorkspaceTabTitle(feature: feature, profileID: feature.selectedProfileID).title == "SQL History") - feature.selectedProfileID = profile.id - for section in DatabaseWorkspaceSection.allCases { - feature.workspaceSection = section - #expect(DatabaseWorkspaceTabTitle(feature: feature, profileID: profile.id).title == profile.name) - } - feature.selectedProfileID = nil - if hasSelectedConnection { - feature.selectedProfileID = profile.id - feature.workspaceSection = .sql - } - let databaseTab = EditorTabItem.database(feature.selectedProfileID) - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - model.workbenchFeature.setVisibility(.database, isVisible: true) - #expect(model.selectedSidebar == .project) - #expect(!model.editorTabItems.contains(databaseTab)) - model.openDatabaseWorkspace() - model.openDatabaseWorkspace() - #expect(model.editorTabItems.filter { $0 == databaseTab }.count == 1) - #expect(model.isDatabaseWorkspaceSelected) - model.workbenchFeature.setVisibility(.database, isVisible: false) - #expect(model.isDatabaseWorkspaceSelected) - #expect(model.requestCloseActiveWorkbenchItem()) - #expect(!model.editorTabItems.contains(databaseTab)) - model.openDatabaseWorkspace() - feature.addSQLTab(sql: "SELECT 'retained draft'") - let draftID = feature.selectedSQLTabID - model.openDatabaseWorkspace(overview: true) - model.openDatabaseWorkspace(overview: true) - #expect(model.editorTabItems.filter { $0 == .databaseOverview }.count == 1) - #expect(model.editorTabItems.contains(databaseTab)) - #expect(model.editorTabOrderFeature.databaseOverviewSelected) - model.editorTabOrderFeature.reconcileDocuments(orderedIDs: []) - #expect(model.editorTabItems.contains(.databaseOverview)) - #expect(model.requestCloseActiveWorkbenchItem()) - #expect(!model.editorTabItems.contains(.databaseOverview)) - #expect(model.editorTabItems.contains(databaseTab)) - #expect(model.isDatabaseWorkspaceSelected) - #expect(!model.editorTabOrderFeature.databaseOverviewSelected) - #expect(feature.selectedSQLTabID == draftID) - #expect(feature.selectedSQLTab?.sql == "SELECT 'retained draft'") - model.openDatabaseWorkspace(overview: true) - model.closeDatabaseWorkspace(overview: false) - #expect(model.isDatabaseWorkspaceSelected) - #expect(model.editorTabOrderFeature.databaseOverviewSelected) - model.openDatabaseWorkspace() - - func content(revision: Int) -> AnyView { - AnyView(HStack(spacing: 0) { - DatabaseSidebarView().frame(width: 280) - VStack { - if feature.workspaceSection == .sql { DatabaseSQLWorkspaceView() } - else { DatabaseWorkspaceView() } - DatabaseOverviewView() - }.frame(width: 760, height: 550) - } - .id(revision) - .environmentObject(feature) - .environmentObject(model) - .environmentObject(settings)) - } - let host = NSHostingView(rootView: content(revision: 0)) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 1040, height: 550), - styleMask: [.borderless], backing: .buffered, defer: false - ) - window.isReleasedWhenClosed = false - defer { window.contentView = nil; window.close() } - window.contentView = host - host.layoutSubtreeIfNeeded() - #expect(host.fittingSize.width > 0) - #expect(model.databaseFeatureIfActive === feature) - // The same native host must survive all four page renderers, including - // their empty states, without starting a database process. - if hasSelectedConnection { - for section in DatabaseWorkspaceSection.allCases { - feature.workspaceSection = section - host.rootView = content(revision: 2) - host.layoutSubtreeIfNeeded() - #expect(host.fittingSize.width > 0) - } - } - - // Keep the native host alive across cache teardown, then force another - // body evaluation. A parent sidebar guard cannot protect this ordering. - feature.prepareForModuleRelease() - await model.shutdownProjectSession() - #expect(model.databaseFeatureIfActive == nil) - #expect(!model.editorTabItems.contains(databaseTab)) - #expect(!model.editorTabItems.contains(.databaseOverview)) - host.rootView = content(revision: 1) - host.layoutSubtreeIfNeeded() - #expect(host.fittingSize.width > 0) - - // Connection and schema sheets can also outlive the workspace subtree. - host.rootView = AnyView(VStack { - DatabaseConnectionEditor(isPresented: .constant(true)) - DatabaseSchemaDiffView() - }.environmentObject(feature) - .environmentObject(model) - .environmentObject(settings)) - host.layoutSubtreeIfNeeded() - #expect(host.fittingSize.width > 0) - } - - @Test(arguments: [false, true]) - func consoleUsesEditorTypographyAndCoversTheBlankViewport(dark: Bool) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Console fixture", kind: .sqlite, path: "/fixture/console.sqlite") - try store.save([profile]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: DatabaseLifecycleProcessRunner(), executableURL: nil), connectionStore: store) - defer { feature.prepareForModuleRelease() } - feature.activateProfile(profile) - feature.addSQLTab(sql: "SELECT 12, '-- value' -- comment") - let host = NSHostingView(rootView: VStack(spacing: 0) { - LitheTheme.sidebar.frame(height: 20) - DatabaseSQLWorkspaceView() - } - .environmentObject(feature).environmentObject(model).environmentObject(settings) - .environment(\.colorScheme, dark ? .dark : .light)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 740, height: 700), styleMask: [.borderless], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) - defer { window.contentView = nil; window.close() } - window.contentView = host - host.layoutSubtreeIfNeeded() - func editor(in view: NSView) -> NSTextView? { - if let text = view as? NSTextView { return text } - return view.subviews.lazy.compactMap { editor(in: $0) }.first - } - let textView = try #require(editor(in: host)) - let scroll = try #require(textView.enclosingScrollView) - let gutter = try #require(scroll.superview?.subviews.compactMap { $0 as? LineNumberGutterView }.first) - func pixel(_ view: NSView, at point: NSPoint) throws -> NSColor { - let rect = NSRect(origin: point, size: NSSize(width: 1, height: 1)) - let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: rect)) - view.cacheDisplay(in: rect, to: bitmap) - return try #require(bitmap.colorAt(x: 0, y: 0)?.usingColorSpace(.deviceRGB)) - } - func expectSameRGB(_ first: NSColor, _ second: NSColor) { - for (a, b) in zip([first.redComponent, first.greenComponent, first.blueComponent], [second.redComponent, second.greenComponent, second.blueComponent]) { - #expect(abs(a - b) < 0.015) - } - } - // Sample the outer edge: the current line must cover the whole gutter, not leave an inset. - let line = LitheTextViewportLayout.consoleCaretLineRect(in: textView) - expectSameRGB(try pixel(gutter, at: NSPoint(x: 0, y: line.midY)), try pixel(textView, at: NSPoint(x: 500, y: line.midY))) - expectSameRGB(try pixel(gutter, at: NSPoint(x: 0, y: 100)), try pixel(textView, at: NSPoint(x: 500, y: 100))) - // New UI positions the separator 3 logical pixels before the content origin. - let separator = try pixel(gutter, at: NSPoint(x: gutter.bounds.width - 3, y: 100)) - let editorBackground = try pixel(textView, at: NSPoint(x: 500, y: 100)) - // Detect the line position in the rendered bitmap; color tokens have separate appearance tests. - #expect(abs(separator.redComponent - editorBackground.redComponent) > 0.04) - for x in [gutter.bounds.width - 2, gutter.bounds.width - 1] { - expectSameRGB(try pixel(gutter, at: NSPoint(x: x, y: 100)), try pixel(textView, at: NSPoint(x: 500, y: 100))) - } - #expect(textView.textLayoutManager == nil) // Console and its shared gutter use TextKit 1 from creation. - scroll.layoutSubtreeIfNeeded() - // Like the main editor, native views leave the structural background to the SwiftUI pane. - #expect(!textView.drawsBackground) - #expect(!scroll.drawsBackground) - #expect(!scroll.contentView.drawsBackground) - #expect(!gutter.isOpaque) - // Compare the composed pixels, including native children, with a sidebar surface in this window. - let sidebarPoint = NSPoint(x: 500, y: host.isFlipped ? 10 : host.bounds.maxY - 10) - let canvasPoint = host.convert(NSPoint(x: 500, y: 100), from: textView) - #expect(try pixel(host, at: canvasPoint) == pixel(host, at: sidebarPoint)) - #expect(textView.font?.pointSize == 13) - #expect(textView.defaultParagraphStyle?.lineHeightMultiple == 1.2) - #expect(textView.textContainerOrigin.x + (textView.textContainer?.lineFragmentPadding ?? 0) == 0) - let storage = try #require(textView.textStorage) - #expect((storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 13) - #expect(!(textView.font?.fontDescriptor.symbolicTraits.contains(.bold) ?? true)) - let completions = textView.delegate?.textView?(textView, completions: [], forPartialWordRange: NSRange(location: 0, length: 3), indexOfSelectedItem: nil) - #expect(completions == ["SELECT"]) - let literal = (storage.string as NSString).range(of: "'-- value'") - let quoteColor = storage.attribute(.foregroundColor, at: literal.location, effectiveRange: nil) as? NSColor - let markerColor = storage.attribute(.foregroundColor, at: literal.location + 1, effectiveRange: nil) as? NSColor - #expect(quoteColor == markerColor) - #expect(textView.frame.height >= scroll.contentSize.height) - // Empty space below a short query stays editable when the console is taller than its old 240pt document. - #expect(scroll.contentSize.height > 240) - let hoverPoint = textView.convert(NSPoint(x: 4, y: scroll.documentVisibleRect.maxY - 4), to: nil) - let event = try #require(NSEvent.mouseEvent(with: .mouseMoved, location: hoverPoint, modifierFlags: [], timestamp: 0, - windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)) - textView.cursorUpdate(with: event) - #expect(NSCursor.current == NSCursor.iBeam) - let originalCursor = NSCursor.current - defer { originalCursor.set() } - textView.updateTrackingAreas() - #expect(textView.trackingAreas.contains { $0.owner === textView && $0.options.contains([.inVisibleRect, .cursorUpdate, .mouseEnteredAndExited, .mouseMoved, .activeAlways]) }) - // Hover must work before focus, including the blank area below the query. - window.makeFirstResponder(nil) - NSCursor.arrow.set() - textView.mouseEntered(with: event) - #expect(NSCursor.current == NSCursor.iBeam) - NSCursor.arrow.set() - textView.mouseMoved(with: event) - #expect(NSCursor.current == NSCursor.iBeam) - let exit = try #require(NSEvent.enterExitEvent(with: .mouseExited, location: hoverPoint, modifierFlags: [], timestamp: 0, - windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, userData: nil)) - NSCursor.resizeLeftRight.set() - textView.mouseExited(with: exit) - #expect(NSCursor.current == NSCursor.resizeLeftRight) - #expect(textView.acceptsFirstMouse(for: event)) - // Native insertion hit testing clamps blank-space clicks to EOF without adding lines. - for x in [CGFloat(4), scroll.contentSize.width - 4] { - textView.setSelectedRange(NSRange(location: 0, length: 3)) - let point = NSPoint(x: x, y: scroll.contentSize.height - 20) - #expect(textView.hitTest(point) === textView) - #expect(textView.characterIndexForInsertion(at: point) == storage.length) - } - #expect(window.makeFirstResponder(textView)) - textView.setSelectedRange(NSRange(location: storage.length, length: 0)) - textView.insertText("\nSELECT 2", replacementRange: textView.selectedRange()) - #expect(feature.selectedSQLTab?.sql.hasSuffix("\nSELECT 2") == true) - #expect((textView.textStorage?.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)?.lineHeightMultiple == 1.2) - textView.insertText("\n", replacementRange: textView.selectedRange()) - #expect(gutter.currentConsoleLine == 3) - let emptyFinalLine = LitheTextViewportLayout.consoleCaretLineRect(in: textView) - #expect(emptyFinalLine.minY > line.minY) - expectSameRGB(try pixel(gutter, at: NSPoint(x: 0, y: emptyFinalLine.midY)), try pixel(textView, at: NSPoint(x: 500, y: emptyFinalLine.midY))) - // AppKit suppresses selection notifications during mouse tracking. Chrome must already follow the caret. - textView.setSelectedRange(NSRange(location: 0, length: 0), affinity: .downstream, stillSelecting: true) - #expect(gutter.currentConsoleLine == 1) - expectSameRGB(try pixel(gutter, at: NSPoint(x: 0, y: line.midY)), try pixel(textView, at: NSPoint(x: 500, y: line.midY))) - textView.setSelectedRanges([NSValue(range: NSRange(location: storage.length, length: 0))], affinity: .downstream, stillSelecting: true) - #expect(gutter.currentConsoleLine == 3) - textView.setSelectedRange(NSRange(location: storage.length, length: 0), affinity: .downstream, stillSelecting: false) - func gutterImage(at y: CGFloat) throws -> Data { - let rect = NSRect(x: 0, y: y, width: gutter.bounds.width - 1, height: 32) - let bitmap = try #require(gutter.bitmapImageRepForCachingDisplay(in: rect)) - gutter.cacheDisplay(in: rect, to: bitmap) - return try #require(bitmap.representation(using: .png, properties: [:])) - } - // The same line number must remain at the same baseline when the EOF fragment gains a glyph. - let emptyNumber = try gutterImage(at: emptyFinalLine.minY) - textView.insertText("2", replacementRange: textView.selectedRange()) - #expect(try gutterImage(at: emptyFinalLine.minY) == emptyNumber) - // An entirely empty document uses a separate native layout path; protect it too. - textView.setSelectedRange(NSRange(location: 0, length: storage.length)) - textView.insertText("", replacementRange: textView.selectedRange()) - #expect(gutter.currentConsoleLine == 1) - let firstNumber = try gutterImage(at: 0) - textView.insertText("1", replacementRange: textView.selectedRange()) - #expect(try gutterImage(at: 0) == firstNumber) - // Community's 4 + 4 + 14pt areas right-align a one-digit number before x = 22. - let numberRect = NSRect(x: 0, y: 0, width: gutter.bounds.width - 3, - height: floor(LitheTextViewportLayout.consoleCaretLineRect(in: textView).height)) - let numberBitmap = try #require(gutter.bitmapImageRepForCachingDisplay(in: numberRect)) - gutter.cacheDisplay(in: numberRect, to: numberBitmap) - let background = try #require(numberBitmap.colorAt(x: 0, y: 0)?.usingColorSpace(.deviceRGB)) - var inkColumns = Set() - for y in 0.. 0.05 { inkColumns.insert(x) } - } - } - let rightmostInk = try #require(inkColumns.max()) - #expect(CGFloat(rightmostInk) * numberRect.width / CGFloat(numberBitmap.pixelsWide) < 22) - window.setContentSize(NSSize(width: 500, height: 900)) - host.layoutSubtreeIfNeeded() - scroll.layoutSubtreeIfNeeded() - #expect(textView.frame.height >= scroll.contentSize.height) - await model.shutdownProjectSession() - } - - @Test - func sidebarRightClickPreservesAllUnappliedTableEdits() async throws { - let app = NSApplication.shared - let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") - let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false - app.accessibilitySetValue(true, forAttribute: attribute) - defer { app.accessibilitySetValue(previous, forAttribute: attribute) } - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Context menu fixture", kind: .mysql, database: "first_db") - try store.save([profile]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, - services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: TableEditsWorkspaceRunner(), - executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - feature.activateProfile(profile) - await feature.refreshTables() - await feature.openTable("customers") - let host = NSHostingView(rootView: DatabaseSidebarView().frame(width: 340, height: 600) - .environmentObject(feature).environmentObject(model).environmentObject(settings)) - let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 340, height: 600), - styleMask: [.titled], backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.contentView = host - window.makeKeyAndOrderFront(nil) - defer { LitheContextMenuPresenter.shared.dismiss(); window.contentView = nil; window.close() } - func hasText(_ value: Any, _ text: String) -> Bool { - let item = value as AnyObject - let stringValue: String? = item.accessibilityValue?() - if stringValue == text || item.accessibilityLabel?() == text { return true } - return (item.accessibilityChildren?() ?? []).contains { hasText($0, text) } - } - func row(_ value: Any, named text: String) -> AnyObject? { - let item = value as AnyObject - if item.accessibilityRole?() == .button, item.accessibilityLabel?() == text { return item } - if let child = (item.accessibilityChildren?() ?? []).lazy.compactMap({ row($0, named: text) }).first { - return child - } - return item.accessibilityRole?() == .button && hasText(item, text) ? item : nil - } - func waitForRow(_ text: String) async -> AnyObject? { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(3)) - repeat { - host.layoutSubtreeIfNeeded() - if let found = row(host, named: text) { return found } - await Task.yield() - } while clock.now < deadline - return nil - } - let connection = try #require(await waitForRow(profile.name)) - #expect(connection.accessibilityPerformPress?() == true) - #expect(await waitForRow("Collapse connection") != nil) - #expect(await waitForRow("first_db") != nil) - #expect(feature.databaseTrees[profile.id]?.state == .ready) - #expect(feature.databaseTrees[profile.id]?.names.contains("first_db") == true) - let databaseDisclosure = try #require(await waitForRow("Expand database first_db")) - #expect(databaseDisclosure.accessibilityPerformPress?() == true) - let orders = try #require(await waitForRow("orders")) - #expect(feature.selectedTable == "customers") - let cell = DatabaseCellKey(row: 0, column: "name") - feature.tableCellDrafts[cell] = .string("edited") - feature.tableInsertedRows = [["name": .string("new")]] - feature.tableDeletedRows = [1] - let rect = try #require(orders.accessibilityFrame?()) - let point = window.convertPoint(fromScreen: NSPoint(x: rect.midX, y: rect.midY)) - let rightClick = try #require(NSEvent.mouseEvent(with: .rightMouseDown, location: point, - modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, - eventNumber: 1, clickCount: 1, pressure: 1)) - func views(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(views) } - let captures = views(host).filter { String(describing: type(of: $0)) == "LitheRightClickCaptureView" } - let capture = try #require(captures.filter { - $0.convert($0.bounds, to: nil).contains(point) - }.min { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }) - // Invoke the actual AppKit event boundary; do not run a nested application event loop. - capture.rightMouseDown(with: rightClick) - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .seconds(1)) - func menu() -> NSPanel? { NSApp.windows.compactMap { $0 as? NSPanel }.first { $0.isVisible } } - while menu() == nil && clock.now < deadline { await Task.yield() } - let popup = try #require(menu()) - NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 0, windowNumber: popup.windowNumber, context: nil, characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))) - #expect(!popup.isVisible) - #expect(feature.selectedTable == "customers") - #expect(feature.tableCellDrafts[cell] == .string("edited")) - #expect(feature.tableInsertedRows == [["name": .string("new")]]) - #expect(feature.tableDeletedRows == [1]) - // An explicit open still follows the real sidebar navigation path. - let target = try #require(await waitForRow("orders")) - func openAction(_ element: Any) -> NSAccessibilityCustomAction? { - let item = element as AnyObject - if let action = item.accessibilityCustomActions?()?.first(where: { $0.name == "Open" }) { return action } - return (item.accessibilityChildren?() ?? []).lazy.compactMap(openAction).first - } - let action = try #require(openAction(target)) - let handler = try #require(action.handler) - #expect(handler()) - #expect(await awaitChange(on: feature, until: { feature.selectedTable == "orders" })) - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - await model.shutdownProjectSession() - } - - @Test - func tableEditsSurviveConsoleRenderingAndBackgroundRefresh() async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let profile = DatabaseProfile(name: "Table edits", kind: .mysql, database: "first_db") - try store.save([profile]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer(store: preferences, settings: settings, moduleLaunchMode: .safeMode).services) - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: TableEditsWorkspaceRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), connectionStore: store) - defer { feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - feature.activateProfile(profile) - await feature.openTable("customers") - let originalRows = feature.rows - #expect(originalRows.count == 2) - let cell = DatabaseCellKey(row: 0, column: "name") - feature.tableCellDrafts[cell] = .string("edited") - feature.tableInsertedRows = [["name": .string("new")]] - feature.tableDeletedRows = [1] - func dataView() -> AnyView { - AnyView(DatabaseWorkspaceView().environmentObject(feature).environmentObject(model).environmentObject(settings)) - } - let host = NSHostingView(rootView: dataView()) - host.frame = NSRect(x: 0, y: 0, width: 740, height: 550) - host.layoutSubtreeIfNeeded() - if let directory = ProcessInfo.processInfo.environment["LITHE_DATABASE_CAPTURE_DIR"] { - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) - let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) - host.cacheDisplay(in: host.bounds, to: bitmap) - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: directory).appendingPathComponent("database-drafts.png")) - } - model.openDatabaseQuery(profileID: profile.id, sql: "SELECT 1 AS x, 2 AS x") - host.rootView = AnyView(DatabaseSQLWorkspaceView().environmentObject(feature).environmentObject(model).environmentObject(settings)) - let query = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: query) - #expect(feature.selectedSQLTab?.resultColumns == ["x", "x"]) - host.rootView = AnyView(DatabaseServicesView().environmentObject(feature).environmentObject(model).environmentObject(settings)) - host.layoutSubtreeIfNeeded() // This renders the formerly crashing duplicate-column result. - await feature.refreshTables() - #expect(feature.rows == originalRows) - model.selectDatabaseWorkspace(profileID: profile.id) - host.rootView = dataView() - host.layoutSubtreeIfNeeded() - #expect(feature.tableCellDrafts[cell] == .string("edited")) - #expect(feature.tableInsertedRows == [["name": .string("new")]]) - #expect(feature.tableDeletedRows == [1]) - feature.discardTableEdits() - #expect(!feature.hasTableEdits) - feature.tableCellDrafts[cell] = .null - await feature.openTable("orders") - #expect(!feature.hasTableEdits) // Edits must never attach to a different table. - await model.shutdownProjectSession() - } - - @Test - func connectionAndConsoleTabsRestoreDraftsAndCloseIndependently() async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "First", kind: .mysql, database: "first_db") - let second = DatabaseProfile(name: "Second", kind: .sqlite, database: "second_db") - try store.save([first, second]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer( - store: preferences, settings: settings, moduleLaunchMode: .safeMode - ).services) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: EmptyDatabaseWorkspaceRunner(), executableURL: URL(fileURLWithPath: "/fixture/database")), - connectionStore: store - ) - defer { feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - do { - await feature.select(first) - model.openDatabaseWorkspace() - model.openDatabaseWorkspace() - model.openDatabaseQuery(profileID: first.id, sql: "SELECT 'first draft'") - let firstQueryID = try #require(feature.selectedSQLTabID) - let firstQuery = EditorTabItem.databaseQuery(profileID: first.id, queryID: firstQueryID) - model.openDatabaseQuery(profileID: first.id, sql: "SELECT 'second draft'") - let secondQueryID = try #require(feature.selectedSQLTabID) - let secondQuery = EditorTabItem.databaseQuery(profileID: first.id, queryID: secondQueryID) - #expect(model.editorTabItems == [.database(first.id), firstQuery, secondQuery]) - #expect(model.activeDatabaseEditorTab == secondQuery) - #expect(model.isDatabaseWorkspaceSelected) - #expect(DatabaseWorkspaceTabTitle(feature: feature, profileID: first.id, queryID: firstQueryID).title == "console [First]") - await feature.refreshDatabases() - await feature.selectDatabase("alternate_db", for: first) - #expect(feature.selectedProfile?.database == "alternate_db") - #expect(feature.selectedSQLTabID == secondQueryID) - #expect(model.activeDatabaseEditorTab == secondQuery) - #expect(feature.sqlTab(firstQueryID, profileID: first.id)?.sql == "SELECT 'first draft'") - model.selectDatabaseWorkspace(profileID: first.id) - #expect(feature.workspaceSection == .data) - #expect(model.activeDatabaseEditorTab == .database(first.id)) - model.openDatabaseQuery(profileID: second.id, sql: "SELECT 'other connection'") - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - let otherQueryID = try #require(feature.selectedSQLTabID) - let otherQuery = EditorTabItem.databaseQuery(profileID: second.id, queryID: otherQueryID) - #expect(!model.editorTabItems.contains(.database(second.id))) - #expect(feature.sqlTab(firstQueryID, profileID: first.id)?.sql == "SELECT 'first draft'") - model.editorTabOrderFeature.reconcileDocuments(orderedIDs: []) - #expect(model.editorTabItems.contains(firstQuery)) - // Closing a background console edits only its owning connection's draft cache. - model.closeDatabaseTab(secondQuery) - #expect(model.activeDatabaseEditorTab == otherQuery) - #expect(feature.sqlTab(secondQueryID, profileID: first.id) == nil) - model.selectDatabaseWorkspace(profileID: first.id, queryID: firstQueryID) - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - #expect(model.activeDatabaseEditorTab == firstQuery) - #expect(feature.selectedSQLTab?.sql == "SELECT 'first draft'") - #expect(model.requestCloseActiveWorkbenchItem()) - #expect(!model.editorTabItems.contains(firstQuery)) - #expect(model.editorTabItems.contains(.database(first.id))) - #expect(model.activeDatabaseEditorTab == otherQuery) - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - #expect(feature.selectedSQLTab?.sql == "SELECT 'other connection'") - model.openDatabaseWorkspace(overview: true) - #expect(model.requestCloseActiveWorkbenchItem()) - #expect(model.activeDatabaseEditorTab == otherQuery) - model.closeDatabaseWorkspace() - #expect(model.editorTabItems.isEmpty) - #expect(model.editorTabOrderFeature.databaseQuerySelected == nil) - } catch { - await model.shutdownProjectSession() - throw error - } - await model.shutdownProjectSession() - } - - @Test - func sleepingModuleStopsResourcesWhileOldPresentationSurvivesAndWakeUsesNewFeature() async throws { - let runtime = ModuleRuntime() - let preferences = DatabaseLifecyclePreferences() - try runtime.register(ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { - DatabaseLifecycleWorkspaceModule() - }) - try runtime.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { - DatabaseModule( - processRunner: DatabaseLifecycleProcessRunner(), executableURL: nil, - preferenceStore: preferences, secureStore: DatabaseLifecycleSecrets(), - recoveryStore: UnavailableDatabaseRecoveryStore(), fileStorage: UnavailableDatabaseFileStorage() - ) - }) - var notifications: [String] = [] - let coordinator = DatabaseModuleCoordinator(runtime: runtime, store: ModuleCapabilityStore(), onChange: {}, onError: { notifications.append($0) }) - do { - var retained: DatabaseFeatureModel? = try #require(await coordinator.activate()) - #expect(await coordinator.activate() === retained) - retained?.errorMessage = "Connection failed" - #expect(notifications == ["Connection failed"]) - retained?.errorMessage = nil - #expect(notifications.count == 1) - weak var released = retained - retained?.addSQLTab(sql: "SELECT 'old presentation'") - - await coordinator.sleep() - retained?.errorMessage = "Late failure after sleep" - #expect(notifications == ["Connection failed"]) - #expect(runtime.capability(.databaseWorkspace) == nil) - #expect(try runtime.snapshot(for: .database).activity.activeResourceCount == 0) - #expect(retained?.hasActiveModuleWork == false) - #expect(retained?.selectedSQLTab?.sql == "SELECT 'old presentation'") - - let next = try #require(await coordinator.activate()) - next.errorMessage = "New connection failed" - #expect(notifications == ["Connection failed", "New connection failed"]) - #expect(next !== retained) - #expect(next.selectedSQLTab?.sql == "") - retained?.updateSQL("SELECT 'late edit'", in: try #require(retained?.selectedSQLTabID)) - #expect(next.selectedSQLTab?.sql == "") - retained = nil - #expect(released == nil, "The old feature must be released when its presentation goes away") - } catch { - await runtime.shutdownAll() - throw error - } - await runtime.shutdownAll() - } - - @Test(arguments: ["document", "overview", "query", "close", "closeAll", "closeBeforeLoad"], [false, true]) - func delayedConnectionLoadPreservesLaterNavigation(destination: String, fails: Bool) async throws { - let preferences = DatabaseLifecyclePreferences() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseLifecycleSecrets()) - let first = DatabaseProfile(name: "First", kind: .redis) - let second = DatabaseProfile(name: "Second", kind: .sqlite) - try store.save([first, second]) - let settings = AppSettings(store: preferences) - let model = AppModel(settings: settings, services: MacServiceContainer( - store: preferences, settings: settings, moduleLaunchMode: .safeMode - ).services) - let runner = GatedDatabaseWorkspaceRunner(fails: fails) - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/fixture/database")), - connectionStore: store - ) - defer { runner.release.open(); feature.prepareForModuleRelease() } - model.cacheModuleCapability(DatabaseModuleCapability(feature: feature), id: .databaseWorkspace, moduleID: .database) - feature.activateProfile(first) - #expect(!feature.isLoading && feature.connectionStatus(for: first) == .connected) - model.documentFeature.openVirtualDocument(URL(string: "lithe-test://documents/Navigation.txt")!, text: "fixture", displayPath: nil) - do { - let document = try #require(model.openDocuments.first) - model.selectDatabaseWorkspace(profileID: second.id) - // The tab must become selected before I/O starts, so even a loading - // connection is immediately closable. This fails with the old await-then-open path. - #expect(feature.selectedProfileID == second.id) - #expect(model.isDatabaseWorkspaceSelected) - #expect(model.activeDatabaseEditorTab == .database(second.id)) - if destination == "closeBeforeLoad" { model.closeDatabaseTab(.database(second.id)) } - #expect(await runner.entered.waitUntilOpen()) - #expect(feature.isLoading) - switch destination { - case "document": model.selectEditorDocument(document) - case "overview": model.openDatabaseWorkspace(overview: true) - case "query": model.openDatabaseQuery(profileID: second.id, sql: "SELECT 1") - case "close": model.closeDatabaseTab(.database(second.id)) - case "closeBeforeLoad": break - default: model.closeDatabaseWorkspace() - } - let expectedTabs = model.editorTabItems - let expectedSelected = model.isDatabaseWorkspaceSelected - let expectedOverview = model.editorTabOrderFeature.databaseOverviewSelected - let expectedDatabaseTab = model.activeDatabaseEditorTab - runner.release.open() - #expect(await awaitChange(on: feature, until: { !feature.isLoading })) - #expect((feature.errorMessage != nil) == fails) - #expect(model.editorTabItems == expectedTabs) - #expect(model.isDatabaseWorkspaceSelected == expectedSelected) - #expect(model.editorTabOrderFeature.databaseOverviewSelected == expectedOverview) - #expect(model.activeDatabaseEditorTab == expectedDatabaseTab) - if destination == "document" { #expect(model.activeDocumentID == document.id) } - } catch { - runner.release.open() - _ = await awaitChange(on: feature, until: { !feature.isLoading }) - await model.shutdownProjectSession() - throw error - } - await model.shutdownProjectSession() - } -} - -@MainActor -private final class DatabaseLifecycleWorkspaceModule: LitheModule { - let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) - func activate(context: ModuleContext) async throws {} - func prepareForSleep() async throws {} - func sleep() async {} - func shutdown() async {} - func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } -} - -private struct DatabaseLifecycleProcessRunner: DatabaseProcessRunning { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - Issue.record("Rendering database views must not launch a database process") - return DatabaseProcessResult(output: "", exitCode: 1) - } -} - -private final class DatabaseLifecyclePreferences: KeyValueStore, DatabasePreferenceStore, @unchecked Sendable { - private var values: [String: Any] = [:] - func data(forKey key: String) -> Data? { values[key] as? Data } - func object(forKey key: String) -> Any? { values[key] } - func string(forKey key: String) -> String? { values[key] as? String } - func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } - func set(_ value: Any?, forKey key: String) { values[key] = value } -} - -private final class DatabaseFolderSecrets: DatabaseSecureStore, @unchecked Sendable { - private let lock = NSLock() - private var values: [String: String] = [:] - private var rejectsChanges = false - private var reads = 0 - var readCount: Int { lock.withLock { reads } } - func rejectChanges(_ reject: Bool) { lock.withLock { rejectsChanges = reject } } - func read(key: String) -> String? { lock.withLock { reads += 1; return values[key] } } - func write(_ value: String, key: String) throws { - try lock.withLock { - if rejectsChanges { throw NSError(domain: "Fixture", code: 1) } - values[key] = value - } - } - func delete(key: String) throws { - try lock.withLock { - if rejectsChanges { throw NSError(domain: "Fixture", code: 1) } - values.removeValue(forKey: key) - } - } -} - -private struct DatabaseLifecycleSecrets: DatabaseSecureStore { - func read(key: String) -> String? { nil } - func write(_ value: String, key: String) throws {} - func delete(key: String) throws {} -} - -private struct EmptyDatabaseWorkspaceRunner: DatabaseProcessRunning { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String, - let output = try? JSONSerialization.data(withJSONObject: ["id": id, "ok": true, "result": input["method"] as? String == "listDatabases" ? ["first_db", "alternate_db"] : []]) else { - return DatabaseProcessResult(output: "Invalid fixture request", exitCode: 1) - } - return DatabaseProcessResult(output: String(decoding: output, as: UTF8.self), exitCode: 0) - } -} - -private struct GatedBackgroundSaveRunner: DatabaseProcessRunning { - let operation: String - let fails: Bool - let loadEntered = TestGate() - let loadRelease = TestGate() - let saveEntered = TestGate() - let saveRelease = TestGate() - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let method = input["method"] as? String else { - return DatabaseProcessResult(output: "Invalid fixture request", exitCode: 1) - } - let params = input["params"] as? [String: Any] - let isSave = method == "testConnection" - let isLoad = (operation == "refresh" && method == "listTables") - || (operation == "open" && method == "describeTable") - || (operation == "page" && method == "pageTable" && params?["offset"] as? Int == 200) - if isSave || isLoad { - (isSave ? saveEntered : loadEntered).open() - // The synchronous port runs on the production worker, with cleanup and a local deadline. - guard (isSave ? saveRelease : loadRelease).waitSynchronously(timeout: 5) else { - Issue.record("Background save test did not release its request") - return DatabaseProcessResult(output: "Fixture deadline exceeded", exitCode: 1) - } - if isSave && fails { return DatabaseProcessResult(output: "Fixture connection failed", exitCode: 1) } - } - return TableEditsWorkspaceRunner().runDatabaseProcess(request) - } -} - -private struct GatedDatabaseWorkspaceRunner: DatabaseProcessRunning { - let fails: Bool - var gatedMethod = "listTables" - let entered = TestGate() - let release = TestGate() - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String else { - return DatabaseProcessResult(output: "Invalid fixture request", exitCode: 1) - } - if input["method"] as? String == gatedMethod { - entered.open() - // This synchronous port runs on the production-owned worker, never MainActor. - guard release.waitSynchronously(timeout: 2) else { - Issue.record("Database navigation test did not release its request") - return DatabaseProcessResult(output: "Fixture deadline exceeded", exitCode: 1) - } - if fails { return DatabaseProcessResult(output: "Fixture connection failed", exitCode: 1) } - } - if gatedMethod == "query" { return TableEditsWorkspaceRunner().runDatabaseProcess(request) } - guard let output = try? JSONSerialization.data(withJSONObject: ["id": id, "ok": true, "result": []]) else { - return DatabaseProcessResult(output: "Invalid fixture response", exitCode: 1) - } - return DatabaseProcessResult(output: String(decoding: output, as: UTF8.self), exitCode: 0) - } -} - -private struct TableEditsWorkspaceRunner: DatabaseProcessRunning { - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String else { return DatabaseProcessResult(output: "Invalid fixture", exitCode: 1) } - let result: Any - switch input["method"] as? String { - case "testConnection": result = ["connected": true] - case "listDatabases": result = ["first_db", "alternate_db"] - case "listTables": result = [["table_name": "customers"], ["table_name": "orders"]] - case "describeTable": result = [["name": "id", "type": "INTEGER", "pk": 1], ["name": "name", "type": "TEXT", "pk": 0]] - case "pageTable": result = ["columns": ["id", "name"], "rows": [["id": 1, "name": "Alice"], ["id": 2, "name": "Bob"]], "truncated": false, "totalRows": 2] - case "query": result = ["columns": ["x", "x"], "rows": [["x": 2]], "truncated": false] - default: result = [] - } - guard let output = try? JSONSerialization.data(withJSONObject: ["id": id, "ok": true, "result": result]) else { return DatabaseProcessResult(output: "Invalid fixture response", exitCode: 1) } - return DatabaseProcessResult(output: String(decoding: output, as: UTF8.self), exitCode: 0) - } -} - -private struct QueryOutputRunner: DatabaseProcessRunning { - let fails: Bool - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let params = input["params"] as? [String: Any] else { return DatabaseProcessResult(output: "Invalid fixture", exitCode: 1) } - if input["method"] as? String == "query", fails, (params["sql"] as? String)?.contains("missing_table") == true { - return DatabaseProcessResult(output: "Table 'fixture.missing_table' doesn't exist", exitCode: 1) - } - return TableEditsWorkspaceRunner().runDatabaseProcess(request) - } -} - -private struct GatedDatabasePageRunner: DatabaseProcessRunning { - let fails: Bool - let entered = TestGate() - let release = TestGate() - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let params = input["params"] as? [String: Any] else { - return DatabaseProcessResult(output: "Invalid fixture request", exitCode: 1) - } - if input["method"] as? String == "pageTable", params["offset"] as? Int == 200 { - entered.open() - // The synchronous provider port is called on the production-owned worker. - guard release.waitSynchronously(timeout: 5) else { - Issue.record("Delayed page was not released before its deadline") - return DatabaseProcessResult(output: "Fixture deadline exceeded", exitCode: 1) - } - if fails { return DatabaseProcessResult(output: "Fixture page failed", exitCode: 1) } - } - return TableEditsWorkspaceRunner().runDatabaseProcess(request) - } -} - -private final class ReloadedTableKeysRunner: DatabaseProcessRunning, @unchecked Sendable { - let fails: Bool - private let lock = NSLock() - private var unfilteredPages = 0 - private var applies = 0 - var applyRequests: Int { lock.withLock { applies } } - init(fails: Bool) { self.fails = fails } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String else { return DatabaseProcessResult(output: "Invalid fixture", exitCode: 1) } - if input["method"] as? String == "applyChanges" { lock.withLock { applies += 1 } } - if input["method"] as? String == "pageTable", let params = input["params"] as? [String: Any] { - let narrowed = !(params["filters"] as? [Any] ?? []).isEmpty || !(params["sort"] as? [Any] ?? []).isEmpty - if !narrowed { - let page = lock.withLock { unfilteredPages += 1; return unfilteredPages } - if fails && page > 1 { return DatabaseProcessResult(output: "Fixture reload failed", exitCode: 1) } - } - let result: [String: Any] = ["columns": ["id", "name"], "rows": [["id": narrowed ? 2 : 1, "name": "fixture"]], "truncated": false, "totalRows": 1] - guard let output = try? JSONSerialization.data(withJSONObject: ["id": id, "ok": true, "result": result]) else { - return DatabaseProcessResult(output: "Invalid fixture page", exitCode: 1) - } - return DatabaseProcessResult(output: String(decoding: output, as: UTF8.self), exitCode: 0) - } - return TableEditsWorkspaceRunner().runDatabaseProcess(request) - } -} - -private final class GatedSQLNavigationRunner: DatabaseProcessRunning, @unchecked Sendable { - let method: String - let fails: Bool - let gatedRequest: Int - let entered = TestGate() - let release = TestGate() - private let lock = NSLock() - private var count = 0 - var requests: Int { lock.withLock { count } } - init(method: String, fails: Bool, gatedRequest: Int = 1) { self.method = method; self.fails = fails; self.gatedRequest = gatedRequest } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String else { - return DatabaseProcessResult(output: "Invalid fixture request", exitCode: 1) - } - if input["method"] as? String == "exportSqlToFile", - let params = input["params"] as? [String: Any], let path = params["outputPath"] as? String { - do { - try Data().write(to: URL(fileURLWithPath: path)) - let response: [String: Any] = ["id": id, "ok": true, "result": ["path": path, "byteCount": 0, - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]] - return DatabaseProcessResult(output: String(decoding: try JSONSerialization.data(withJSONObject: response), as: UTF8.self), exitCode: 0) - } catch { return DatabaseProcessResult(output: error.localizedDescription, exitCode: 1) } - } - if input["method"] as? String == method { - let number = lock.withLock { count += 1; return count } - if number >= gatedRequest { - entered.open() - // Block only the synchronous provider worker; the test owns release and a bounded deadline. - guard release.waitSynchronously(timeout: 5) else { - Issue.record("SQL navigation test did not release its request") - return DatabaseProcessResult(output: "Fixture deadline exceeded", exitCode: 1) - } - if fails { return DatabaseProcessResult(output: "Fixture SQL failed", exitCode: 1) } - } - if method == "execute" { - return DatabaseProcessResult(output: "{\"id\":\"\(id)\",\"ok\":true,\"result\":{\"rowsAffected\":1}}", exitCode: 0) - } - } - return TableEditsWorkspaceRunner().runDatabaseProcess(request) - } -} - -private final class GatedDatabaseApplyRunner: DatabaseProcessRunning, @unchecked Sendable { - let fails: Bool - let entered = TestGate() - let release = TestGate() - private let lock = NSLock() - private var applies = 0 - private var pages = 0 - var applyRequests: Int { lock.withLock { applies } } - var pageRequests: Int { lock.withLock { pages } } - init(fails: Bool) { self.fails = fails } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - guard let data = request.standardInput, - let input = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let id = input["id"] as? String else { return DatabaseProcessResult(output: "Invalid fixture", exitCode: 1) } - if input["method"] as? String == "pageTable" { - lock.withLock { pages += 1 } - let params = input["params"] as? [String: Any] - let table = params?["table"] as? String ?? "" - let response: [String: Any] = ["id": id, "ok": true, "result": ["columns": ["id", "name"], - "rows": [["id": 1, "name": table], ["id": 2, "name": table]], "truncated": false, "totalRows": 2]] - guard let data = try? JSONSerialization.data(withJSONObject: response) else { - return DatabaseProcessResult(output: "Invalid fixture page", exitCode: 1) - } - return DatabaseProcessResult(output: String(decoding: data, as: UTF8.self), exitCode: 0) - } - guard input["method"] as? String == "applyChanges" else { return TableEditsWorkspaceRunner().runDatabaseProcess(request) } - lock.withLock { applies += 1 } - entered.open() - guard release.waitSynchronously(timeout: 5) else { - Issue.record("Delayed apply was not released before its deadline") - return DatabaseProcessResult(output: "Fixture deadline exceeded", exitCode: 1) - } - if fails { return DatabaseProcessResult(output: "Fixture write failed", exitCode: 1) } - let output = "{\"id\":\"\(id)\",\"ok\":true,\"result\":{\"rowsAffected\":1}}" - return DatabaseProcessResult(output: output, exitCode: 0) - } -} - -private final class CancellableTreeSidecarRunner: DatabaseProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private var blocks = true - private let blockedMethod: String - private let pidFile: URL - private let fallback = DatabaseTreeRunner() - - init(blockedMethod: String, pidFile: URL) { - self.blockedMethod = blockedMethod - self.pidFile = pidFile - } - - var shouldBlock: Bool { - get { lock.withLock { blocks } } - set { lock.withLock { blocks = newValue } } - } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - let method = request.standardInput.flatMap { - (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["method"] as? String - } - guard method == blockedMethod, shouldBlock else { return fallback.runDatabaseProcess(request) } - return MacProcessRunner().runDatabaseProcess(DatabaseProcessRequest( - executablePath: request.executablePath, - environment: ["LITHE_TEST_PID_FILE": pidFile.path], - standardInput: request.standardInput, - timeoutMilliseconds: 2_500 - )) - } -} - -private final class DatabaseTreeRunner: DatabaseProcessRunning, @unchecked Sendable { - struct Request { let method: String; let database: String } - private let lock = NSLock() - private var recorded: [Request] = [] - private var storedNames = ["beta", "alpha", "alpha"] - private var storedIncludesObjects = false - private var storedFails = false - private var storedBlocks = false - private var storedBlockedMethod = "listTables" - private var storedFailureMethod = "listTables" - private var storedColumnNames = ["id", "name"] - let entered = TestGate(), release = TestGate(), pageEntered = TestGate() - var requests: [Request] { lock.withLock { recorded } } - var names: [String] { - get { lock.withLock { storedNames } } - set { lock.withLock { storedNames = newValue } } - } - var fails: Bool { - get { lock.withLock { storedFails } } - set { lock.withLock { storedFails = newValue } } - } - var blocks: Bool { - get { lock.withLock { storedBlocks } } - set { lock.withLock { storedBlocks = newValue } } - } - - var blockedMethod: String { - get { lock.withLock { storedBlockedMethod } } - set { lock.withLock { storedBlockedMethod = newValue } } - } - var failureMethod: String { - get { lock.withLock { storedFailureMethod } } - set { lock.withLock { storedFailureMethod = newValue } } - } - var columnNames: [String] { - get { lock.withLock { storedColumnNames } } - set { lock.withLock { storedColumnNames = newValue } } - } - - var includesObjects: Bool { - get { lock.withLock { storedIncludesObjects } } - set { lock.withLock { storedIncludesObjects = newValue } } - } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - do { - let inputData = try #require(request.standardInput) - let input = try #require(JSONSerialization.jsonObject(with: inputData) as? [String: Any]) - let id = try #require(input["id"] as? String) - let method = try #require(input["method"] as? String) - let params = input["params"] as? [String: Any] ?? [:] - let connection = params["connection"] as? [String: Any] ?? [:] - let database = connection["database"] as? String ?? "" - lock.withLock { recorded.append(Request(method: method, database: database)) } - let columns = columnNames - if blocks && method == blockedMethod && database == "alpha" { - entered.open() - // Production invokes this synchronous port on its worker; the gate has a local deadline. - guard release.waitSynchronously(timeout: 5) else { throw CocoaError(.userCancelled) } - } - if fails && method == failureMethod { throw CocoaError(.fileReadNoPermission) } - let result: Any - switch method { - case "listDatabases": result = names - case "listTables": result = [["table_name": "\(database)_only"], ["table_name": "shared"]] - case "listObjects": result = includesObjects ? [["name": "fixture_\(params["kind"] as? String ?? "object")"]] : [] - case "describeTable": result = columns.map { ["column_name": $0, "data_type": $0 == "id" ? "int" : "text"] } - case "listIndexes", "listForeignKeys": result = [] as [String] - case "pageTable": - pageEntered.open() - result = ["columns": [], "rows": [], "totalRows": 0, "truncated": false] - default: throw CocoaError(.featureUnsupported) - } - let data = try JSONSerialization.data(withJSONObject: ["id": id, "ok": true, "result": result]) - return DatabaseProcessResult(output: String(decoding: data, as: UTF8.self), exitCode: 0) - } catch { - return DatabaseProcessResult(output: error.localizedDescription, exitCode: 1) - } - } -} diff --git a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift index 87a95261a..4583fa545 100644 --- a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift +++ b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift @@ -236,22 +236,6 @@ struct EditorTabOrderFeatureModelTests { #expect(drag.destination(at: CGPoint(x: 290, y: 250), sourceGroup: group)?.side == .right) } - @Test func databaseTabsKeepTheirSharedWorkspaceWhileDocumentsSplit() throws { - let feature = EditorTabOrderFeatureModel() - let first = EditorTabItem.document(UUID()), second = EditorTabItem.document(UUID()) - let database = EditorTabItem.database(UUID()), query = EditorTabItem.databaseQuery(profileID: UUID(), queryID: UUID()) - feature.moveToEnd(first); feature.moveToEnd(second); feature.moveToEnd(database) - let original = try #require(feature.groupLayout.groups.first) - #expect(feature.drop(second, into: original, side: .right)) - let other = try #require(feature.groupLayout.groups.last) - feature.moveToEnd(query) - #expect(feature.items(in: original).contains(query)) - #expect(!feature.drop(database, into: other, side: .center)) - #expect(!feature.move(query, before: second)) - #expect(feature.move(query, before: database)) - #expect(feature.items(in: other) == [second]) - } - @Test(arguments: [true, false], [true, false]) func sharedTabPaintsActiveBlueAndInactiveGray(dark: Bool, active: Bool) async throws { let hosting = NSHostingView(rootView: Text("Example.swift").font(LitheTheme.uiFont(size: 13)) diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 6ae9b7a29..189955c27 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4,7 +4,6 @@ import CoreServices import Foundation import SwiftUI import LitheApplicationKernel -@testable import LitheDatabaseModule @testable import LitheGitModule import LitheLocalHistoryModule import LitheModuleAPI @@ -1052,1314 +1051,6 @@ struct LitheCoreLogicTests { #expect(window.frame == restoredFrame) } - @Test - func databaseSidecarParsesCapabilitiesWithoutStartingUntilRequested() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"protocolVersion":1,"databaseTypes":["mysql","postgresql","sqlite"],"features":["schema"]}}"#, - exitCode: 0 - ) - } - let service = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - - #expect(runner.requests.isEmpty) - #expect(try service.capabilities() == DatabaseCapabilities( - protocolVersion: 1, - databaseTypes: ["mysql", "postgresql", "sqlite"], - features: ["schema"] - )) - #expect(runner.requests.count == 1) - #expect(runner.requests[0].arguments.isEmpty) - #expect(runner.requests[0].standardInput != nil) - #expect(runner.requests[0].timeoutMilliseconds == 30_000) - } - - @Test - func databaseSidecarSerializesRedisAndNacosWorkspaceRequests() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "redisScan": - result = #"{"keys":[{"key":"session:42","type":"string","ttl":60,"size":9}],"nextCursor":"19"}"# - case "nacosListConfigs": - result = #"{"items":[{"dataId":"app.yaml","group":"DEFAULT_GROUP","namespace":"dev","type":"yaml","md5":"abc"}],"totalCount":1}"# - default: - Issue.record("Unexpected sidecar method: \(method)") - result = "{}" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - - let redis = DatabaseConnection(kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - let scan = try service.redisScan(connection: redis, cursor: "0", pattern: "session:*", count: 50) - #expect(scan.keys.first?.key == "session:42") - #expect(scan.nextCursor == "19") - - let nacos = DatabaseConnection(kind: .nacos, host: "127.0.0.1", port: 8848, database: "dev", path: "/nacos") - let configs = try service.nacosListConfigs(connection: nacos, dataId: "app", group: "DEFAULT_GROUP") - #expect(configs.items.first?.dataId == "app.yaml") - #expect(configs.totalCount == 1) - - let redisRequest = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - let nacosRequest = String(decoding: try #require(runner.requests[1].standardInput), as: UTF8.self) - #expect(redisRequest.contains(#""method":"redisScan""#)) - #expect(redisRequest.contains(#""kind":"redis""#)) - #expect(!redisRequest.contains("127.0.0.1:6379")) - #expect(nacosRequest.contains(#""method":"nacosListConfigs""#)) - #expect(nacosRequest.contains(#""kind":"nacos""#)) - } - - @Test - func databaseSidecarSerializesRedisSizePreference() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"keys":[],"nextCursor":"0"}}"#, - exitCode: 0 - ) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let redis = DatabaseConnection(kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - - _ = try service.redisScan(connection: redis, includeSize: false) - - let request = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(request.contains(#""includeSize":false"#)) - } - - @Test - func databaseSidecarDecodesLegacyMongoMetadataRowsEnvelope() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": result = #"{"rows":[{"table_name":"events","table_type":"collection"}],"truncated":false}"# - case "describeTable": result = #"{"rows":[{"column_name":"_id","data_type":"objectId"}],"truncated":false}"# - case "listIndexes": result = #"{"rows":[{"index_name":"_id_","definition":"{\"_id\":1}"}],"truncated":false}"# - case "listForeignKeys": result = #"{"rows":[],"truncated":false}"# - case "listObjects": result = #"{"rows":[{"object_name":"events","object_kind":"collection"}],"truncated":false}"# - default: result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let mongo = DatabaseConnection(kind: .mongodb, host: "127.0.0.1", port: 27017, database: "lithe_test") - func stringValue(_ row: DatabaseRow?, _ key: String) -> String? { - guard case let .string(value) = row?[key] else { return nil } - return value - } - - #expect(stringValue(try service.listTables(connection: mongo).first, "table_name") == "events") - #expect(stringValue(try service.describeTable(connection: mongo, table: "events").first, "column_name") == "_id") - #expect(stringValue(try service.listIndexes(connection: mongo, table: "events").first, "index_name") == "_id_") - #expect((try service.listForeignKeys(connection: mongo, table: "events")).isEmpty) - #expect(stringValue(try service.listObjects(connection: mongo, kind: DatabaseObjectKind.tables).first, "object_name") == "events") - } - - @Test - func databaseSidecarMapsFailedProcessToStableError() { - let runner = RecordingProcessRunner(result: ProcessResult(output: "connection store failed", exitCode: 2)) - let service = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/dbx") - ) - - #expect(throws: DatabaseSidecarError.processFailed(exitCode: 2, output: "connection store failed")) { - try service.capabilities() - } - } - - @Test - func databaseSidecarErrorsAreSingleLineAndBounded() { - let error = DatabaseSidecarError.requestFailed(code: "database_error", message: String(repeating: "x", count: 800) + "\nnext line") - let description = error.localizedDescription - #expect(description.count <= 500 + "Database request failed (database_error): ".count) - #expect(!description.contains("\n")) - #expect(description.hasSuffix("...")) - } - - @Test - func databaseValueDisplayKeepsNullAndEmptyStringDistinct() { - #expect(DatabaseValue.null.displayText == "NULL") - #expect(DatabaseValue.string("").displayText == "\"\"") - #expect(DatabaseValue.string("NULL").displayText == "NULL") - #expect(DatabaseValue.object(["empty": .string("")]).displayText == #"{"empty":""}"#) - } - - @Test - func databaseProfilesKeepPasswordsOutOfPreferences() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Local", kind: .mysql, username: "root", database: "app") - - try store.save([profile]) - try store.savePassword("secret-value", for: profile.id) - - #expect(store.load() == [profile]) - #expect(store.password(for: profile.id) == "secret-value") - let encodedProfiles = try #require(preferences.data(forKey: "database.profiles.v1")) - #expect(!String(decoding: encodedProfiles, as: UTF8.self).contains("secret-value")) - } - - @Test - @MainActor - func databaseRepeatedConnectionEditsPreserveTheSavedPasswordAndProfileID() async throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"connected":true}}"#, - exitCode: 0 - ) - } - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let operations = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let profile = DatabaseProfile(name: "Local Redis", kind: .redis, port: 6379) - - #expect(await feature.add(profile, password: "1234")) - var firstRename = profile - firstRename.name = "Renamed Redis" - #expect(await feature.update(firstRename, password: nil)) - var secondRename = firstRename - secondRename.name = "Renamed Again" - #expect(await feature.update(secondRename, password: nil)) - - #expect(feature.profiles.first?.id == profile.id) - #expect(feature.profiles.first?.name == "Renamed Again") - #expect(store.password(for: profile.id) == "1234") - #expect(runner.requests.count == 3) - for request in runner.requests { - let payload = String(decoding: try #require(request.standardInput), as: UTF8.self) - #expect(payload.contains(#""password":"1234""#)) - } - } - - @Test - func databaseDBXImportMapsConnectionsFoldersAndUnsupportedTypes() throws { - let data = Data(dbxPlainConnectionExport.utf8) - let duplicate = DatabaseProfile(name: "Production MySQL", kind: .mysql, host: "db.example.com", port: 3306) - let plan = try DatabaseDBXImportService().parse(data: data, passphrase: nil, existingProfiles: [duplicate]) - - #expect(plan.wasEncrypted == false) - #expect(plan.candidates.count == 2) - #expect(plan.duplicateCount == 1) - #expect(plan.unsupportedTypes == ["oracle": 1]) - #expect(plan.folders.count == 2) - let production = try #require(plan.candidates.first { $0.sourceID == "mysql-1" }) - #expect(production.profile.kind == .mysql) - #expect(production.profile.username == "root") - #expect(production.password == "db-secret") - #expect(production.profile.readOnly) - #expect(production.profile.productionProtection) - #expect(production.profile.sshHost == "jump.example.com") - let sqlite = try #require(plan.candidates.first { $0.sourceID == "sqlite-1" }) - #expect(sqlite.profile.kind == .sqlite) - #expect(sqlite.profile.path == "/tmp/local.sqlite") - let localFolder = try #require(plan.folders.first { $0.id == sqlite.profile.folderID }) - #expect(localFolder.name == "Local") - #expect(localFolder.parentID != nil) - } - - @Test - func databaseDBXImportDecryptsTheVersionOneWebCryptoEnvelope() throws { - let data = Data(dbxEncryptedConnectionExport.utf8) - let service = DatabaseDBXImportService() - #expect(service.isEncrypted(data)) - let plan = try service.parse(data: data, passphrase: "migration-pass", existingProfiles: []) - #expect(plan.wasEncrypted) - #expect(plan.candidates.count == 1) - #expect(plan.candidates[0].profile.name == "M") - #expect(plan.candidates[0].password == "p") - #expect(throws: DatabaseDBXImportError.wrongPassphrase) { - try service.parse(data: data, passphrase: "wrong", existingProfiles: []) - } - } - - @Test - @MainActor - func databaseDBXImportPersistsProfilesFoldersAndKeychainPasswordsOffline() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 1)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let plan = try DatabaseDBXImportService().parse( - data: Data(dbxPlainConnectionExport.utf8), - passphrase: nil, - existingProfiles: [] - ) - - let count = feature.importDBXConnections(plan: plan, selectedIDs: Set(plan.candidates.map(\.id))) - #expect(count == 2) - #expect(feature.profiles.count == 2) - #expect(feature.folders.count == 2) - let mysql = try #require(feature.profiles.first { $0.name == "Production MySQL" }) - #expect(store.password(for: mysql.id) == "db-secret") - #expect(mysql.folderID != nil) - let sqlite = try #require(feature.profiles.first { $0.name == "Local SQLite" }) - let localFolder = try #require(feature.folders.first { $0.id == sqlite.folderID }) - #expect(localFolder.parentID == mysql.folderID) - } - - @Test - func databaseConnectionFoldersPersistWithProfiles() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let folder = DatabaseConnectionFolder(name: "Development") - let profile = DatabaseProfile(name: "Local MySQL", kind: .mysql, folderID: folder.id) - - try store.saveFolders([folder]) - try store.save([profile]) - - #expect(store.loadFolders() == [folder]) - #expect(store.load().first?.folderID == folder.id) - #expect(store.load().first?.group == "") - } - - @Test - @MainActor - func databaseLegacyGroupsMigrateToFoldersAndFolderRemovalKeepsConnections() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Legacy MySQL", kind: .mysql, group: "Team A") - try store.save([profile]) - - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let folder = try #require(feature.folders.first) - let migrated = try #require(feature.profiles.first) - - #expect(folder.name == "Team A") - #expect(migrated.folderID == folder.id) - #expect(migrated.group.isEmpty) - #expect(store.loadFolders() == [folder]) - - feature.removeFolder(folder) - - let remaining = try #require(feature.profiles.first) - #expect(feature.folders.isEmpty) - #expect(remaining.id == profile.id) - #expect(remaining.folderID == nil) - #expect(store.load().first?.folderID == nil) - } - - @Test - @MainActor - func databaseConnectionMovePersistsFolderAssignment() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let first = DatabaseConnectionFolder(name: "One") - let second = DatabaseConnectionFolder(name: "Two") - let profile = DatabaseProfile(name: "Move me", kind: .sqlite, path: "/tmp/test.sqlite") - try store.saveFolders([first, second]) - try store.save([profile]) - - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - feature.move(profile, toFolder: second.id) - - #expect(feature.profiles.first?.folderID == second.id) - #expect(store.load().first?.folderID == second.id) - } - - @Test - @MainActor - func databaseNestedFolderAndDuplicateConnectionKeepAssignments() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let parent = DatabaseConnectionFolder(name: "Team") - let child = DatabaseConnectionFolder(name: "Backend", parentID: parent.id) - let profile = DatabaseProfile(name: "Local", kind: .sqlite, path: "/tmp/local.sqlite", folderID: child.id) - try store.saveFolders([parent, child]) - try store.save([profile]) - try store.savePassword("password", for: profile.id) - - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - #expect(feature.createFolder(name: "Queries", parentID: parent.id)) - #expect(feature.folders.first(where: { $0.name == "Queries" })?.parentID == parent.id) - let copy = try #require(feature.duplicate(profile)) - #expect(copy.folderID == child.id) - #expect(copy.id != profile.id) - #expect(store.password(for: copy.id) == "password") - - feature.removeFolder(parent) - #expect(feature.folders.first(where: { $0.id == child.id })?.parentID == nil) - #expect(feature.profiles.first(where: { $0.id == profile.id })?.folderID == child.id) - } - - @Test - func databaseBrandIconCatalogCoversSupportedKinds() { - #expect(DatabaseKind.allCases.map(\.brandIconFilename) == [ - "mysql.svg", "mariadb.svg", "postgres.svg", "sqlite.svg", - "sqlserver.svg", "mongodb.svg", "redis.svg", "nacos.svg" - ]) - } - - @Test - @MainActor - func databaseDisconnectResetsSelectedConnectionState() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Disconnect me", kind: .sqlite, path: "/tmp/disconnect.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), connectionStore: store) - await feature.select(profile) - #expect(feature.connectionStatus(for: profile) == .connected) - feature.disconnect(profile) - #expect(feature.connectionStatus(for: profile) == .idle) - #expect(feature.selectedProfileID == nil) - } - - @Test - @MainActor - func databaseProfileSwitchResetsProfileScopedWorkspaceState() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let first = DatabaseProfile(name: "First", kind: .sqlite, host: "first", path: "/tmp/first.sqlite") - let second = DatabaseProfile(name: "Second", kind: .sqlite, host: "second", path: "/tmp/second.sqlite") - try store.save([first, second]) - let tableRequestCounter = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": - tableRequestCounter.value += 1 - let tableName = tableRequestCounter.value == 1 ? "first_table" : "second_table" - result = #"[{"table_name":"\#(tableName)"}]"# - default: - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(first) - #expect(feature.tables == ["first_table"]) - feature.addSQLTab(sql: "SELECT from_first") - #expect(feature.selectedSQLTab?.sql == "SELECT from_first") - - await feature.select(second) - #expect(feature.tables == ["second_table"]) - #expect(feature.rows.isEmpty) - #expect(feature.selectedTable == nil) - #expect(feature.sqlTabs.count == 1) - #expect(feature.selectedSQLTab?.sql.isEmpty == true) - } - - @Test - @MainActor - func databaseMySQLDatabaseSelectionPersistsAndRefreshesTables() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "MySQL", kind: .mysql, host: "localhost", username: "root") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listDatabases": result = #"["alpha","beta"]"# - case "listTables": result = #"[{"table_name":"items"}]"# - default: result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - #expect(feature.databaseOptions == ["alpha", "beta"]) - #expect(feature.connectionStatus(for: profile) == .connected) - await feature.selectDatabase("beta", for: profile) - - #expect(feature.selectedProfile?.database == "beta") - #expect(feature.tables == ["items"]) - #expect(store.load().first?.database == "beta") - } - - @Test - @MainActor - func databaseRefreshingTablesReloadsTheCurrentlyOpenTable() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-refresh.sqlite") - try store.save([profile]) - let pageCounter = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": - result = #"[{"table_name":"items"}]"# - case "describeTable": - result = #"[{"column_name":"id","data_type":"integer","column_key":"PRI"}]"# - case "pageTable": - pageCounter.value += 1 - result = #"{"columns":["id"],"rows":[{"id":\#(pageCounter.value)}],"truncated":false,"totalRows":1}"# - default: - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.openTable("items") - #expect(feature.errorMessage == nil) - #expect(feature.rows.first?["id"] == DatabaseValue.integer(1)) - - await feature.refreshTables() - - #expect(feature.tables == ["items"]) - #expect(feature.selectedTable == "items") - #expect(feature.rows.first?["id"] == DatabaseValue.integer(2)) - #expect(pageCounter.value == 2) - } - - @Test - @MainActor - func databaseFailedSQLClearsPreviousResultsAndRecordsFailure() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-test.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - if method == "query", let params = object["params"] as? [String: Any], - (params["sql"] as? String)?.contains("missing_table") == true { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"syntax_error","message":"no such table: missing_table"}}"#, exitCode: 0) - } - if method == "query" { - result = #"{"rows":[{"value":1}],"columns":["value"],"truncated":false}"# - } else { - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - feature.addSQLTab(sql: "SELECT 1") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.result?.rows.count == 1) - #expect(feature.selectedSQLTab?.execution != nil) - - feature.updateSQL("SELECT * FROM missing_table", in: tabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.result == nil) - #expect(feature.selectedSQLTab?.execution?.rowsReturned == nil) - #expect(feature.selectedSQLTab?.execution != nil) - #expect(feature.selectedSQLTab?.statementExecutions.last?.errorMessage?.contains("missing_table") == true) - #expect(feature.sqlHistory.count == 2) - #expect(feature.selectedSQLTab?.rowsAffected == nil) - #expect(feature.selectedSQLTab?.errorMessage != nil) - - // Empty input is rejected before execution and must not create another history entry. - feature.updateSQL(" ", in: tabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.execution == nil) - #expect(feature.sqlHistory.count == 2) - } - - @Test - @MainActor - func databaseRedisStatusRecoversAfterSuccessfulKeyLoad() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - if method == "redisScan" { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"redis_error","message":"NOAUTH"}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"key":"session:42","type":"string","ttl":60,"size":9,"stringValue":"ready","hashEntries":[]}}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.connectionStatus(for: profile) == .failed) - await feature.loadRedisKey("session:42") - #expect(feature.connectionStatus(for: profile) == .connected) - #expect(feature.redisSelectedKey?.stringValue == "ready") - } - - @Test - @MainActor - func databaseRedisRescanFailureDoesNotLeaveOldKeysVisible() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let calls = TestCounter() - let runner = RecordingProcessRunner { request in - calls.value += 1 - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - if calls.value == 1 { - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"keys":[{"key":"session:42","type":"string","ttl":60,"size":9}],"nextCursor":"0"}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"redis_error","message":"NOAUTH"}}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.redisKeys.map(\.key) == ["session:42"]) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.redisKeys.isEmpty) - #expect(feature.errorMessage?.contains("NOAUTH") == true) - #expect(feature.connectionStatus(for: profile) == .failed) - } - - @Test - @MainActor - func databaseRedisWriteRefreshesKeySummaryAndDetail() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let scanCount = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - switch method { - case "redisScan": - scanCount.value += 1 - let key = scanCount.value == 1 ? "session:old" : "session:new" - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"keys":[{"key":"\#(key)","type":"string","ttl":60,"size":9}],"nextCursor":"0"}}"#, exitCode: 0) - case "redisSetString": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - case "redisGetKey": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"key":"session:new","type":"string","ttl":120,"size":12,"stringValue":"updated","hashEntries":[]}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "session:*") - #expect(feature.redisKeys.first?.key == "session:old") - - #expect(await feature.saveRedisString(key: "session:new", value: "updated", ttl: 120, confirmed: true)) - #expect(feature.redisKeys.first?.key == "session:new") - #expect(feature.redisSelectedKey?.key == "session:new") - #expect(feature.redisSelectedKey?.ttl == 120) - #expect(scanCount.value == 2) - } - - @Test - @MainActor - func databaseNacosPublishRefreshesConfigListAndDetail() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Nacos", kind: .nacos, host: "127.0.0.1", port: 8848, database: "public") - try store.save([profile]) - let listCount = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - switch method { - case "nacosListConfigs": - listCount.value += 1 - let dataID = listCount.value == 1 ? "old.yaml" : "new.yaml" - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"items":[{"dataId":"\#(dataID)","group":"DEFAULT_GROUP","namespace":"public","type":"yaml","md5":"abc"}],"totalCount":1}}"#, exitCode: 0) - case "nacosPublishConfig": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - case "nacosGetConfig": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"dataId":"new.yaml","group":"DEFAULT_GROUP","namespace":"public","type":"yaml","md5":"def","content":"updated"}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadNacosConfigs(dataId: "", group: "") - #expect(feature.nacosConfigs.first?.dataId == "old.yaml") - - #expect(await feature.publishNacosConfig(dataId: "new.yaml", group: "DEFAULT_GROUP", content: "updated", type: "yaml", confirmed: true)) - #expect(feature.nacosConfigs.first?.dataId == "new.yaml") - #expect(feature.nacosSelectedConfig?.dataId == "new.yaml") - #expect(feature.nacosSelectedConfig?.content == "updated") - #expect(listCount.value == 2) - } - - @Test - @MainActor - func databaseConnectionStatusTracksSuccessfulConnection() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Status success", kind: .sqlite, path: "/tmp/status-success.sqlite") - try store.save([profile]) - - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - - #expect(feature.connectionStatus(for: profile) == .connected) - #expect(feature.connectedProfileCount == 1) - } - - @Test - @MainActor - func databaseConnectionStatusRetainsFailure() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Status failure", kind: .sqlite, path: "/tmp/status-failure.sqlite") - try store.save([profile]) - - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "connection refused", exitCode: 1)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ), - connectionStore: store - ) - - await feature.select(profile) - - #expect(feature.connectionStatus(for: profile) == .failed) - #expect(feature.connectedProfileCount == 0) - #expect(feature.errorMessage != nil) - } - - @Test - func databaseSQLBackupUsesStdinAndExtendedTimeout() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"encoding":"base64","data":"U0VMRUNUIDE7"}}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let data = try service.exportSQL(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite")) - - #expect(String(decoding: data, as: UTF8.self) == "SELECT 1;") - #expect(runner.requests[0].arguments.isEmpty) - #expect(runner.requests[0].timeoutMilliseconds == 120_000) - let requestText = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(requestText.contains(#""method":"exportSql""#)) - } - - @Test - func databaseFileBackupUsesPathProtocolAndExtendedTimeout() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result = method == "exportSqlToFile" - ? #"{"path":"/tmp/backup.sql","byteCount":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}"# - : #"{"rowsAffected":1}"# - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let output = try service.exportSQLToFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), outputURL: URL(fileURLWithPath: "/tmp/backup.sql")) - #expect(output.byteCount == 12) - #expect(runner.requests[0].timeoutMilliseconds == 120_000) - let requestText = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(requestText.contains(#""method":"exportSqlToFile""#)) - #expect(requestText.contains("outputPath")) - - _ = try service.importSQLFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), fileURL: URL(fileURLWithPath: "/tmp/backup.sql"), confirmed: true, allowWrite: true) - #expect(runner.requests[1].timeoutMilliseconds == 120_000) - - _ = try service.restoreSQLFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), fileURL: URL(fileURLWithPath: "/tmp/backup.sql"), confirmed: true, allowWrite: true) - #expect(runner.requests[2].timeoutMilliseconds == 120_000) - let restoreRequest = String(decoding: try #require(runner.requests[2].standardInput), as: UTF8.self) - #expect(restoreRequest.contains(#""method":"restoreSqlFile""#)) - #expect(DatabaseSQLExportOptions().limit == 0) - } - - @Test - func databaseSQLAnalyzerProtectsUnqualifiedAndDestructiveStatements() { - let unsafeUpdate = DatabaseSQLAnalyzer.analyze("UPDATE users SET active = 0") - #expect(unsafeUpdate.kind == .mutation) - #expect(unsafeUpdate.requiresConfirmation) - #expect(unsafeUpdate.warning?.contains("WHERE") == true) - - let safeDelete = DatabaseSQLAnalyzer.analyze("delete from users where id = 1") - #expect(safeDelete.kind == .mutation) - #expect(!safeDelete.requiresConfirmation) - - let drop = DatabaseSQLAnalyzer.analyze("-- review first\nDROP TABLE `temporary users`") - #expect(drop.kind == .definition) - #expect(drop.requiresConfirmation) - - let query = DatabaseSQLAnalyzer.analyze("SELECT 'UPDATE users SET x = 1' AS example") - #expect(query.kind == .query) - #expect(!query.requiresConfirmation) - - let invalid = DatabaseSQLAnalyzer.analyze("SELEC 1") - #expect(invalid.kind == .unknown) - #expect(!invalid.requiresConfirmation) - #expect(invalid.warning?.contains("not recognized") == true) - } - - @Test - func databaseSQLAnalyzerSplitsBatchesWithoutBreakingQuotedSemicolons() { - let analysis = DatabaseSQLAnalyzer.analyze("SELECT ';' AS marker; -- keep this comment\nSELECT 2;") - - #expect(analysis.canExecute) - #expect(analysis.statementCount == 2) - #expect(analysis.kind == .batch) - #expect(analysis.statements.first?.contains("';'") == true) - #expect(analysis.statements.last?.contains("SELECT 2") == true) - - let escapedQuote = DatabaseSQLAnalyzer.analyze(#"SELECT 'a\';b'; SELECT 2"#) - #expect(escapedQuote.statementCount == 2) - - let commentsOnly = DatabaseSQLAnalyzer.analyze("-- review later\n/* no statement */") - #expect(!commentsOnly.canExecute) - #expect(commentsOnly.statementCount == 0) - } - - @Test - @MainActor - func databaseSQLBatchRunsInOrderAndAggregatesResults() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-batch.sqlite") - let recoveryRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-batch-recovery-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let params = object["params"] as? [String: Any] - let sql = params?["sql"] as? String ?? "" - switch method { - case "exportSqlToFile": - if let path = params?["outputPath"] as? String { - try? Data().write(to: URL(fileURLWithPath: path)) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"path":"/tmp/backup.sql","byteCount":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}"#, exitCode: 0) - case "query": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rows":[{"value":1}],"columns":["value"],"truncated":false}}"#, exitCode: 0) - case "execute": - let affected = sql.contains("UPDATE") ? 3 : 2 - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rowsAffected":\#(affected)}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store, - recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot), - fileStorage: MacFileStorage() - ) - - await feature.select(profile) - feature.addSQLTab(sql: "INSERT INTO items VALUES (1); SELECT 1; UPDATE items SET value = 2 WHERE id = 1;") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID, confirmedRisk: true) - - let sqlRequests = runner.requests.compactMap { request -> (String, String)? in - guard let input = request.standardInput, - let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], - let method = object["method"] as? String, - method == "query" || method == "execute", - let params = object["params"] as? [String: Any], - let sql = params["sql"] as? String else { return nil } - return (method, sql) - } - #expect(sqlRequests.map(\.0) == ["execute", "query", "execute"]) - #expect(sqlRequests.map(\.1) == [ - "INSERT INTO items\nVALUES(1)", - "SELECT\n 1", - "UPDATE items\nSET value = 2\nWHERE id = 1" - ]) - #expect(feature.selectedSQLTab?.result?.rows.count == 1) - #expect(feature.selectedSQLTab?.rowsAffected == 5) - #expect(feature.selectedSQLTab?.execution?.rowsReturned == 1) - #expect(feature.sqlHistory.first?.sql.contains("SELECT\n 1") == true) - } - - @Test - @MainActor - func databaseSQLConfirmationAsksTheHelperBeforeProtectedWrites() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let protected = DatabaseProfile(name: "Production", kind: .sqlite, path: "/tmp/lithe-protected.sqlite", productionProtection: true) - let local = DatabaseProfile(name: "Local", kind: .sqlite, path: "/tmp/lithe-local.sqlite") - try store.save([protected, local]) - let runner = RecordingProcessRunner { request in - let object = try! JSONSerialization.jsonObject(with: try! #require(request.standardInput)) as! [String: Any] - let id = object["id"] as! String - let sql = (object["params"] as? [String: Any])?["sql"] as? String ?? "" - guard object["method"] as? String == "checkWriteProtection" else { - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - // Stand-in for the helper's lexer: the subquery WHERE does not bound the outer UPDATE. - let requires = sql.contains("SELECT") - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"requiresConfirmation":\#(requires)}}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - func checkedSQL() -> [String] { - runner.requests.compactMap { request in - guard let input = request.standardInput, - let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], - object["method"] as? String == "checkWriteProtection" else { return nil } - return (object["params"] as? [String: Any])?["sql"] as? String - } - } - - await feature.select(protected) - feature.addSQLTab(sql: "UPDATE items SET value = (SELECT max(value) FROM items WHERE id = 1)") - let tabID = try #require(feature.selectedSQLTabID) - // The local analyzer sees a WHERE token, so only the helper can ask for confirmation here. - #expect(!feature.analysis(forSQLTab: tabID).requiresConfirmation) - let risk = try #require(await feature.sqlConfirmation(forSQLTab: tabID)) - #expect(risk.requiresConfirmation) - #expect(risk.warning == "Production protection requires confirmation before this SQL runs.") - - // A bounded write and a read-only query run without a prompt; queries never reach the helper check. - feature.updateSQL("UPDATE items SET value = 2\nWHERE id = 1", in: tabID) - #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) - feature.updateSQL("SELECT 1", in: tabID) - #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) - #expect(checkedSQL().count == 2) - - // Without production protection the helper is not consulted, even for SQL it would flag. - await feature.select(local) - feature.addSQLTab(sql: "UPDATE items SET value = 2 WHERE id = (SELECT 1)") - #expect(await feature.sqlConfirmation(forSQLTab: try #require(feature.selectedSQLTabID)) == nil) - #expect(checkedSQL().count == 2) - } - - @Test - @MainActor - func databaseSQLBatchStopsAfterTheFirstFailedStatement() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-batch-failure.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let params = object["params"] as? [String: Any] - let sql = params?["sql"] as? String ?? "" - if method == "query", sql.contains("bad") { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"syntax_error","message":"near bad"}}"#, exitCode: 0) - } - if method == "query" { - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rows":[{"value":1}],"columns":["value"],"truncated":false}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - feature.addSQLTab(sql: "SELECT 1; SELECT bad; SELECT 3;") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID) - - let querySQL = runner.requests.compactMap { request -> String? in - guard let input = request.standardInput, - let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], - object["method"] as? String == "query", - let params = object["params"] as? [String: Any] else { return nil } - return params["sql"] as? String - } - #expect(querySQL == ["SELECT\n 1", "SELECT\n bad"]) - #expect(feature.selectedSQLTab?.errorMessage?.contains("Statement 2 failed") == true) - #expect(feature.sqlHistory.count == 1) - #expect(feature.sqlHistory.first?.sql == feature.selectedSQLTab?.sql) - #expect(feature.selectedSQLTab?.statementExecutions.count == 2) - #expect(feature.selectedSQLTab?.statementExecutions.last?.errorMessage != nil) - } - - @Test - func databaseQueryResultPreservesProtocolColumnOrder() throws { - let data = Data(#"{"columns":["z_col","a_col"],"rows":[{"z_col":1,"a_col":2}],"truncated":false}"#.utf8) - let result = try JSONDecoder().decode(DatabaseQueryResult.self, from: data) - #expect(result.columns == ["z_col", "a_col"]) - #expect(result.rows.first?["z_col"] == .integer(1)) - } - - @Test - func databaseMongoQueryFixtureDecodesExtendedJSONWithoutLosingDocuments() throws { - let data = Data(#"{"rows":[{"_id":{"$oid":"507f1f77bcf86cd799439011"},"empty":"","null":null,"nested":{"base64":"AAEC"},"array":[1,{"$date":"2024-01-01T00:00:00Z"}],"binary":{"$binary":{"base64":"AAEC","subType":"00"}}}],"truncated":false}"#.utf8) - let result = try JSONDecoder().decode(DatabaseQueryResult.self, from: data) - let row = try #require(result.rows.first) - #expect(row["empty"] == .string("")) - #expect(row["null"] == .null) - #expect(row["_id"] == .object(["$oid": .string("507f1f77bcf86cd799439011")])) - #expect(row["nested"] == .object(["base64": .string("AAEC")])) - #expect(row["binary"] == .object(["$binary": .object(["base64": .string("AAEC"), "subType": .string("00")])])) - } - - @Test - func databaseValuePreservesTaggedDecimalAndBinaryValues() throws { - let data = Data(#"[{"decimal":"0.00"},{"binary":"AAEC"}]"#.utf8) - let values = try JSONDecoder().decode([DatabaseValue].self, from: data) - #expect(values == [.decimal("0.00"), .binary(Data([0, 1, 2]))]) - let encoded = try JSONEncoder().encode(values) - #expect(String(decoding: encoded, as: UTF8.self).contains(#""decimal":"0.00""#)) - #expect(String(decoding: encoded, as: UTF8.self).contains(#""binary":"AAEC""#)) - let mongoObject = try JSONDecoder().decode(DatabaseValue.self, from: Data(#"{"base64":"AAEC"}"#.utf8)) - #expect(mongoObject == .object(["base64": .string("AAEC")])) - } - - @Test - func databaseSQLFormatterPreservesQuotedTextAndWordCase() { - let formatted = DatabaseSQLFormatter.format("select name, 'a b' from users where id=1;") - #expect(formatted.contains("select\n name, 'a b' from users\nwhere id=1;")) - let withComment = DatabaseSQLFormatter.format("select -- keep this text\nfrom users") - #expect(withComment.contains("-- keep this text")) - #expect(withComment.contains("\nfrom users")) - } - - @Test(arguments: [#"SELECT 'C:\', 'a,b';"#, #"SELECT E'it\'s FROM here', 'a,b';"#, #"UPDATE settings SET path='C:\', note='a,b';"#]) - func databaseSQLFormatterLeavesSessionDependentEscapesVerbatim(sql: String) { - #expect(DatabaseSQLFormatter.format(sql) == sql) - } - - @Test(arguments: ["\u{0301}a,b", "\u{0301}a''b, c", "\u{FE0F}a,b; DROP TABLE t; --"]) - func databaseSQLScannersPreserveCombiningMarksInsideLiterals(literal: String) { - let sql = "SELECT '\(literal)' AS value;" - let formatted = DatabaseSQLFormatter.format(sql) - #expect(formatted.contains("'\(literal)'")) - #expect(DatabaseSQLFormatter.format(formatted) == formatted) - let analysis = DatabaseSQLAnalyzer.analyze(formatted) - #expect(analysis.kind == .query) - #expect(analysis.statementCount == 1) - #expect(analysis.statements.first?.contains("'\(literal)'") == true) - } - - @Test(arguments: [ - "select a>=1, b<>2, c!=3, 1.25, t.column::text from t;", - #"select E'it\'s from here', X'AB', [a]]b], `from`, 'where; 😀' from t;"#, - "select 'a'\n'b' from t;", - "select t.order, t.from, t.select from t;", - "select /* outer /* nested */ comment */ a from t;", - "select a # keep FROM here\nfrom t;", - "select $$begin SELECT x FROM t; end$$;" - ]) - func databaseSQLFormatterPreservesLiteralAndOperatorTokens(sql: String) { - let formatted = DatabaseSQLFormatter.format(sql) - #expect(DatabaseSQLFormatter.format(formatted) == formatted) - if sql.contains("$$") { #expect(formatted == sql) } - else { - // Layout may change whitespace/case, but never an operator or a protected literal/comment. - for token in [">=", "<>", "!=", "1.25", "t.column::text", "t.order", "t.from", "t.select", #"E'it\'s from here'"#, "X'AB'", "[a]]b]", "`from`", "'where; 😀'", "'a'\n'b'", "/* outer /* nested */ comment */", "# keep FROM here"] where sql.contains(token) { - #expect(formatted.contains(token)) - } - } - } - - @Test - func databaseSQLHistoryIsBoundedAndKeepsOnlyProfileReferences() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profileID = UUID() - - for index in 0..<105 { - try store.appendSQLHistory(DatabaseSQLHistoryEntry( - profileID: profileID, - sql: "SELECT \(index)", - kind: .query, - executedAt: Date(timeIntervalSince1970: TimeInterval(index)), - durationMilliseconds: index - )) - } - - let history = store.loadSQLHistory() - #expect(history.count == 100) - #expect(history.first?.sql == "SELECT 104") - #expect(history.allSatisfy { $0.profileID == profileID }) - let encoded = try #require(preferences.data(forKey: "database.sql-history.v1")) - #expect(!String(decoding: encoded, as: UTF8.self).contains("password")) - } - - @Test - func databaseRecoveryStoreRoundTripsCompressedSnapshotsAndAudit() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-recovery-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: root) } - let store = MacDatabaseRecoveryStore(rootURL: root) - let profileID = UUID() - let snapshot = Data(repeating: 65, count: 128 * 1_024) - let point = try store.createRecoveryPoint(profileID: profileID, reason: "test", data: snapshot) - - #expect(point.profileID == profileID) - #expect(point.isCompressed) - #expect(point.sha256.count == 64) - #expect(try store.data(for: point) == snapshot) - - let audit = DatabaseAuditEntry(id: UUID(), profileID: profileID, action: "test", summary: "snapshot", createdAt: Date(), recoveryPointID: point.id, rowsAffected: nil, succeeded: true, errorMessage: nil) - try store.appendAudit(audit) - let loadedAudit = try #require(store.auditEntries(for: profileID).first) - #expect(loadedAudit.id == audit.id) - #expect(loadedAudit.profileID == profileID) - #expect(loadedAudit.recoveryPointID == point.id) - #expect(loadedAudit.summary == "snapshot") - - let event = DatabaseExecutionEvent( - id: UUID(), profileID: profileID, profileName: "Test DB", source: .sql, - operation: "query", startedAt: Date(timeIntervalSince1970: 1_700_000_000), durationMilliseconds: 12, - status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: "syntax error" - ) - try store.appendExecutionEvent(event) - let loadedEvent = try #require(store.executionEvents(for: profileID).first) - #expect(loadedEvent == event) - try store.deleteExecutionEvents(for: profileID) - #expect(store.executionEvents(for: profileID).isEmpty) - } - - @Test - func databaseRecoveryStoreCopiesAndValidatesFileBackups() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-recovery-file-\(UUID().uuidString)") - let source = root.appendingPathComponent("source.sql") - defer { try? FileManager.default.removeItem(at: root) } - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let contents = Data("CREATE TABLE items (id INTEGER);\n".utf8) - try contents.write(to: source) - - let store = MacDatabaseRecoveryStore(rootURL: root.appendingPathComponent("store")) - let point = try store.createRecoveryPoint(profileID: UUID(), reason: "file", fileURL: source) - #expect(!point.isCompressed) - #expect(point.originalByteCount == contents.count) - #expect(try store.fileURL(for: point).lastPathComponent == point.fileName) - #expect(try store.data(for: point) == contents) - - try Data("tampered".utf8).write(to: root.appendingPathComponent("store").appendingPathComponent(point.fileName)) - #expect(throws: CocoaError(.fileReadCorruptFile)) { try store.data(for: point) } - } - - @Test - func databaseProfilesDecodeLegacyPreferencesWithSafeDefaults() throws { - let legacy = """ - [{"id":"00000000-0000-0000-0000-000000000001","name":"Legacy","kind":"mysql","host":"127.0.0.1","port":3306,"username":"root","database":"app","path":"","ssl":false}] - """ - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - preferences.set(Data(legacy.utf8), forKey: "database.profiles.v1") - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = try #require(store.load().first) - - #expect(profile.readOnly == false) - #expect(profile.productionProtection == false) - #expect(profile.maskSensitiveFields == false) - #expect(profile.sensitiveColumnPatterns.contains("password")) - } - - @Test - func databaseSensitiveFieldMaskerMasksConfiguredColumnsWithoutChangingNulls() { - let rows: [DatabaseRow] = [[ - "id": .integer(7), - "email": .string("user@example.com"), - "api_token": .string("secret-value"), - "password": .null - ]] - - let masked = DatabaseSensitiveFieldMasker.mask( - rows: rows, - enabled: true, - patterns: ["token", "password"] - ) - - #expect(masked[0]["id"] == .integer(7)) - #expect(masked[0]["email"] == .string("user@example.com")) - #expect(masked[0]["api_token"] == .string("******")) - #expect(masked[0]["password"] == .null) - #expect(DatabaseSensitiveFieldMasker.mask(rows: rows, enabled: false, patterns: ["token"]) == rows) - } - - @Test - func databaseSchemaDiffReportsTableColumnAndDestructiveChanges() { - let source = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Source", - kind: .sqlite, - schema: "", - tables: [DatabaseSchemaTableSnapshot( - name: "users", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "email", dataType: "TEXT", isNullable: false, defaultValue: nil, isPrimaryKey: false), - DatabaseSchemaColumnSnapshot(name: "active", dataType: "INTEGER", isNullable: true, defaultValue: "1", isPrimaryKey: false) - ], - indexes: [], - foreignKeys: [] - )] - ) - let target = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Target", - kind: .sqlite, - schema: "", - tables: [DatabaseSchemaTableSnapshot( - name: "users", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "name", dataType: "TEXT", isNullable: true, defaultValue: nil, isPrimaryKey: false) - ], - indexes: [], - foreignKeys: [] - )] - ) - - let diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) - #expect(diff.items.map(\.kind).contains(.addColumn)) - #expect(diff.items.map(\.kind).contains(.dropColumn)) - #expect(diff.requiresConfirmation) - #expect(diff.migrationSQL.contains("ADD COLUMN \"email\" TEXT NOT NULL")) - #expect(diff.migrationSQL.contains("DROP COLUMN \"name\"")) - } - - @Test - func databaseSchemaDiffCreatesReferencedTablesBeforeIndexes() { - let source = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Source", - kind: .sqlite, - schema: "", - tables: [ - DatabaseSchemaTableSnapshot( - name: "posts", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "user_id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: false) - ], - indexes: [DatabaseSchemaIndexSnapshot(name: "idx_posts_user", definition: "CREATE INDEX \"idx_posts_user\" ON \"posts\" (\"user_id\")")], - foreignKeys: [DatabaseSchemaForeignKeySnapshot(name: "0", column: "user_id", referencedTable: "users", referencedColumn: "id")] - ), - DatabaseSchemaTableSnapshot( - name: "users", - columns: [DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true)], - indexes: [], - foreignKeys: [] - ) - ] - ) - let target = DatabaseSchemaSnapshot(profileID: UUID(), profileName: "Target", kind: .sqlite, schema: "", tables: []) - - let diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) - let createdTables = diff.items.filter { $0.kind == .addTable }.map(\.table) - let indexPosition = try! #require(diff.items.firstIndex { $0.id == "add-index:posts:idx_posts_user" }) - let postPosition = try! #require(diff.items.firstIndex { $0.id == "add-table:posts" }) - - #expect(createdTables == ["users", "posts"]) - #expect(indexPosition > postPosition) - #expect(diff.items[postPosition].sql.contains("FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\")")) - } - @Test func updateDownloadProgressReportsKnownAndUnknownTotals() { let progress = UpdateDownloadProgress(downloadedBytes: 512, totalBytes: 2_048) @@ -4099,78 +2790,6 @@ private final class CloseCommandTestWindow: NSWindow { } } -private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private let handler: (ProcessRequest) -> ProcessResult - private let requestsLock = NSLock() - private var recordedRequests: [ProcessRequest] = [] - - var requests: [ProcessRequest] { - requestsLock.lock() - defer { requestsLock.unlock() } - return recordedRequests - } - - init(result: ProcessResult) { - handler = { _ in result } - } - - init(handler: @escaping (ProcessRequest) -> ProcessResult) { - self.handler = handler - } - - func run(_ request: ProcessRequest) -> ProcessResult { - requestsLock.lock() - recordedRequests.append(request) - requestsLock.unlock() - return handler(request) - } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - let result = run(ProcessRequest( - executablePath: request.executablePath, - environment: request.environment, - standardInput: request.standardInput, - timeoutMilliseconds: request.timeoutMilliseconds - )) - return DatabaseProcessResult(output: result.output, exitCode: result.exitCode) - } -} - -private final class TestCounter: @unchecked Sendable { - private let lock = NSLock() - private var storage = 0 - - var value: Int { - get { - lock.lock() - defer { lock.unlock() } - return storage - } - set { - lock.lock() - storage = newValue - lock.unlock() - } - } -} - -private final class DatabaseTestKeyValueStore: KeyValueStore, DatabasePreferenceStore, @unchecked Sendable { - private var values: [String: Any] = [:] - func data(forKey key: String) -> Data? { values[key] as? Data } - func object(forKey key: String) -> Any? { values[key] } - func string(forKey key: String) -> String? { values[key] as? String } - func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } - func set(_ value: Any?, forKey key: String) { values[key] = value } -} - -private final class DatabaseTestSecureStore: SecureStore, DatabaseSecureStore, @unchecked Sendable { - private var values: [String: String] = [:] - func read(key: String) -> String? { values[key] } - func write(_ value: String, key: String) throws { values[key] = value } - func delete(key: String) throws { values.removeValue(forKey: key) } -} - @Suite("Editor documents") @MainActor struct EditorDocumentTests { @@ -7870,7 +6489,7 @@ private final class TestTerminalTransport: TerminalTransport { } } -private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseFileStorage, @unchecked Sendable { +private final class InMemoryFileStorage: FileStorage, GitShelfStorage, @unchecked Sendable { private let lock = NSLock() private let support = URL(fileURLWithPath: "/in-memory-application-support", isDirectory: true) private var files: [String: Data] = [:] @@ -8019,31 +6638,6 @@ private final class MutableKeyValueStore: KeyValueStore { func set(_ value: Any?, forKey key: String) { values[key] = value } } -private let dbxPlainConnectionExport = #""" -{ - "connections": [ - { - "id": "mysql-1", "name": "Production MySQL", "db_type": "mysql", - "host": "db.example.com", "port": 3306, "username": "root", - "password": "db-secret", "database": "app", "color": "#ff5500", - "read_only": true, "is_production": true, "ssl": true, - "ca_cert_path": "/tmp/ca.pem", - "transport_layers": [{"type":"ssh","enabled":true,"host":"jump.example.com","port":22,"user":"deploy","key_path":"/tmp/id_ed25519"}] - }, - { "id": "sqlite-1", "name": "Local SQLite", "db_type": "sqlite", "host": "/tmp/local.sqlite", "port": 0, "username": "", "password": "", "database": "" }, - { "id": "oracle-1", "name": "Oracle", "db_type": "oracle", "host": "oracle.example.com", "port": 1521, "username": "scott", "password": "tiger" } - ], - "layout": { - "groups": [{"id":"g1","name":"Production","collapsed":false},{"id":"g2","name":"Local","collapsed":false}], - "order": [{"type":"group","id":"g1","connectionIds":["mysql-1"],"children":[{"type":"group","id":"g2","connectionIds":["sqlite-1"]}]}] - } -} -"""# - -private let dbxEncryptedConnectionExport = #""" -{"format":"dbx-encrypted","version":1,"salt":"AAECAwQFBgcICQoLDA0ODw==","iv":"EBESExQVFhcYGRob","data":"fdwV5NDM/8LXPJqMyQgoQVkuOwMe+0VDPFR8HsEWD1AMIhPz1sHRRkmzd6ZLcBqnfcA57xCJz3Jtnbf+djnYI83EiNkr6iukZq1Ahd8aGy/r61/JdThx/NTaUgzn0mwAIcpxDl9uyBDwI0PO8WAaXbZyWbFumsLn3SJSEb8d"} -"""# - @Suite("Editor session coordination") @MainActor struct EditorSessionCoordinatorTests { diff --git a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift index a6f4d880d..d21f55a4b 100644 --- a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift +++ b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift @@ -8,15 +8,14 @@ import Testing @MainActor struct ModuleSessionCoordinatorTests { @Test - func releaseEventsClearOnlyTheirModuleAndCloseTheDatabaseDock() { + func releaseEventsClearOnlyTheirModule() { let runtime = ModuleRuntime() let store = ModuleCapabilityStore() let workbench = WorkbenchFeatureModel() - let database = SessionTestCapability() + let search = SessionTestCapability() let git = SessionTestCapability() - store.cache(database, id: .databaseWorkspace, moduleID: .database) + store.cache(search, id: .searchWorkspace, moduleID: .search) store.cache(git, id: .gitWorkspace, moduleID: .git) - workbench.setVisibility(.database, isVisible: true) var released: [ModuleID] = [] var changes = 0 let coordinator = ModuleSessionCoordinator( @@ -26,17 +25,16 @@ struct ModuleSessionCoordinatorTests { ) defer { coordinator.stopObserving() } - runtime.publish(ModuleEvent(source: .database, name: "module.sleeping")) + runtime.publish(ModuleEvent(source: .search, name: "module.sleeping")) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) == nil) + #expect(store.capability(.searchWorkspace, as: SessionTestCapability.self) == nil) #expect(store.capability(.gitWorkspace) === git) #expect(workbench.selectedSidebar == .project) - #expect(!workbench.isVisible(.database)) - #expect(released == [.database]) + #expect(released == [.search]) #expect(changes == 1) runtime.publish(ModuleEvent(source: .git, name: ModuleEvent.stateChangedName)) #expect(changes == 2) - #expect(released == [.database]) + #expect(released == [.search]) coordinator.stopObserving() coordinator.stopObserving() @@ -76,13 +74,10 @@ struct ModuleSessionCoordinatorTests { func repeatedShutdownRequestsShareWorkAndWaitersResumeAfterCleanup() async { let started = TestGate() let release = TestGate() - let store = ModuleCapabilityStore() - store.cache(SessionTestCapability(), id: .databaseWorkspace, moduleID: .database) var shutdowns = 0 - var released: [ModuleID] = [] let coordinator = ModuleSessionCoordinator( - runtime: ModuleRuntime(), capabilities: store, workbench: WorkbenchFeatureModel(), - onModuleReleased: { released.append($0) }, + runtime: ModuleRuntime(), capabilities: ModuleCapabilityStore(), workbench: WorkbenchFeatureModel(), + onModuleReleased: { _ in }, onChange: {}, shutdownRuntime: { shutdowns += 1 @@ -98,17 +93,14 @@ struct ModuleSessionCoordinatorTests { coordinator.beginShutdown() #expect(await started.waitUntilOpen()) #expect(shutdowns == 1) - #expect(released.isEmpty) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) != nil) release.open() await coordinator.awaitShutdown() - #expect(released == [.database]) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) == nil) + #expect(shutdowns == 1) + // A finished shutdown no longer counts as running. await coordinator.shutdown() #expect(shutdowns == 2) - #expect(released == [.database, .database]) } @Test diff --git a/macos/Tests/LitheTests/OutputTimestamperTests.swift b/macos/Tests/LitheTests/OutputTimestamperTests.swift index d79c8d5fc..d7f15a469 100644 --- a/macos/Tests/LitheTests/OutputTimestamperTests.swift +++ b/macos/Tests/LitheTests/OutputTimestamperTests.swift @@ -8,8 +8,8 @@ import Testing @Suite("Output Copy cursor ownership") @MainActor struct OutputCopyCursorTests { - @Test(arguments: [false, true]) - func databaseTextAndCopyIgnoreLateEventsOverDivider(editable: Bool) throws { + @Test + func outputTextAndCopyIgnoreLateEventsOverDivider() throws { let previousCursor = NSCursor.current let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false @@ -17,12 +17,10 @@ struct OutputCopyCursorTests { let content = try #require(window.contentView) let output = OutputNativeTextView(frame: NSRect(x: 0, y: 0, width: 160, height: 240)) output.isEditable = false; output.isSelectable = true; output.string = "output" - let console = SQLTextView(frame: NSRect(x: 180, y: 0, width: 180, height: 240)) - console.isEditable = editable; console.string = "select 1;" let copy = OutputCopyButtonHost(rootView: AnyView(Button("Copy") {})) copy.frame = NSRect(x: 100, y: 200, width: 50, height: 22) let handle = SplitHandleInteractionView(frame: NSRect(x: 160, y: 0, width: 10, height: 240)) - for view in [output, console, copy, handle] { content.addSubview(view) } + for view in [output, copy, handle] { content.addSubview(view) } output.copyButtonView = copy let point = NSPoint(x: 165, y: 120) #expect(content.hitTest(point) === handle) @@ -35,7 +33,7 @@ struct OutputCopyCursorTests { let exited = try tracking(.mouseExited, at: point) let moved = try #require(NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)) - for view in [output, console, copy] { + for view in [output, copy] { for event in [cursor, entered, moved, exited] { handle.cursorUpdate(with: cursor) switch event.type { @@ -47,16 +45,14 @@ struct OutputCopyCursorTests { #expect(NSCursor.current === NSCursor.resizeLeftRight) } } - // Normal body tracking still supplies the native text cursor (or the read-only preview arrow). + // Normal body tracking still supplies the native text cursor. output.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 20, y: 120))) #expect(NSCursor.current === NSCursor.iBeam) - console.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 200, y: 120))) - #expect(NSCursor.current === (editable ? NSCursor.iBeam : NSCursor.arrow)) copy.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 125, y: 211))) #expect(NSCursor.current === NSCursor.arrow) content.subviews.forEach { $0.removeFromSuperview() } NSCursor.resizeLeftRight.set() - output.cursorUpdate(with: cursor); console.cursorUpdate(with: cursor); copy.cursorUpdate(with: cursor) + output.cursorUpdate(with: cursor); copy.cursorUpdate(with: cursor) #expect(NSCursor.current === NSCursor.resizeLeftRight) } diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index e8f4fa1d4..315663d94 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -112,32 +112,6 @@ struct PluginManagerTests { #expect(manager.snapshots.isEmpty) } - @Test - func internalDatabaseModuleIsNotShownAsAPlugin() async throws { - let root = temporaryRoot() - defer { try? FileManager.default.removeItem(at: root) } - let preferences = PluginManagerKeyValueStore() - let configuration = MacModuleConfigurationStore(store: preferences) - let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) - let manager = MacPluginManager( - packageStore: makeStore(root: root), - moduleRuntime: runtime, - configurationStore: configuration, - settings: InMemoryPluginSettings(), - launchMode: .normal, - startup: MacPluginStartupResult( - installedPlugins: [], - registeredPlugins: [], - issues: [] - ), - managedBuiltInPlugins: [] - ) - - #expect(manager.snapshots.isEmpty) - #expect(try #require(BuiltInModuleCatalog.manifests.first { $0.id == .database }).defaultState == .enabled) - #expect(configuration.enabledState(for: .database) == nil) - } - @Test func enablingAPluginInstalledAfterLaunchPersistsPreferenceAndRequiresRestart() async throws { let root = temporaryRoot() diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index babed04d5..5829c883f 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -747,7 +747,7 @@ struct PluginWebBridgeTests { store: base.store, fileStorage: base.fileStorage, fileOperations: base.fileOperations, agentEditRestorer: base.agentEditRestorer, binaryFileViewerRegistry: base.binaryFileViewerRegistry, projectRuntimeService: base.projectRuntimeService, gitWatchContextProvider: base.gitWatchContextProvider, - githubService: base.githubService, secureStore: base.secureStore, databaseSecureStore: base.databaseSecureStore, + githubService: base.githubService, secureStore: base.secureStore, diagnosticsExportService: base.diagnosticsExportService, credentialResolver: base.credentialResolver, aiConfigurationSources: base.aiConfigurationSources, recentProjectsStore: base.recentProjectsStore, recentProjectBranchReader: base.recentProjectBranchReader, workspaceSessionStore: base.workspaceSessionStore, diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 19cc4021c..6f61eab49 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -120,8 +120,8 @@ struct TerminalPlacementFeatureModelTests { await model.shutdownProjectSession() } - @Test(arguments: [false, true]) - func documentFocusRestoresSelectionAfterTerminalOrDatabase(database: Bool) async throws { + @Test + func documentFocusRestoresSelectionAfterTerminal() async throws { let context = makeTerminalCloseContext() let model = context.model do { @@ -132,22 +132,15 @@ struct TerminalPlacementFeatureModelTests { model.editorTabOrderFeature.moveToEnd(.terminal(context.session.id)) #expect(model.editorTabOrderFeature.drop(.terminal(context.session.id), into: group, side: .right)) model.selectEditorTerminalSession(context.session) - if database { - model.editorTabOrderFeature.moveToEnd(.database(nil)) - model.editorTabOrderFeature.databaseSelected = true - model.editorTabOrderFeature.focus(.database(nil)) - } #expect(model.activeDocumentID == document.id) model.editorDidFocus(document) #expect(model.activeEditorTerminalSession == nil) - #expect(!model.isDatabaseWorkspaceSelected) #expect(model.editorTabOrderFeature.selectedItem(in: group) == .document(document.id)) model.showFindBar() #expect(model.isFindBarVisible) #expect(model.requestCloseActiveWorkbenchItem()) #expect(!model.openDocuments.contains { $0.id == document.id }) #expect(model.editorTerminalSessions.contains { $0.id == context.session.id }) - if database { #expect(model.editorTabItems.contains(.database(nil))) } } catch { await model.shutdownProjectSession(); throw error } await model.shutdownProjectSession() } diff --git a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift index 3087b18b7..92b447584 100644 --- a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift +++ b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift @@ -279,11 +279,11 @@ struct WorkbenchContributionHostTests { let model = WorkbenchFeatureModel() model.reconcileContributions([owner: [declaration]]) model.setVisibility(.terminal, isVisible: true) - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(window, isVisible: true) #expect(model.activeLeftToolWindow == window) #expect(model.activeToolWindow == .terminal) - #expect(model.activeRightToolWindow == .database) + #expect(model.activeRightToolWindow == .agent) model.customizeContribution(id, placement: .rightSidebar) #expect(model.activeLeftToolWindow == nil) #expect(model.activeRightToolWindow == window) @@ -655,29 +655,34 @@ struct WorkbenchContributionHostTests { let store = WorkbenchLayoutStore(store: MacUserDefaultsStore(defaults: defaults)) let workspace = URL(fileURLWithPath: "/fixture/workspace") let workbench = WorkbenchFeatureModel(layoutStore: store) - let windows = BuiltInModuleCatalog.contributions(for: .database) - workbench.reconcileContributions([.database: windows]) + let owner = ModuleID("org.example.ui") + let windows = [ + ModuleContribution(id: "org.example.sidebar", kind: .toolWindow, title: "Sidebar", placement: .rightSidebar), + ModuleContribution(id: "org.example.services", kind: .toolWindow, title: "Services") + ] + let sidebar = WorkbenchFeatureModel.ToolWindow(rawValue: "org.example.sidebar") + workbench.reconcileContributions([owner: windows]) _ = workbench.loadLayout(for: workspace) - workbench.setVisibility(.database, isVisible: true) - workbench.customizeContribution("database.workspace", placement: .activityBar) + workbench.setVisibility(sidebar, isVisible: true) + workbench.customizeContribution(sidebar.rawValue, placement: .activityBar) #expect(workbench.activeRightToolWindow == nil) - #expect(workbench.activeToolWindow == .database) - #expect(workbench.contributions(windows, at: .activityBar).contains { $0.id == "database.workspace" }) + #expect(workbench.activeToolWindow == sidebar) + #expect(workbench.contributions(windows, at: .activityBar).contains { $0.id == sidebar.rawValue }) store.save(WorkbenchLayout(sidebarWidth: 444, topPaneHeight: 555), for: workspace) let restored = WorkbenchFeatureModel(layoutStore: store) - restored.reconcileContributions([.database: windows]) + restored.reconcileContributions([owner: windows]) let layout = restored.loadLayout(for: workspace) #expect(layout.sidebarWidth == 444) - #expect(layout.visibleToolWindowIDs == ["database.workspace"]) - #expect(restored.contributionPreferences["database.workspace"]?.placement == .activityBar) - restored.setVisibility(.database, isVisible: true) - restored.customizeContribution("database.workspace", isHidden: true) - #expect(!restored.isVisible(.database)) - #expect(restored.contributions(windows, at: .activityBar).map(\.id) == ["database.services"]) + #expect(layout.visibleToolWindowIDs == [sidebar.rawValue]) + #expect(restored.contributionPreferences[sidebar.rawValue]?.placement == .activityBar) + restored.setVisibility(sidebar, isVisible: true) + restored.customizeContribution(sidebar.rawValue, isHidden: true) + #expect(!restored.isVisible(sidebar)) + #expect(restored.contributions(windows, at: .activityBar).map(\.id) == ["org.example.services"]) restored.reconcileContributions([:]) - restored.reconcileContributions([.database: windows]) - #expect(restored.contributionPreferences["database.workspace"]?.isHidden == true) - restored.customizeContribution("database.workspace", isHidden: false) + restored.reconcileContributions([owner: windows]) + #expect(restored.contributionPreferences[sidebar.rawValue]?.isHidden == true) + restored.customizeContribution(sidebar.rawValue, isHidden: false) #expect(restored.contributions(windows, at: .activityBar).count == 2) } @@ -862,46 +867,6 @@ struct WorkbenchContributionHostTests { } } - @Test(arguments: [false, true]) - func savedServicesWindowActivatesItsOwnerWithoutAnotherEntry(useShow: Bool) async throws { - let suite = "lithe-services-restore-" + UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let store = MacUserDefaultsStore(defaults: defaults), settings = AppSettings(store: store) - let workspace = URL(fileURLWithPath: "/fixture/services-restore") - let firstContainer = MacServiceContainer(store: store, settings: settings) - defer { firstContainer.moduleLifecycleCoordinator.stop() } - let first = AppModel(settings: settings, services: firstContainer.services) - try await WorkbenchTestScope.withCleanup({ await first.shutdownProjectSession() }) { scope in - first.workspaceSessionCoordinator.beginWorkspace(at: workspace) - _ = first.loadWorkbenchLayout(for: workspace) - try await scope.run("initial Services activation") { - await first.activateDatabaseModule(toolWindow: .databaseServices) - } - #expect(first.databaseFeatureIfActive != nil) - #expect(first.workbenchFeature.isVisible(.databaseServices)) - } - let container = MacServiceContainer(store: store, settings: settings) - defer { container.moduleLifecycleCoordinator.stop() } - let restored = AppModel(settings: settings, services: container.services) - try await WorkbenchTestScope.withCleanup({ await restored.shutdownProjectSession() }) { scope in - restored.workspaceSessionCoordinator.beginWorkspace(at: workspace) - let layout = restored.loadWorkbenchLayout(for: workspace) - let services = try #require(restored.workbenchContributions.first { $0.id == "database.services" }) - #expect(restored.workbenchFeature.isVisible(.databaseServices)) - #expect(restored.databaseFeatureIfActive == nil) - if useShow { restored.showContributionEntry(services) } - else { restored.restoreRegisteredToolWindows(layout.toolWindows?.filter(\.isVisible).map(\.id) ?? []) } - let activation = try #require(restored.contributionActivationTasks[services.id]) - try await scope.run("restored Services activation") { await activation.value } - #expect(restored.databaseFeatureIfActive != nil) - #expect(restored.workbenchFeature.isVisible(.databaseServices)) - #expect(!restored.workbenchFeature.isVisible(.database)) - #expect(restored.workbenchUIRegistry.isContentVisible(for: services, model: restored)) - #expect(try container.services.moduleRuntime.snapshot(for: .database).isInstantiated) - } - } - @Test func rebuildJavaIndexIsEnabledOnlyWithAWorkspace() throws { let suite = "lithe-rebuild-index-" + UUID().uuidString let defaults = try #require(UserDefaults(suiteName: suite)) @@ -1014,45 +979,17 @@ struct WorkbenchContributionHostTests { let workbench = WorkbenchFeatureModel() let build = DeclarativeBuildSystem(id: "make", displayName: "Make", modelSource: nil, tasks: [], testScopes: [], dependencyScopes: []) workbench.reconcileContributions([ - .database: BuiltInModuleCatalog.contributions(for: .database), .agentConversation: BuiltInModuleCatalog.contributions(for: .agentConversation), .buildSystemsExtension("org.example.make"): build.contributions(pluginID: "org.example.make", order: 0)]) let position = DockPosition(anchor: .right, group: .primary) let window = WorkbenchFeatureModel.ToolWindow( rawValue: DeclarativeBuildSystem.toolWindowID(pluginID: "org.example.make", buildSystemID: "make")) // A build system's window docks on the right like the Maven window it replaces. - #expect(workbench.tools(in: position) == [.database, .agent, window]) + #expect(workbench.tools(in: position) == [.agent, window]) workbench.moveContribution(window.rawValue, earlier: true) - #expect(workbench.tools(in: position) == [.database, window, .agent]) + #expect(workbench.tools(in: position) == [window, .agent]) workbench.moveContribution(window.rawValue, earlier: false) - #expect(workbench.tools(in: position) == [.database, .agent, window]) - } - - @Test func servicesCommandActivatesDatabaseWithoutOpeningItsSidebar() async throws { - let suite = "lithe-ui-services-" + UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let store = MacUserDefaultsStore(defaults: defaults) - let settings = AppSettings(store: store) - let container = MacServiceContainer(store: store, settings: settings) - defer { container.moduleLifecycleCoordinator.stop() } - let model = AppModel(settings: settings, services: container.services) - try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in - model.workspaceSessionCoordinator.beginWorkspace(at: URL(fileURLWithPath: "/fixture/workspace")) - #expect(model.databaseFeatureIfActive == nil) - let services = try #require(model.workbenchContributions.first { $0.id == "database.services" }) - let opened = TestGate() - let observation = model.workbenchFeature.$toolWindowStates.sink { states in - if states.contains(where: { $0.id == WorkbenchFeatureModel.ToolWindow.databaseServices.rawValue && $0.isVisible }) { opened.open() } - } - defer { observation.cancel(); opened.open() } - model.workbenchUIRegistry.perform(services, model: model) - #expect(await opened.waitUntilOpen(), "Services never completed database activation") - #expect(model.databaseFeatureIfActive != nil) - #expect(model.workbenchFeature.isVisible(.databaseServices)) - #expect(!model.workbenchFeature.isVisible(.database)) - #expect(model.workbenchUIRegistry.selectedToolContent(from: model.activityBarContributions, model: model).rendererID == "database.services") - } + #expect(workbench.tools(in: position) == [.agent, window]) } @Test func duplicateIDsFailBeforeRenderingOrChangingTheRuntime() throws { diff --git a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift index 74bf0ae91..aecfda5d3 100644 --- a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift +++ b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift @@ -36,58 +36,57 @@ struct WorkbenchFeatureModelTests { @Test func pluginsOpenInSettingsWhileARightToolRemainsVisible() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.presentSettings(category: .plugins) #expect(model.isSettingsPresented) #expect(model.requestedSettingsCategory == .plugins) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) } @Test func rightDockedToolDoesNotReplaceBottomTools() { let model = WorkbenchFeatureModel() model.setVisibility(.terminal, isVisible: true) - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) #expect(model.activeToolWindow == .terminal) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) model.setVisibility(.run, isVisible: true) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.isVisible(.run)) #expect(!model.isVisible(.terminal)) model.setVisibility(.debug, isVisible: true) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.isVisible(.debug)) } @Test func rightDockedToolAndBuildOutputCloseIndependently() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(.buildOutput, isVisible: true) model.setVisibility(.buildOutput, isVisible: false) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.activeToolWindow == nil) model.setVisibility(.buildOutput, isVisible: true) - model.toggleVisibility(.database) - #expect(!model.isVisible(.database)) + model.toggleVisibility(.agent) + #expect(!model.isVisible(.agent)) #expect(model.isVisible(.buildOutput)) - model.toggleVisibility(.database) + model.toggleVisibility(.agent) model.hideAllToolWindows() - #expect(!model.isVisible(.database)) + #expect(!model.isVisible(.agent)) #expect(model.activeToolWindow == nil) } @Test - func agentDocksBesideTheEditorAndSharesTheRightSlotWithDatabase() { + func agentDocksBesideTheEditor() { // The Agent conversation must keep the editor visible, so it takes the - // right dock instead of the bottom tool window. Database and Agent share - // that dock, and neither replaces a bottom tool window. + // right dock instead of the bottom tool window and does not replace one. let model = WorkbenchFeatureModel() model.setVisibility(.terminal, isVisible: true) model.setVisibility(.agent, isVisible: true) @@ -95,15 +94,6 @@ struct WorkbenchFeatureModelTests { #expect(model.activeToolWindow == .terminal) #expect(model.activeRightToolWindow == .agent) - model.setVisibility(.database, isVisible: true) - #expect(model.isVisible(.database)) - #expect(!model.isVisible(.agent)) - #expect(model.isVisible(.terminal)) - - model.toggleVisibility(.agent) - #expect(model.isVisible(.agent)) - #expect(!model.isVisible(.database)) - model.hideBottomToolWindow() #expect(model.isVisible(.agent)) @@ -114,10 +104,10 @@ struct WorkbenchFeatureModelTests { @Test func workspaceResetClearsRightAndBottomAreas() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(.buildOutput, isVisible: true) model.reset() - #expect(!model.isVisible(.database)) + #expect(!model.isVisible(.agent)) #expect(!model.isVisible(.buildOutput)) } @@ -131,19 +121,19 @@ struct WorkbenchFeatureModelTests { moduleLaunchMode: .safeMode ).services let model = AppModel(settings: settings, services: services) - model.workbenchFeature.setVisibility(.database, isVisible: true) + model.workbenchFeature.setVisibility(.agent, isVisible: true) model.isTerminalVisible = true model.presentLanguageNavigationResults(.references) - #expect(model.workbenchFeature.isVisible(.database)) + #expect(model.workbenchFeature.isVisible(.agent)) #expect(model.isReferencesVisible) #expect(!model.isTerminalVisible) model.isTerminalVisible = true model.presentLanguageNavigationResults(.implementations) - #expect(model.workbenchFeature.isVisible(.database)) + #expect(model.workbenchFeature.isVisible(.agent)) #expect(model.isImplementationChooserVisible) #expect(!model.isReferencesVisible) #expect(!model.isTerminalVisible) diff --git a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift index 294dbc843..5c87d7637 100644 --- a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift +++ b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift @@ -121,26 +121,6 @@ struct WorkbenchModuleUIRegistryTests { try registry.validate(contributions: contributions) } - @Test func databaseUsesTheRightDockWithoutReplacingTheProjectTreeOrTerminal() throws { - let contributions = BuiltInModuleCatalog.contributions(for: .database) - let database = try #require(contributions.first { $0.id == "database.workspace" }) - let registry = WorkbenchModuleUIComposition.builtIn - #expect(database.placement == .rightSidebar) - #expect(registry.renderer(for: database)?.rightSidebarBehavior == .docked) - try registry.validate(contributions: contributions) - - let workbench = WorkbenchFeatureModel() - workbench.setVisibility(.terminal, isVisible: true) - workbench.setVisibility(.database, isVisible: true) - #expect(workbench.selectedSidebar == .project) - #expect(workbench.isSidebarVisible) - #expect(workbench.activeToolWindow == .terminal) - #expect(workbench.activeRightToolWindow == .database) - workbench.setVisibility(.agent, isVisible: true) - #expect(!workbench.isVisible(.database)) - #expect(workbench.isVisible(.terminal)) - } - @Test func duplicateActionIDsAreRejected() { let first = WorkbenchModuleUIRegistry.Registration(actions: [ .init(id: "test.action", perform: { _ in }) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e53a20acb..d15dd23d9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -49,7 +49,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" dependencies = [ "anyhow", - "derive_more 2.1.1", + "derive_more", "schemars 1.2.2", "serde", "serde_json", @@ -58,30 +58,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -91,12 +67,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "ammonia" version = "4.1.4" @@ -133,21 +103,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "async-channel" version = "2.5.0" @@ -231,39 +186,6 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "asynchronous-codec" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" -dependencies = [ - "bytes", - "futures-sink", - "futures-util", - "memchr", - "pin-project-lite", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -328,18 +250,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -367,7 +277,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -392,21 +302,6 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] [[package]] name = "block-buffer" @@ -430,30 +325,6 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "bs58" version = "0.5.1" @@ -463,63 +334,12 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "bson" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" -dependencies = [ - "ahash 0.8.12", - "base64 0.22.1", - "bitvec", - "getrandom 0.2.17", - "getrandom 0.3.4", - "hex", - "indexmap 2.14.0", - "js-sys", - "once_cell", - "rand 0.9.5", - "serde", - "serde_bytes", - "serde_json", - "time", - "uuid", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -584,10 +404,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -602,20 +420,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "futures-core", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "comrak" version = "0.54.0" @@ -643,44 +447,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "connection-string" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.10.0" @@ -690,16 +462,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -724,21 +486,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" version = "1.5.0" @@ -748,27 +495,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -790,27 +522,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "darling" version = "0.23.0" @@ -879,7 +590,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -902,28 +613,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive-syn-parse" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive-where" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -935,19 +624,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -963,7 +639,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -980,7 +656,6 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "subtle", ] [[package]] @@ -994,12 +669,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "dtoa" version = "1.0.11" @@ -1026,9 +695,6 @@ name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -dependencies = [ - "serde", -] [[package]] name = "emojis" @@ -1039,15 +705,6 @@ dependencies = [ "phf", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "entities" version = "1.0.1" @@ -1055,69 +712,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" [[package]] -name = "enum-as-inner" -version = "0.6.1" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "enumflags2" -version = "0.7.12" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "enumflags2_derive", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ "parking", "pin-project-lite", @@ -1167,23 +781,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1203,12 +800,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.34" @@ -1264,17 +855,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.34" @@ -1364,11 +944,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1396,20 +974,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] [[package]] name = "hashbrown" @@ -1417,15 +981,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -1444,69 +999,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-proto" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.8.7", - "thiserror 1.0.69", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "lru-cache", - "once_cell", - "parking_lot", - "rand 0.8.7", - "resolv-conf", - "smallvec", - "thiserror 1.0.69", - "tokio", - "tracing", -] - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "home" version = "0.5.12" @@ -1601,11 +1093,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.43", + "rustls", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -1625,7 +1117,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "tokio", "tower-service", "tracing", @@ -1787,19 +1279,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2 0.6.5", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.1" @@ -1815,15 +1294,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1950,35 +1420,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" -dependencies = [ - "bitflags 2.13.1", - "libc", - "plain", - "redox_syscall 0.9.1", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2060,39 +1501,6 @@ dependencies = [ "zip", ] -[[package]] -name = "lithe-db-mcp" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "lithe-db-sidecar" -version = "0.1.0" -dependencies = [ - "base64 0.22.1", - "chrono", - "csv", - "futures-util", - "hex", - "mongodb", - "redis", - "reqwest", - "rust_decimal", - "serde", - "serde_json", - "sha2", - "sqlx", - "tiberius", - "tokio", - "tokio-util", - "urlencoding", - "uuid", -] - [[package]] name = "lithe-git-host" version = "0.1.0" @@ -2165,69 +1573,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "lru-cache" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "macro_magic" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" -dependencies = [ - "macro_magic_core", - "macro_magic_macros", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" -dependencies = [ - "const-random", - "derive-syn-parse", - "macro_magic_core_macros", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_core_macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" -dependencies = [ - "macro_magic_core", - "quote", - "syn 2.0.119", -] - [[package]] name = "maplit" version = "1.0.2" @@ -2251,16 +1602,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2300,67 +1641,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mongodb" -version = "3.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3788f35159bbcf461227af84711950525343b64451fdd90de4e237a0b8a13" -dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", - "bson", - "chrono", - "derive-where", - "derive_more 0.99.20", - "futures-core", - "futures-executor", - "futures-io", - "futures-util", - "hex", - "hickory-proto", - "hickory-resolver", - "hmac", - "macro_magic", - "md-5", - "mongodb-internal-macros", - "once_cell", - "pbkdf2", - "percent-encoding", - "rand 0.8.7", - "rustc_version_runtime", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "serde", - "serde_bytes", - "serde_with", - "sha1", - "sha2", - "socket2 0.5.10", - "stringprep", - "strsim", - "take_mut", - "thiserror 1.0.69", - "tokio", - "tokio-rustls 0.24.1", - "tokio-util", - "typed-builder", - "uuid", - "webpki-roots 0.25.4", -] - -[[package]] -name = "mongodb-internal-macros" -version = "3.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" -dependencies = [ - "macro_magic", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2389,20 +1669,10 @@ dependencies = [ ] [[package]] -name = "num-bigint" -version = "0.4.8" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ "lazy_static", "libm", @@ -2455,12 +1725,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "parking" version = "2.2.1" @@ -2485,7 +1749,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2496,15 +1760,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2617,18 +1872,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "polling" version = "3.11.0" @@ -2688,12 +1931,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "pretty-hex" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" - [[package]] name = "prettyplease" version = "0.2.37" @@ -2704,15 +1941,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -2722,26 +1950,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -2763,9 +1971,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls 0.23.43", - "socket2 0.6.5", - "thiserror 2.0.20", + "rustls", + "socket2", + "thiserror", "tokio", "tracing", "web-time", @@ -2784,10 +1992,10 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash 2.1.3", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror", "tinyvec", "tracing", "web-time", @@ -2802,7 +2010,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -2828,12 +2036,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.7" @@ -2919,33 +2121,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "redis" -version = "0.27.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" -dependencies = [ - "arc-swap", - "async-trait", - "bytes", - "combine", - "futures-util", - "itertools 0.13.0", - "itoa", - "num-bigint", - "percent-encoding", - "pin-project-lite", - "rustls 0.23.43", - "rustls-native-certs 0.7.3", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "ryu", - "tokio", - "tokio-rustls 0.26.4", - "tokio-util", - "url", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2955,15 +2130,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "redox_syscall" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "ref-cast" version = "1.0.26" @@ -3013,15 +2179,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -3044,14 +2201,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3061,15 +2218,9 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "ring" version = "0.17.14" @@ -3084,35 +2235,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rmcp" version = "3.5.0" @@ -3131,7 +2253,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.20", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -3159,23 +2281,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.7", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3197,16 +2302,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustc_version_runtime" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" -dependencies = [ - "rustc_version", - "semver", -] - [[package]] name = "rustix" version = "0.38.44" @@ -3233,18 +2328,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.43" @@ -3254,54 +2337,11 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.14", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe", - "rustls-pemfile 1.0.4", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3312,16 +2352,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.14" @@ -3345,15 +2375,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.9.0" @@ -3398,45 +2419,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3453,16 +2435,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -3652,12 +2624,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "1.0.3" @@ -3665,258 +2631,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64 0.22.1", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.14.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rust_decimal", - "rustls 0.23.43", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.119", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.13.1", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.7", - "rsa", - "rust_decimal", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "sqlx-postgres" -version = "0.8.6" +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.13.1", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.7", - "rust_decimal", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "sqlx-sqlite" -version = "0.8.6" +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.20", - "tracing", - "url", - "uuid", + "base64ct", + "der", ] [[package]] @@ -3968,17 +2717,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -4012,17 +2750,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -4065,18 +2792,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "take_mut" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tendril" version = "0.5.1" @@ -4086,33 +2801,13 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -4126,36 +2821,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tiberius" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" -dependencies = [ - "async-trait", - "asynchronous-codec", - "byteorder", - "bytes", - "chrono", - "connection-string", - "encoding_rs", - "enumflags2", - "futures-util", - "num-traits", - "once_cell", - "pin-project-lite", - "pretty-hex", - "rust_decimal", - "rustls-native-certs 0.6.3", - "rustls-pemfile 1.0.4", - "thiserror 1.0.69", - "tokio", - "tokio-rustls 0.24.1", - "tokio-util", - "tracing", - "uuid", -] - [[package]] name = "time" version = "0.3.55" @@ -4186,15 +2851,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -4232,7 +2888,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4248,23 +2904,13 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.43", + "rustls", "tokio", ] @@ -4471,7 +3117,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1", - "thiserror 2.0.20", + "thiserror", "utf-8", ] @@ -4481,29 +3127,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" -[[package]] -name = "typed-builder" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4519,12 +3148,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4561,12 +3184,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -4587,16 +3204,9 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -4627,12 +3237,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -4642,7 +3246,6 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", - "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -4734,21 +3337,6 @@ dependencies = [ "string_cache_codegen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -4770,22 +3358,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -4815,7 +3387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core 0.52.0", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4824,7 +3396,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4868,17 +3440,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4897,22 +3458,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4924,67 +3476,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4997,48 +3516,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5066,15 +3561,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "yoke" version = "0.8.3" @@ -5191,7 +3677,7 @@ dependencies = [ "flate2", "indexmap 2.14.0", "memchr", - "thiserror 2.0.20", + "thiserror", "zopfli", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9db96d3dc..bf2995801 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-git-host", "lithe-db-sidecar", "lithe-db-mcp", "lithe-agent-host", "lithe-ide-host", "lithe-remote-protocol", "lithe-remote-server", "lithe-remote-client"] +members = ["lithe-core", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote-protocol", "lithe-remote-server", "lithe-remote-client"] resolver = "2" [profile.release] diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index e0a7f26c1..5c4972ed3 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -14,7 +14,7 @@ //! every plugin server process. It is not bound to a workspace, so //! `${workspaceStateDir}` is refused. //! -//! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-24-database-provider-plugin.md +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md use super::declarative::{invalid, ServerProcess, ServerRole, Validation}; use super::settings::DeclaredSettings; diff --git a/rust/lithe-db-mcp/Cargo.toml b/rust/lithe-db-mcp/Cargo.toml deleted file mode 100644 index 00afd2c7d..000000000 --- a/rust/lithe-db-mcp/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "lithe-db-mcp" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -publish = false - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4"] } diff --git a/rust/lithe-db-mcp/src/main.rs b/rust/lithe-db-mcp/src/main.rs deleted file mode 100644 index 07f923462..000000000 --- a/rust/lithe-db-mcp/src/main.rs +++ /dev/null @@ -1,678 +0,0 @@ -use serde_json::{json, Map, Value}; -use std::{ - env, - fs::{self, OpenOptions}, - io::{self, BufRead, Write}, - path::PathBuf, - process::{Command, Stdio}, -}; - -fn main() { - let connections = load_json_env("LITHE_DB_MCP_CONNECTIONS").unwrap_or_else(|| json!({})); - let policy = load_json_env("LITHE_DB_MCP_POLICY").unwrap_or_else(|| json!({})); - let audit_path = env::var_os("LITHE_DB_MCP_AUDIT_LOG").map(PathBuf::from); - let recovery_dir = env::var_os("LITHE_DB_MCP_RECOVERY_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| env::temp_dir().join("lithe-db-mcp-recovery")); - - let stdin = io::stdin(); - let mut stdout = io::stdout(); - for line in stdin.lock().lines() { - let line = match line { - Ok(line) if !line.trim().is_empty() => line, - _ => continue, - }; - let request: Value = match serde_json::from_str(&line) { - Ok(request) => request, - Err(error) => { - write_response( - &mut stdout, - &json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":error.to_string()}}), - ); - continue; - } - }; - if request.get("method").and_then(Value::as_str) == Some("notifications/initialized") { - continue; - } - let response = handle_request( - &request, - &connections, - &policy, - &recovery_dir, - audit_path.as_ref(), - ); - if request.get("id").is_some() { - write_response(&mut stdout, &response); - } - } -} - -fn handle_request( - request: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Value { - let id = request.get("id").cloned().unwrap_or(Value::Null); - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or_default(); - let params = request.get("params").cloned().unwrap_or_else(|| json!({})); - let result = match method { - "initialize" => Ok(json!({ - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {"listChanged": false}}, - "serverInfo": {"name": "lithe-db-mcp", "version": env!("CARGO_PKG_VERSION")} - })), - "tools/list" => Ok(tools()), - "tools/call" => call_tool(¶ms, connections, policy, recovery_dir, audit_path), - _ => Err(format!("Unknown MCP method: {method}")), - }; - match result { - Ok(value) => json!({"jsonrpc":"2.0","id":id,"result":value}), - Err(message) => json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":message}}), - } -} - -fn tools() -> Value { - let tool = |name: &str, description: &str, properties: Value, required: &[&str]| json!({"name":name,"description":description,"inputSchema":{"type":"object","properties":properties,"required":required}}); - json!({"tools":[ - tool("db_list_connections", "List configured database aliases without revealing credentials.", json!({}), &[]), - tool("db_list_tables", "List tables, views, or MongoDB collections for a configured connection.", json!({"connection":{"type":"string"}}), &["connection"]), - tool("db_describe_table", "Read columns, indexes, foreign keys, or sampled MongoDB document fields.", json!({"connection":{"type":"string"},"table":{"type":"string"}}), &["connection","table"]), - tool("db_list_objects", "List views, routines, triggers, or sequences.", json!({"connection":{"type":"string"},"kind":{"type":"string","enum":["views","routines","triggers","sequences"]}}), &["connection","kind"]), - tool("db_plan_sql", "Classify SQL and report whether explicit write confirmation is required.", json!({"sql":{"type":"string"}}), &["sql"]), - tool("db_query", "Execute a bounded read-only SQL query, or a JSON filter against a MongoDB collection.", json!({"connection":{"type":"string"},"table":{"type":"string","description":"Required for MongoDB and interpreted as the collection name."},"sql":{"type":"string","description":"SQL text, or a MongoDB JSON filter object."},"values":{"type":"array"},"limit":{"type":"integer"}}), &["connection","sql"]), - tool("db_explain", "Inspect the database query plan without executing the query.", json!({"connection":{"type":"string"},"sql":{"type":"string"},"format":{"type":"string"}}), &["connection","sql"]), - tool("db_diagnostics", "Read table size, locks, slow queries, indexes, data quality, or schema impact diagnostics.", json!({"connection":{"type":"string"},"kind":{"type":"string"},"table":{"type":"string"}}), &["connection","kind"]), - tool("db_backup", "Create a recoverable SQL backup before an approved change.", json!({"connection":{"type":"string"},"tables":{"type":"array"}}), &["connection"]), - tool("db_execute", "Execute one approved SQL mutation. Requires policy permission and confirmed=true.", json!({"connection":{"type":"string"},"sql":{"type":"string"},"values":{"type":"array"},"confirmed":{"type":"boolean"}}), &["connection","sql","confirmed"]), - tool("db_transaction", "Execute multiple statements atomically after policy approval.", json!({"connection":{"type":"string"},"statements":{"type":"array"},"confirmed":{"type":"boolean"}}), &["connection","statements","confirmed"]), - tool("db_schema_change", "Apply a structured schema change after policy approval.", json!({"connection":{"type":"string"},"operation":{"type":"string"},"table":{"type":"string"},"name":{"type":"string"},"confirmed":{"type":"boolean"}}), &["connection","operation","confirmed"]), - tool("db_restore", "Restore a previously created backup reference after explicit confirmation.", json!({"connection":{"type":"string"},"reference":{"type":"string"},"confirmed":{"type":"boolean"}}), &["connection","reference","confirmed"]) - ]}) -} - -fn call_tool( - params: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let name = params - .get("name") - .and_then(Value::as_str) - .ok_or("tools/call requires a tool name")?; - let args = params - .get("arguments") - .cloned() - .unwrap_or_else(|| json!({})); - let result = match name { - "db_list_connections" => { - let rows = connections.as_object().unwrap_or(&Map::new()).iter().map(|(alias, value)| json!({"connection":alias,"kind":value.get("kind").and_then(Value::as_str).unwrap_or("unknown"),"host":value.get("host").and_then(Value::as_str).unwrap_or(""),"database":value.get("database").and_then(Value::as_str).unwrap_or("")})).collect::>(); - Ok(json!({"rows":rows})) - } - "db_plan_sql" => Ok(classify_sql( - args.get("sql").and_then(Value::as_str).unwrap_or_default(), - )), - "db_list_tables" => read_call("listTables", &args, connections, policy, None), - "db_describe_table" => { - let table = required_string(&args, "table")?; - read_call("describeTable", &args, connections, policy, Some(table)) - } - "db_list_objects" => read_call("listObjects", &args, connections, policy, None), - "db_query" => read_call( - "query", - &args, - connections, - policy, - args.get("table").and_then(Value::as_str), - ), - "db_explain" => read_call("explain", &args, connections, policy, None), - "db_diagnostics" => read_call("diagnostics", &args, connections, policy, None), - "db_backup" => backup_call(&args, connections, policy, recovery_dir, audit_path), - "db_execute" => write_call( - "execute", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_transaction" => write_call( - "transaction", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_schema_change" => write_call( - "schemaChange", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_restore" => restore_call(&args, connections, policy, recovery_dir, audit_path), - _ => Err(format!("Unknown tool: {name}")), - }?; - Ok(tool_result(result)) -} - -fn read_call( - method: &str, - args: &Value, - connections: &Value, - policy: &Value, - table: Option<&str>, -) -> Result { - let alias = required_string(args, "connection")?; - check_policy(policy, alias, table, "read", false)?; - let connection = connection_for(connections, alias)?; - let mut params = args.as_object().cloned().unwrap_or_default(); - params.remove("connection"); - params.insert("connection".into(), connection); - if method == "listObjects" { - params - .entry("objectKind") - .or_insert_with(|| json!(args.get("kind").and_then(Value::as_str).unwrap_or("views"))); - } - if method == "diagnostics" { - params.entry("diagnosticKind").or_insert_with(|| { - json!(args - .get("kind") - .and_then(Value::as_str) - .unwrap_or("tableSize")) - }); - } - let response = sidecar_call(method, Value::Object(params))?; - unwrap_sidecar(response) -} - -fn backup_call( - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - check_policy(policy, alias, None, "backup", true)?; - let connection = connection_for(connections, alias)?; - let tables = args.get("tables").cloned().unwrap_or_else(|| json!([])); - fs::create_dir_all(recovery_dir).map_err(|error| error.to_string())?; - let reference = recovery_dir.join(format!("{}.sql", uuid::Uuid::new_v4())); - let response = sidecar_call( - "exportSqlToFile", - json!({"connection":connection,"selectedTables":tables,"includeStructure":true,"includeData":true,"limit":0,"outputPath":reference}), - )?; - let result = unwrap_sidecar(response)?; - let output_path = result - .get("path") - .and_then(Value::as_str) - .ok_or("Sidecar backup did not return an output path")?; - if PathBuf::from(output_path) != reference || !reference.is_file() { - return Err("Sidecar backup did not create the requested recovery file".into()); - } - let bytes = result - .get("byteCount") - .and_then(Value::as_u64) - .ok_or("Sidecar backup did not return a byte count")?; - if fs::metadata(&reference) - .map_err(|error| error.to_string())? - .len() - != bytes - { - return Err("Sidecar backup size did not match the created recovery file".into()); - } - let sha256 = result - .get("sha256") - .and_then(Value::as_str) - .ok_or("Sidecar backup did not return a SHA-256 checksum")?; - append_audit( - audit_path, - json!({"action":"backup","connection":alias,"reference":reference,"bytes":bytes,"sha256":sha256,"succeeded":true}), - )?; - Ok(json!({"reference":reference,"bytes":bytes,"sha256":sha256})) -} - -fn write_call( - method: &str, - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - let confirmed = args - .get("confirmed") - .and_then(Value::as_bool) - .unwrap_or(false); - check_policy( - policy, - alias, - args.get("table").and_then(Value::as_str), - "write", - confirmed, - )?; - if !confirmed { - return Err("Write operations require confirmed=true".into()); - } - let backup = backup_call( - &json!({"connection":alias}), - connections, - policy, - recovery_dir, - audit_path, - )?; - let connection = connection_for(connections, alias)?; - let mut params = args.as_object().cloned().unwrap_or_default(); - params.remove("connection"); - params.insert("connection".into(), connection); - params.insert("allowWrite".into(), json!(true)); - params.insert("confirmed".into(), json!(true)); - if method == "transaction" { - if let Some(statements) = params.get_mut("statements") { - normalize_statements(statements); - } - } - let response = sidecar_call(method, Value::Object(params))?; - let result = unwrap_sidecar(response)?; - append_audit( - audit_path, - json!({"action":method,"connection":alias,"backup":backup,"succeeded":true}), - )?; - Ok(result) -} - -fn restore_call( - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - let confirmed = args - .get("confirmed") - .and_then(Value::as_bool) - .unwrap_or(false); - check_policy(policy, alias, None, "write", confirmed)?; - if !confirmed { - return Err("Restore requires confirmed=true".into()); - } - let reference = recovery_reference(recovery_dir, required_string(args, "reference")?)?; - let backup = backup_call( - &json!({"connection":alias}), - connections, - policy, - recovery_dir, - audit_path, - )?; - let connection = connection_for(connections, alias)?; - let response = sidecar_call( - "restoreSqlFile", - json!({"connection":connection,"outputPath":reference,"allowWrite":true,"confirmed":true}), - )?; - let result = unwrap_sidecar(response)?; - append_audit( - audit_path, - json!({"action":"restore","connection":alias,"reference":reference,"backup":backup,"succeeded":true}), - )?; - Ok(result) -} - -fn normalize_statements(statements: &mut Value) { - if let Some(items) = statements.as_array_mut() { - for item in items { - if item.get("values").is_none() { - item.as_object_mut() - .map(|object| object.insert("values".into(), json!([]))); - } - } - } -} - -fn check_policy( - policy: &Value, - connection: &str, - table: Option<&str>, - action: &str, - confirmed: bool, -) -> Result<(), String> { - let default = policy.get("default").and_then(Value::as_object); - let allowed_default = default - .and_then(|value| value.get(action)) - .and_then(Value::as_bool) - .unwrap_or(action == "read" || action == "backup"); - let mut allowed = allowed_default; - if let Some(connection_policy) = policy - .get("connections") - .and_then(|value| value.get(connection)) - .and_then(Value::as_object) - { - if let Some(value) = connection_policy.get(action).and_then(Value::as_bool) { - allowed = value; - } - if let Some(table_name) = table { - if let Some(table_policy) = connection_policy - .get("tables") - .and_then(|value| value.get(table_name)) - .and_then(Value::as_object) - { - if let Some(value) = table_policy.get(action).and_then(Value::as_bool) { - allowed = value; - } - } - } - } - if !allowed { - return Err(format!( - "Policy denied {action} access for connection {connection}" - )); - } - if action == "write" && !confirmed { - return Err("Write approval is required".into()); - } - Ok(()) -} - -fn classify_sql(sql: &str) -> Value { - let trimmed = sql.trim_start(); - let upper = trimmed.to_ascii_uppercase(); - let kind = if upper.starts_with("SELECT") - || upper.starts_with("SHOW") - || upper.starts_with("DESCRIBE") - || upper.starts_with("EXPLAIN") - || upper.starts_with("PRAGMA") - { - "query" - } else if upper.starts_with("INSERT") - || upper.starts_with("UPDATE") - || upper.starts_with("DELETE") - || upper.starts_with("MERGE") - || upper.starts_with("REPLACE") - { - "mutation" - } else if upper.starts_with("CREATE") - || upper.starts_with("ALTER") - || upper.starts_with("DROP") - || upper.starts_with("TRUNCATE") - { - "definition" - } else { - "unknown" - }; - let has_where = upper.split_whitespace().any(|token| token == "WHERE"); - let dangerous = upper.starts_with("DROP ") - || upper.starts_with("TRUNCATE ") - || (upper.starts_with("UPDATE ") && !has_where) - || (upper.starts_with("DELETE ") && !has_where); - json!({"kind":kind,"requiresConfirmation":dangerous || kind == "unknown","dangerous":dangerous,"statementCount":count_sql_statements(sql)}) -} - -fn count_sql_statements(sql: &str) -> usize { - let mut count = 0; - let mut has_content = false; - let mut quote = None; - let mut line_comment = false; - let mut block_comment = false; - let characters: Vec = sql.chars().collect(); - let mut index = 0; - - while index < characters.len() { - let character = characters[index]; - let next = characters.get(index + 1).copied(); - if line_comment { - if character == '\n' { - line_comment = false; - } - index += 1; - continue; - } - if block_comment { - if character == '*' && next == Some('/') { - block_comment = false; - index += 2; - } else { - index += 1; - } - continue; - } - if let Some(active_quote) = quote { - if character == active_quote { - if next == Some(active_quote) { - index += 2; - continue; - } - quote = None; - } - has_content = true; - index += 1; - continue; - } - if character == '-' && next == Some('-') { - line_comment = true; - index += 2; - continue; - } - if character == '/' && next == Some('*') { - block_comment = true; - index += 2; - continue; - } - if matches!(character, '\'' | '"' | '`') { - quote = Some(character); - has_content = true; - index += 1; - continue; - } - if character == ';' { - if has_content { - count += 1; - has_content = false; - } - } else if !character.is_whitespace() { - has_content = true; - } - index += 1; - } - if has_content { - count += 1; - } - count -} - -fn connection_for(connections: &Value, alias: &str) -> Result { - connections - .get(alias) - .cloned() - .ok_or_else(|| format!("Unknown database connection alias: {alias}")) -} - -fn recovery_reference(recovery_dir: &PathBuf, requested: &str) -> Result { - let root = fs::canonicalize(recovery_dir).map_err(|error| error.to_string())?; - let reference = fs::canonicalize(requested).map_err(|error| error.to_string())?; - if !reference.starts_with(&root) { - return Err("Restore reference is outside the configured recovery directory".into()); - } - Ok(reference) -} - -fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { - value - .get(key) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("Missing required argument: {key}")) -} - -fn sidecar_call(method: &str, params: Value) -> Result { - let sidecar = env::var_os("LITHE_DB_SIDECAR_EXECUTABLE") - .map(PathBuf::from) - .or_else(|| { - env::current_exe() - .ok() - .and_then(|path| path.parent().map(|parent| parent.join("lithe-db-sidecar"))) - }) - .ok_or("Database sidecar executable was not found")?; - let request = json!({"id":uuid::Uuid::new_v4().to_string(),"method":method,"params":params}); - let mut child = Command::new(sidecar) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| error.to_string())?; - child - .stdin - .take() - .ok_or("Could not open sidecar stdin")? - .write_all(request.to_string().as_bytes()) - .map_err(|error| error.to_string())?; - let output = child - .wait_with_output() - .map_err(|error| error.to_string())?; - let response: Value = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("Invalid sidecar response: {error}"))?; - if !output.status.success() { - return Err(response - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("Sidecar failed") - .into()); - } - Ok(response) -} - -fn unwrap_sidecar(response: Value) -> Result { - if response.get("ok").and_then(Value::as_bool) != Some(true) { - return Err(response - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("Sidecar request failed") - .into()); - } - response - .get("result") - .cloned() - .ok_or_else(|| "Sidecar returned no result".into()) -} - -fn tool_result(value: Value) -> Value { - json!({"content":[{"type":"text","text":serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())}]}) -} - -fn append_audit(path: Option<&PathBuf>, value: Value) -> Result<(), String> { - let Some(path) = path else { - return Ok(()); - }; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| error.to_string())?; - writeln!(file, "{}", value).map_err(|error| error.to_string()) -} - -fn load_json_env(key: &str) -> Option { - env::var(key) - .ok() - .and_then(|value| serde_json::from_str(&value).ok()) -} - -fn write_response(stdout: &mut impl Write, response: &Value) { - let _ = writeln!(stdout, "{}", response); - let _ = stdout.flush(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sql_planning_counts_real_statements_and_protects_writes() { - let select = classify_sql("SELECT 'a;b';"); - assert_eq!(select["statementCount"], 1); - assert_eq!(select["requiresConfirmation"], false); - - let update = classify_sql("UPDATE items SET name = 'x'\nWHERE id = 1"); - assert_eq!(update["statementCount"], 1); - assert_eq!(update["dangerous"], false); - - let delete = classify_sql("DELETE FROM items"); - assert_eq!(delete["dangerous"], true); - assert_eq!(delete["requiresConfirmation"], true); - - let multiple = classify_sql("SELECT 1; /* ; */ SELECT 2;"); - assert_eq!(multiple["statementCount"], 2); - } - - #[test] - fn policy_defaults_to_read_and_can_scope_writes() { - let policy = json!({ - "default": {"read": true, "write": false}, - "connections": { - "local": { - "write": true, - "tables": {"audit": {"write": false}} - } - } - }); - assert!(check_policy(&policy, "local", Some("items"), "read", false).is_ok()); - assert!(check_policy(&policy, "local", Some("items"), "write", true).is_ok()); - assert!(check_policy(&policy, "local", Some("audit"), "write", true).is_err()); - assert!(check_policy(&policy, "unknown", None, "write", true).is_err()); - } - - #[test] - fn tools_expose_automation_only_database_operations() { - let listed = tools(); - let names = listed["tools"] - .as_array() - .unwrap() - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - assert!(names.contains(&"db_query")); - assert!(names.contains(&"db_execute")); - assert!(!names.iter().any(|name| name.contains("chat"))); - assert!(!names.iter().any(|name| name.contains("agent"))); - } - - #[test] - fn recovery_reference_rejects_paths_outside_recovery_directory() { - let root = - std::env::temp_dir().join(format!("lithe-mcp-recovery-{}", uuid::Uuid::new_v4())); - let nested = root.join("nested"); - let outside = root.parent().unwrap().join(format!( - "{}-outside", - root.file_name().unwrap().to_string_lossy() - )); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("ok.sql"), "SELECT 1;").unwrap(); - std::fs::write(&outside, "SELECT 2;").unwrap(); - - let valid = recovery_reference(&root, nested.join("ok.sql").to_str().unwrap()).unwrap(); - assert!(valid.starts_with(std::fs::canonicalize(&root).unwrap())); - let escape = nested - .join("..") - .join("..") - .join(outside.file_name().unwrap()); - assert!(recovery_reference(&root, escape.to_str().unwrap()).is_err()); - - let _ = std::fs::remove_dir_all(&root); - let _ = std::fs::remove_file(outside); - } -} diff --git a/rust/lithe-db-sidecar/Cargo.toml b/rust/lithe-db-sidecar/Cargo.toml deleted file mode 100644 index a75421187..000000000 --- a/rust/lithe-db-sidecar/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "lithe-db-sidecar" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -publish = false - -[dependencies] -base64 = "0.22" -csv = "1" -futures-util = "0.3" -hex = "0.4" -chrono = { version = "0.4", features = ["serde"] } -redis = { version = "0.27", default-features = false, features = ["tokio-rustls-comp"] } -mongodb = "=3.2.5" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -rust_decimal = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "any", "mysql", "postgres", "sqlite", "json", "chrono", "rust_decimal", "uuid"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tokio-util = { version = "0.7", features = ["compat"] } -tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] } -urlencoding = "2" -uuid = { version = "1", features = ["serde"] } diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs deleted file mode 100644 index 5ef6079d5..000000000 --- a/rust/lithe-db-sidecar/src/main.rs +++ /dev/null @@ -1,4547 +0,0 @@ -mod mongodb_adapter; -mod sqlserver_adapter; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use futures_util::TryStreamExt; -use redis::AsyncCommands; -use reqwest::{Client as HttpClient, Method as HttpMethod, Url}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use sha2::{Digest, Sha256}; -use sqlx::{ - any::AnyPoolOptions, - mysql::{MySqlPoolOptions, MySqlRow}, - postgres::{PgPoolOptions, PgRow}, - sqlite::{SqlitePoolOptions, SqliteRow}, - Any, AnyPool, Column, MySql, MySqlPool, PgPool, Postgres, Row, Sqlite, SqlitePool, TypeInfo, - ValueRef, -}; -use std::{ - fs::File, - io::{self, BufReader, BufWriter, Read, Write}, - process::{Child, Command, Stdio}, - time::Duration, -}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Request { - id: String, - method: String, - #[serde(default)] - params: Value, -} - -#[derive(Serialize)] -struct Response { - id: String, - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Serialize)] -struct ErrorBody { - code: String, - message: String, -} - -#[derive(Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct Connection { - kind: String, - #[serde(default)] - host: String, - #[serde(default)] - port: u16, - #[serde(default)] - username: String, - #[serde(default)] - password: String, - #[serde(default)] - database: String, - #[serde(default)] - path: String, - #[serde(default)] - ssl: bool, - #[serde(default)] - ca_certificate_path: String, - #[serde(default)] - server_name: String, - #[serde(default)] - ssh_host: String, - #[serde(default)] - ssh_port: u16, - #[serde(default)] - ssh_username: String, - #[serde(default)] - ssh_key_path: String, - #[serde(default)] - ssh_local_port: u16, - /// Lithe's clients send `proxyURL`; `proxyUrl` is the serde default - /// this field was once read as, kept so neither spelling is dropped. - #[serde(default, rename = "proxyURL", alias = "proxyUrl")] - proxy_url: String, - #[serde(default)] - read_only: bool, - #[serde(default)] - production_protection: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Params { - connection: Connection, - #[serde(default)] - schema: String, - #[serde(default)] - table: String, - #[serde(default)] - sql: String, - #[serde(default)] - values: Vec, - #[serde(default = "default_limit")] - limit: u32, - #[serde(default)] - offset: u32, - #[serde(default)] - mutations: Vec, - #[serde(default)] - data: String, - #[serde(default)] - output_path: String, - #[serde(default)] - filters: Vec, - #[serde(default)] - sort: Vec, - #[serde(default)] - selected_tables: Vec, - #[serde(default = "default_true")] - include_structure: bool, - #[serde(default = "default_true")] - include_data: bool, - #[serde(default)] - allow_write: bool, - #[serde(default)] - confirmed: bool, - #[serde(default)] - operation: String, - #[serde(default)] - object_kind: String, - #[serde(default)] - name: String, - #[serde(default)] - old_name: String, - #[serde(default)] - data_type: String, - #[serde(default)] - nullable: bool, - #[serde(default)] - default_value: String, - #[serde(default)] - index_name: String, - #[serde(default)] - index_columns: Vec, - #[serde(default)] - constraint_name: String, - #[serde(default)] - referenced_table: String, - #[serde(default)] - referenced_columns: Vec, - #[serde(default)] - explain_format: String, - #[serde(default)] - diagnostic_kind: String, - #[serde(default)] - statements: Vec, - #[serde(default)] - cursor: String, - #[serde(default)] - pattern: String, - #[serde(default)] - count: usize, - #[serde(default = "default_true")] - include_size: bool, - #[serde(default)] - key: String, - #[serde(default)] - value: String, - #[serde(default)] - new_key: String, - #[serde(default)] - ttl: Option, - #[serde(default)] - entries: Vec, - #[serde(default)] - data_id: String, - #[serde(default)] - group: String, - #[serde(default)] - content: String, - #[serde(default)] - r#type: String, - #[serde(default)] - service_name: String, - #[serde(default)] - page: u32, - #[serde(default)] - page_size: u32, -} - -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct RedisHashEntry { - field: String, - value: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Statement { - sql: String, - #[serde(default)] - values: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Filter { - column: String, - operator: String, - #[serde(default)] - value: Value, - #[serde(default = "default_filter_join")] - join: String, -} - -#[derive(Deserialize)] -struct Sort { - column: String, - #[serde(default)] - descending: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Mutation { - action: String, - table: String, - #[serde(default)] - values: Map, - #[serde(default)] - key: Map, -} - -type DbResult = Result; -fn default_limit() -> u32 { - 200 -} -fn default_true() -> bool { - true -} -fn default_filter_join() -> String { - "and".into() -} - -#[tokio::main] -async fn main() { - sqlx::any::install_default_drivers(); - let response = match read_request() { - Ok(request) => dispatch(request).await, - Err(message) => Response::failure("unknown", "invalid_request", message), - }; - println!( - "{}", - serde_json::to_string(&response).expect("serialize response") - ); - if !response.ok { - std::process::exit(1); - } -} - -fn read_request() -> Result { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(|e| e.to_string())?; - serde_json::from_str(&input).map_err(|e| format!("Invalid request JSON: {e}")) -} - -async fn dispatch(request: Request) -> Response { - let id = request.id; - let result = if request.method == "capabilities" { - Ok(json!({ - "protocolVersion": 1, - "databaseTypes": ["mysql", "mariadb", "postgresql", "sqlite", "sqlserver", "mongodb", "redis", "nacos"], - "features": ["testConnection", "schema", "indexes", "foreignKeys", "objects", "schemaChanges", "pagedFilterSort", "transactionalCrud", "query", "explain", "diagnostics", "csvImportExport", "jsonImportExport", "sqlBackupRestore", "writeProtection", "sqlServerDataGrid", "mongoCollections", "mongoDocumentEditor", "redisScan", "redisStringHashEditor", "redisTTL", "redisFlushDatabase", "nacosConfigManagement", "nacosServiceDiscovery"] - })) - } else if request.method == "checkWriteProtection" { - check_write_protection(request.params) - } else { - database_method(&request.method, request.params).await - }; - match result { - Ok(value) => Response::success(id, value), - Err((code, message)) => Response::failure(id, code, message), - } -} - -/// Reports whether `execute` would reject `sql` until the user confirms, without -/// connecting. Clients ask before running a batch so a statement rejected by -/// production protection cannot stop it after earlier statements were applied. -fn check_write_protection(value: Value) -> DbResult { - let params: Params = - serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; - let requires_confirmation = params.connection.production_protection - && is_dangerous_sql(¶ms.connection.kind, ¶ms.sql); - Ok(json!({"requiresConfirmation": requires_confirmation})) -} - -async fn database_method(method: &str, value: Value) -> DbResult { - let params: Params = - serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; - match params.connection.kind.as_str() { - "redis" => redis_method(method, params).await, - "nacos" => nacos_method(method, params).await, - "sqlserver" => sqlserver_adapter::method(method, params).await, - "mongodb" => mongodb_adapter::method(method, params).await, - _ => sql_database_method(method, params).await, - } -} - -async fn sql_database_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let kind = normalized_kind(&connection.kind)?; - let needs_direct = matches!( - method, - "query" - | "pageTable" - | "applyChanges" - | "importCsv" - | "importJson" - | "exportCsv" - | "exportJson" - | "exportSql" - | "exportSqlToFile" - | "schemaChange" - | "transaction" - ); - let direct = if needs_direct { - Some(connect_direct(&connection).await?) - } else { - None - }; - let pool = connect(&connection).await?; - let result = match method { - "testConnection" => Ok(json!({"connected": true})), - "listDatabases" => list_databases(&pool, kind).await, - "listTables" => list_tables(&pool, kind, ¶ms.schema).await, - "describeTable" => describe_table(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listIndexes" => list_indexes(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listForeignKeys" => list_foreign_keys(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listObjects" => list_objects(&pool, kind, ¶ms.schema, ¶ms.object_kind).await, - "query" => { - query_direct( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "pageTable" => { - page_table( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - params.limit, - params.offset, - ¶ms.filters, - ¶ms.sort, - ) - .await - } - "execute" => { - ensure_write_allowed( - &connection, - ¶ms.sql, - params.confirmed, - params.allow_write, - )?; - execute(&pool, ¶ms.sql, ¶ms.values).await - } - "applyChanges" => { - ensure_mutations_allowed( - &connection, - ¶ms.mutations, - params.confirmed, - params.allow_write, - )?; - apply_changes( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.mutations, - ) - .await - } - "schemaChange" => { - ensure_write_allowed( - &connection, - "ALTER TABLE", - params.confirmed, - params.allow_write, - )?; - schema_change(direct.as_ref().unwrap(), kind, ¶ms).await - } - "explain" => explain(&pool, kind, ¶ms.sql, ¶ms.explain_format).await, - "diagnostics" => diagnostics(&pool, kind, ¶ms).await, - "transaction" => { - ensure_transaction_allowed( - &connection, - ¶ms.statements, - params.confirmed, - params.allow_write, - )?; - run_transaction(direct.as_ref().unwrap(), kind, ¶ms.statements).await - } - "exportCsv" => { - export_csv( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "exportJson" => { - export_json( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "importCsv" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - ¶ms.data, - "csv", - ) - .await - } - "importJson" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - ¶ms.data, - "json", - ) - .await - } - "exportSql" => { - export_sql( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.selected_tables, - params.include_structure, - params.include_data, - params.limit, - ) - .await - } - "exportSqlToFile" => { - export_sql_to_file( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.selected_tables, - params.include_structure, - params.include_data, - params.limit, - ¶ms.output_path, - ) - .await - } - "importSql" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - import_sql(&pool, ¶ms.data).await - } - "importSqlFile" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - import_sql_file(&pool, ¶ms.output_path).await - } - "restoreSql" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - restore_sql(&pool, kind, ¶ms.schema, ¶ms.data).await - } - "restoreSqlFile" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - restore_sql_file(&pool, kind, ¶ms.schema, ¶ms.output_path).await - } - _ => Err(("unknown_method".into(), format!("Unknown method: {method}"))), - }; - pool.close().await; - if let Some(direct) = direct { - direct.close().await; - } - drop(tunnel); - result -} - -// MARK: - Redis specialised workspace - -async fn redis_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let result = redis_method_with_connection(method, ¶ms, &connection).await; - drop(tunnel); - result -} - -async fn redis_method_with_connection( - method: &str, - params: &Params, - connection: &Connection, -) -> DbResult { - let mut redis = redis_connect(connection).await?; - let result = match method { - "testConnection" => { - let _: String = redis::cmd("PING") - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({"connected": true})) - } - "redisScan" => redis_scan(&mut redis, params, connection).await, - "redisGetKey" => redis_get_key(&mut redis, ¶ms.key, connection).await, - "redisSetString" => { - ensure_specialized_write( - connection, - "Update Redis key", - params.confirmed, - params.allow_write, - )?; - redis_set_string(&mut redis, params, connection).await?; - Ok(json!({})) - } - "redisReplaceHash" => { - ensure_specialized_write( - connection, - "Update Redis hash", - params.confirmed, - params.allow_write, - )?; - redis_replace_hash(&mut redis, params, connection).await?; - Ok(json!({})) - } - "redisDeleteKey" => { - ensure_specialized_write( - connection, - "Delete Redis key", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - let _: i64 = redis::cmd("DEL") - .arg(¶ms.key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - "redisRenameKey" => { - ensure_specialized_write( - connection, - "Rename Redis key", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - require_nonempty(¶ms.new_key, "New Redis key")?; - let _: String = redis::cmd("RENAME") - .arg(¶ms.key) - .arg(¶ms.new_key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - "redisSetTTL" => { - ensure_specialized_write( - connection, - "Update Redis TTL", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - let ttl = params - .ttl - .ok_or_else(|| ("invalid_params".into(), "Redis TTL is required".into()))?; - if ttl < -1 { - return Err(( - "invalid_params".into(), - "Redis TTL must be -1 or a non-negative number of seconds".into(), - )); - } - if ttl == -1 { - let _: i64 = redis::cmd("PERSIST") - .arg(¶ms.key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } else { - let _: i64 = redis::cmd("EXPIRE") - .arg(¶ms.key) - .arg(ttl) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Ok(json!({})) - } - "redisFlushDatabase" => { - ensure_specialized_write( - connection, - "Clear Redis database", - params.confirmed, - params.allow_write, - )?; - let _: String = redis::cmd("FLUSHDB") - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - _ => Err(( - "unknown_method".into(), - format!("Unknown Redis method: {method}"), - )), - }; - result -} - -async fn redis_connect( - connection: &Connection, -) -> Result { - let url = redis_url(connection)?; - let client = redis::Client::open(url.as_str()).map_err(|e| redis_error(e, connection))?; - let mut redis = tokio::time::timeout( - Duration::from_secs(8), - client.get_multiplexed_tokio_connection(), - ) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "Redis connection timed out after 8 seconds".into(), - ) - })? - .map_err(|e| redis_error(e, connection))?; - let db = redis_database(connection)?; - if db != 0 { - let _: String = redis::cmd("SELECT") - .arg(db) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Ok(redis) -} - -fn redis_url(connection: &Connection) -> Result { - let host = connection.host.trim(); - if host.is_empty() { - return Err(("invalid_connection".into(), "Redis host is required".into())); - } - if !connection.proxy_url.is_empty() && connection.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current Redis driver".into(), - )); - } - let scheme = if connection.ssl { "rediss" } else { "redis" }; - let port = if connection.port == 0 { - 6379 - } else { - connection.port - }; - let db = redis_database(connection)?; - let credential = if connection.password.is_empty() { - String::new() - } else if connection.username.is_empty() { - format!(":{}@", urlencoding::encode(&connection.password)) - } else { - format!( - "{}:{}@", - urlencoding::encode(&connection.username), - urlencoding::encode(&connection.password) - ) - }; - Url::parse(&format!("{scheme}://{credential}{host}:{port}/{db}")).map_err(|e| { - ( - "invalid_connection".into(), - format!("Redis address is invalid: {e}"), - ) - }) -} - -fn redis_database(connection: &Connection) -> Result { - if connection.database.trim().is_empty() { - return Ok(0); - } - connection.database.trim().parse::().map_err(|_| { - ( - "invalid_connection".into(), - "Redis database index must be a non-negative integer".into(), - ) - }) -} - -async fn redis_scan( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> DbResult { - let cursor = if params.cursor.trim().is_empty() { - 0 - } else { - params.cursor.parse::().map_err(|_| { - ( - "invalid_params".into(), - "Redis scan cursor is invalid".into(), - ) - })? - }; - let pattern = if params.pattern.trim().is_empty() { - "*" - } else { - params.pattern.trim() - }; - let count = params.count.clamp(1, 250); - let (next_cursor, keys): (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(pattern) - .arg("COUNT") - .arg(count) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let (replies, size_enabled) = match redis_scan_metadata(redis, &keys, params.include_size).await - { - Ok(replies) => (replies, params.include_size), - Err(error) if params.include_size && redis_memory_usage_unsupported(&error) => { - let replies = redis_scan_metadata(redis, &keys, false) - .await - .map_err(|e| redis_error(e, connection))?; - (replies, false) - } - Err(error) => return Err(redis_error(error, connection)), - }; - let stride = if size_enabled { 3 } else { 2 }; - if replies.len() != keys.len() * stride { - return Err(( - "redis_error".into(), - "Redis returned an incomplete scan metadata response".into(), - )); - } - let mut summaries = Vec::with_capacity(keys.len()); - for (index, key) in keys.into_iter().enumerate() { - let offset = index * stride; - let kind: String = redis::from_redis_value(&replies[offset]).map_err(|e| { - ( - "redis_error".into(), - format!("Redis returned an invalid key type: {e}"), - ) - })?; - if kind == "none" { - continue; - } - let ttl: i64 = redis::from_redis_value(&replies[offset + 1]).map_err(|e| { - ( - "redis_error".into(), - format!("Redis returned an invalid key TTL: {e}"), - ) - })?; - let size = if size_enabled { - redis::from_redis_value::>(&replies[offset + 2]) - .unwrap_or(None) - .unwrap_or(-1) - } else { - -1 - }; - summaries.push(json!({"key": key, "type": kind, "ttl": ttl, "size": size})); - } - Ok(json!({"keys": summaries, "nextCursor": next_cursor.to_string()})) -} - -async fn redis_scan_metadata( - redis: &mut redis::aio::MultiplexedConnection, - keys: &[String], - include_size: bool, -) -> redis::RedisResult> { - let mut pipeline = redis::pipe(); - for key in keys { - pipeline.cmd("TYPE").arg(key); - pipeline.cmd("TTL").arg(key); - if include_size { - pipeline.cmd("MEMORY").arg("USAGE").arg(key); - } - } - pipeline.query_async(redis).await -} - -fn redis_memory_usage_unsupported(error: &redis::RedisError) -> bool { - let message = error.to_string().to_ascii_lowercase(); - message.contains("unknown command") - || message.contains("unknown subcommand") - || message.contains("unsupported") -} - -async fn redis_key_size( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - kind: &str, - connection: &Connection, -) -> Result { - let command = match kind { - "string" => "STRLEN", - "hash" => "HLEN", - "list" => "LLEN", - "set" => "SCARD", - "zset" => "ZCARD", - "stream" => "XLEN", - _ => return Ok(0), - }; - redis::cmd(command) - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection)) -} - -async fn redis_get_key( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - connection: &Connection, -) -> DbResult { - require_nonempty(key, "Redis key")?; - let kind: String = redis::cmd("TYPE") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - if kind == "none" { - return Err(("not_found".into(), "The Redis key no longer exists".into())); - } - let ttl: i64 = redis::cmd("TTL") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let size = redis_key_size(redis, key, &kind, connection).await?; - let string_value = if kind == "string" { - Some( - redis::cmd("GET") - .arg(key) - .query_async::>(redis) - .await - .map_err(|e| redis_error(e, connection))? - .unwrap_or_default(), - ) - } else { - None - }; - let hash_entries = if kind == "hash" { - let pairs: Vec<(String, String)> = redis - .hgetall(key) - .await - .map_err(|e| redis_error(e, connection))?; - pairs - .into_iter() - .map(|(field, value)| json!({"field": field, "value": value})) - .collect() - } else { - Vec::new() - }; - Ok( - json!({"key": key, "type": kind, "ttl": ttl, "size": size, "stringValue": string_value, "hashEntries": hash_entries}), - ) -} - -async fn redis_set_string( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.key, "Redis key")?; - let prior_ttl: i64 = redis::cmd("PTTL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let _: String = redis::cmd("SET") - .arg(¶ms.key) - .arg(¶ms.value) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - redis_apply_ttl(redis, ¶ms.key, params.ttl, prior_ttl, connection).await -} - -async fn redis_replace_hash( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.key, "Redis key")?; - if params.entries.iter().any(|entry| entry.field.is_empty()) { - return Err(( - "invalid_params".into(), - "Redis hash fields cannot be empty".into(), - )); - } - let prior_ttl: i64 = redis::cmd("PTTL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let _: i64 = redis::cmd("DEL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - if params.entries.is_empty() { - return Ok(()); - } - let mut command = redis::cmd("HSET"); - command.arg(¶ms.key); - for entry in ¶ms.entries { - command.arg(&entry.field).arg(&entry.value); - } - let _: i64 = command - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - redis_apply_ttl(redis, ¶ms.key, None, prior_ttl, connection).await -} - -async fn redis_apply_ttl( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - requested_ttl: Option, - prior_ttl: i64, - connection: &Connection, -) -> Result<(), (String, String)> { - match requested_ttl { - Some(-1) => { - let _: i64 = redis::cmd("PERSIST") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Some(ttl) if ttl >= 0 => { - let _: i64 = redis::cmd("EXPIRE") - .arg(key) - .arg(ttl) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Some(_) => { - return Err(( - "invalid_params".into(), - "Redis TTL must be -1 or a non-negative number of seconds".into(), - )) - } - None if prior_ttl >= 0 => { - let _: i64 = redis::cmd("PEXPIRE") - .arg(key) - .arg(prior_ttl) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - None => {} - } - Ok(()) -} - -fn redis_error(error: redis::RedisError, connection: &Connection) -> (String, String) { - ( - "redis_error".into(), - redact(error.to_string(), &connection.password), - ) -} - -// MARK: - Nacos specialised workspace - -struct NacosClient { - http: HttpClient, - base: Url, - token: Option, -} - -async fn nacos_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let client = nacos_connect(&connection).await; - let result = match client { - Ok(mut client) => nacos_method_with_client(method, ¶ms, &connection, &mut client).await, - Err(error) => Err(error), - }; - drop(tunnel); - result -} - -async fn nacos_method_with_client( - method: &str, - params: &Params, - connection: &Connection, - client: &mut NacosClient, -) -> DbResult { - match method { - "testConnection" => { - let _ = nacos_list_configs(client, connection, "", "", 1, 1).await?; - Ok(json!({"connected": true})) - } - "nacosListConfigs" => { - nacos_list_configs( - client, - connection, - ¶ms.data_id, - ¶ms.group, - normalized_page(params.page), - normalized_page_size(params.page_size), - ) - .await - } - "nacosGetConfig" => { - nacos_get_config(client, connection, ¶ms.data_id, ¶ms.group).await - } - "nacosPublishConfig" => { - ensure_specialized_write( - connection, - "Publish Nacos configuration", - params.confirmed, - params.allow_write, - )?; - nacos_publish_config(client, connection, params).await?; - Ok(json!({})) - } - "nacosDeleteConfig" => { - ensure_specialized_write( - connection, - "Delete Nacos configuration", - params.confirmed, - params.allow_write, - )?; - nacos_delete_config(client, connection, params).await?; - Ok(json!({})) - } - "nacosListServices" => { - nacos_list_services( - client, - connection, - ¶ms.service_name, - ¶ms.group, - normalized_page(params.page), - normalized_page_size(params.page_size), - ) - .await - } - "nacosListInstances" => { - nacos_list_instances(client, connection, ¶ms.service_name, ¶ms.group).await - } - _ => Err(( - "unknown_method".into(), - format!("Unknown Nacos method: {method}"), - )), - } -} - -async fn nacos_connect(connection: &Connection) -> Result { - let base = nacos_base_url(connection)?; - let http = HttpClient::builder() - .timeout(Duration::from_secs(12)) - .build() - .map_err(|e| ("connection_failed".into(), e.to_string()))?; - let mut client = NacosClient { - http, - base, - token: None, - }; - if !connection.username.trim().is_empty() { - client.token = Some(nacos_login(&client, connection).await?); - } - Ok(client) -} - -fn nacos_base_url(connection: &Connection) -> Result { - let host = connection.host.trim(); - if host.is_empty() { - return Err(( - "invalid_connection".into(), - "Nacos server address is required".into(), - )); - } - if !connection.proxy_url.is_empty() && connection.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current Nacos driver".into(), - )); - } - let scheme = if connection.ssl { "https" } else { "http" }; - let port = if connection.port == 0 { - 8848 - } else { - connection.port - }; - let mut url = if host.starts_with("http://") || host.starts_with("https://") { - Url::parse(host).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos server address is invalid: {e}"), - ) - })? - } else { - Url::parse(&format!("{scheme}://{host}:{port}")).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos server address is invalid: {e}"), - ) - })? - }; - if url.port().is_none() { - url.set_port(Some(port)) - .map_err(|_| ("invalid_connection".into(), "Nacos port is invalid".into()))?; - } - let context = if connection.path.trim().is_empty() { - "/nacos" - } else { - connection.path.trim() - }; - let context = format!("/{}", context.trim_matches('/')); - url.set_path(&format!("{context}/")); - url.set_query(None); - Ok(url) -} - -async fn nacos_login( - client: &NacosClient, - connection: &Connection, -) -> Result { - let form = vec![ - ("username".to_string(), connection.username.clone()), - ("password".to_string(), connection.password.clone()), - ]; - let mut errors = Vec::new(); - for path in ["/v1/auth/login", "/v1/auth/users/login"] { - let response = nacos_request_raw( - &client.http, - &client.base, - HttpMethod::POST, - path, - &[], - Some(&form), - ) - .await; - match response { - Ok(response) if response.status().is_success() => { - let value: Value = response.json().await.map_err(|e| { - ( - "nacos_auth_failed".into(), - format!("Nacos login response is invalid: {e}"), - ) - })?; - if let Some(token) = value - .get("accessToken") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - return Ok(token.to_string()); - } - errors.push("Nacos login response did not contain an access token".to_string()); - } - Ok(response) => errors.push(nacos_http_error(response, connection).await.1), - Err(error) => errors.push(error.1), - } - } - Err(("nacos_auth_failed".into(), errors.join("; "))) -} - -async fn nacos_list_configs( - client: &NacosClient, - connection: &Connection, - data_id: &str, - group: &str, - page: u32, - page_size: u32, -) -> DbResult { - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/cs/configs", - &[ - ("search".to_string(), "blur".to_string()), - ("dataId".to_string(), data_id.to_string()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("pageNo".to_string(), page.to_string()), - ("pageSize".to_string(), page_size.to_string()), - ], - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let items = value.get("pageItems").or_else(|| value.get("items")).and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(|item| json!({ - "dataId": item.get("dataId").and_then(Value::as_str).unwrap_or_default(), - "group": item.get("group").or_else(|| item.get("groupName")).and_then(Value::as_str).unwrap_or("DEFAULT_GROUP"), - "namespace": item.get("tenant").or_else(|| item.get("namespace")).or_else(|| item.get("namespaceId")).and_then(Value::as_str).unwrap_or(&connection.database), - "type": item.get("type").or_else(|| item.get("configType")).and_then(Value::as_str), - "md5": item.get("md5").and_then(Value::as_str) - })).collect::>(); - let total_count = value - .get("totalCount") - .or_else(|| value.get("count")) - .and_then(Value::as_i64) - .unwrap_or(items.len() as i64); - Ok(json!({"items": items, "totalCount": total_count.max(0)})) -} - -async fn nacos_get_config( - client: &NacosClient, - connection: &Connection, - data_id: &str, - group: &str, -) -> DbResult { - require_nonempty(data_id, "Nacos data ID")?; - let group = nonempty_or(group, "DEFAULT_GROUP"); - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/cs/configs", - &[ - ("dataId".to_string(), data_id.to_string()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("show".to_string(), "all".to_string()), - ], - None, - ) - .await?; - if !response.status().is_success() { - return Err(nacos_http_error(response, connection).await); - } - let text = response.text().await.map_err(|e| { - ( - "nacos_error".into(), - format!("Unable to read Nacos configuration: {e}"), - ) - })?; - let parsed = serde_json::from_str::(&text).ok(); - let content = parsed - .as_ref() - .and_then(|value| value.get("content")) - .and_then(Value::as_str) - .unwrap_or(&text) - .to_string(); - Ok(json!({ - "dataId": parsed.as_ref().and_then(|value| value.get("dataId")).and_then(Value::as_str).unwrap_or(data_id), - "group": parsed.as_ref().and_then(|value| value.get("group")).or_else(|| parsed.as_ref().and_then(|value| value.get("groupName"))).and_then(Value::as_str).unwrap_or(group), - "namespace": parsed.as_ref().and_then(|value| value.get("tenant")).or_else(|| parsed.as_ref().and_then(|value| value.get("namespace"))).and_then(Value::as_str).unwrap_or(&connection.database), - "content": content, - "type": parsed.as_ref().and_then(|value| value.get("type")).or_else(|| parsed.as_ref().and_then(|value| value.get("configType"))).and_then(Value::as_str), - "md5": parsed.as_ref().and_then(|value| value.get("md5")).and_then(Value::as_str) - })) -} - -async fn nacos_publish_config( - client: &NacosClient, - connection: &Connection, - params: &Params, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.data_id, "Nacos data ID")?; - let group = nonempty_or(¶ms.group, "DEFAULT_GROUP"); - let mut form = vec![ - ("dataId".to_string(), params.data_id.clone()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("content".to_string(), params.content.clone()), - ]; - if !params.r#type.trim().is_empty() { - form.push(("type".to_string(), params.r#type.clone())); - } - let response = - nacos_request(client, HttpMethod::POST, "/v1/cs/configs", &[], Some(&form)).await?; - if response.status().is_success() { - Ok(()) - } else { - Err(nacos_http_error(response, connection).await) - } -} - -async fn nacos_delete_config( - client: &NacosClient, - connection: &Connection, - params: &Params, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.data_id, "Nacos data ID")?; - let group = nonempty_or(¶ms.group, "DEFAULT_GROUP"); - let response = nacos_request( - client, - HttpMethod::DELETE, - "/v1/cs/configs", - &[ - ("dataId".to_string(), params.data_id.clone()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ], - None, - ) - .await?; - if response.status().is_success() { - Ok(()) - } else { - Err(nacos_http_error(response, connection).await) - } -} - -async fn nacos_list_services( - client: &NacosClient, - connection: &Connection, - service_name: &str, - group: &str, - page: u32, - page_size: u32, -) -> DbResult { - let mut query = vec![ - ("namespaceId".to_string(), connection.database.clone()), - ("pageNo".to_string(), page.to_string()), - ("pageSize".to_string(), page_size.to_string()), - ]; - if !group.trim().is_empty() { - query.push(("groupNameParam".to_string(), group.to_string())); - } - if !service_name.trim().is_empty() { - query.push(("serviceNameParam".to_string(), service_name.to_string())); - } - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/ns/catalog/services", - &query, - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let items = value.get("serviceList").or_else(|| value.get("doms")).and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(|item| { - let raw_name = item.get("name").or_else(|| item.get("serviceName")).and_then(Value::as_str).unwrap_or_default(); - let group_name = item.get("groupName").and_then(Value::as_str).unwrap_or_else(|| raw_name.split("@@").next().filter(|_| raw_name.contains("@@")).unwrap_or("DEFAULT_GROUP")); - let name = raw_name.strip_prefix(&format!("{group_name}@@")).unwrap_or(raw_name); - json!({"name": name, "group": group_name, "clusterCount": item.get("clusterCount").and_then(Value::as_i64).unwrap_or(0).max(0)}) - }).collect::>(); - let total_count = value - .get("count") - .or_else(|| value.get("totalCount")) - .and_then(Value::as_i64) - .unwrap_or(items.len() as i64); - Ok(json!({"items": items, "totalCount": total_count.max(0)})) -} - -async fn nacos_list_instances( - client: &NacosClient, - connection: &Connection, - service_name: &str, - group: &str, -) -> DbResult { - require_nonempty(service_name, "Nacos service name")?; - let mut query = vec![ - ("serviceName".to_string(), service_name.to_string()), - ("namespaceId".to_string(), connection.database.clone()), - ]; - if !group.trim().is_empty() { - query.push(("groupName".to_string(), group.to_string())); - } - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/ns/instance/list", - &query, - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let hosts = value - .get("hosts") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - Ok(Value::Array( - hosts - .into_iter() - .map(|host| { - json!({ - "ip": host.get("ip").and_then(Value::as_str).unwrap_or_default(), - "port": host.get("port").and_then(Value::as_i64).unwrap_or(0).max(0), - "healthy": host.get("healthy").and_then(Value::as_bool).unwrap_or(false), - "enabled": host.get("enabled").and_then(Value::as_bool).unwrap_or(true), - "ephemeral": host.get("ephemeral").and_then(Value::as_bool).unwrap_or(true), - "clusterName": host.get("clusterName").and_then(Value::as_str) - }) - }) - .collect(), - )) -} - -async fn nacos_request( - client: &NacosClient, - method: HttpMethod, - path: &str, - query: &[(String, String)], - form: Option<&[(String, String)]>, -) -> Result { - let mut query = query.to_vec(); - if let Some(token) = &client.token { - query.push(("accessToken".to_string(), token.clone())); - } - nacos_request_raw(&client.http, &client.base, method, path, &query, form).await -} - -async fn nacos_request_raw( - http: &HttpClient, - base: &Url, - method: HttpMethod, - path: &str, - query: &[(String, String)], - form: Option<&[(String, String)]>, -) -> Result { - let endpoint = base.join(path.trim_start_matches('/')).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos endpoint is invalid: {e}"), - ) - })?; - let mut request = http.request(method, endpoint).query(query); - if let Some(form) = form { - request = request.form(form); - } - request - .send() - .await - .map_err(|e| ("nacos_error".into(), format!("Nacos request failed: {e}"))) -} - -async fn nacos_json( - response: reqwest::Response, - connection: &Connection, -) -> Result { - if !response.status().is_success() { - return Err(nacos_http_error(response, connection).await); - } - response.json().await.map_err(|e| { - ( - "nacos_error".into(), - format!("Nacos response is not valid JSON: {e}"), - ) - }) -} - -async fn nacos_http_error( - response: reqwest::Response, - connection: &Connection, -) -> (String, String) { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - ( - "nacos_error".into(), - redact( - format!("Nacos server returned {status}: {}", text.trim()), - &connection.password, - ), - ) -} - -fn normalized_page(page: u32) -> u32 { - page.max(1) -} -fn normalized_page_size(page_size: u32) -> u32 { - if page_size == 0 { - 100 - } else { - page_size.clamp(1, 500) - } -} -fn nonempty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str { - if value.trim().is_empty() { - fallback - } else { - value.trim() - } -} -fn require_nonempty(value: &str, label: &str) -> Result<(), (String, String)> { - if value.trim().is_empty() { - Err(("invalid_params".into(), format!("{label} is required"))) - } else { - Ok(()) - } -} - -fn ensure_specialized_write( - connection: &Connection, - action: &str, - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(("read_only".into(), "This connection is read-only".into())); - } - if connection.production_protection && !confirmed { - return Err(( - "write_confirmation_required".into(), - format!("{action} requires confirmation because production protection is enabled"), - )); - } - Ok(()) -} - -enum DirectPool { - MySql(MySqlPool), - Postgres(PgPool), - Sqlite(SqlitePool), -} - -impl DirectPool { - async fn close(self) { - match self { - Self::MySql(pool) => pool.close().await, - Self::Postgres(pool) => pool.close().await, - Self::Sqlite(pool) => pool.close().await, - } - } -} - -async fn connect_direct(connection: &Connection) -> Result { - let url = connection.url()?; - let error = |error: sqlx::Error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }; - match normalized_kind(&connection.kind)? { - "mysql" => MySqlPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::MySql) - .map_err(error), - "postgresql" => PgPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::Postgres) - .map_err(error), - _ => SqlitePoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::Sqlite) - .map_err(error), - } -} - -fn normalized_kind(kind: &str) -> Result<&str, (String, String)> { - match kind { - "mysql" | "mariadb" => Ok("mysql"), - "postgres" | "postgresql" => Ok("postgresql"), - "sqlite" => Ok("sqlite"), - _ => Err(( - "unsupported_database".into(), - format!("Unsupported database type: {kind}"), - )), - } -} - -impl Connection { - fn url(&self) -> Result { - match normalized_kind(&self.kind)? { - "sqlite" if self.path.is_empty() => Err(( - "invalid_connection".into(), - "SQLite path is required".into(), - )), - "sqlite" => Ok(format!("sqlite://{}?mode=rwc", self.path)), - kind if self.host.is_empty() || self.username.is_empty() => Err(( - "invalid_connection".into(), - format!("Host and username are required for {kind}"), - )), - kind => { - let port = if self.port > 0 { - self.port - } else if kind == "mysql" { - 3306 - } else { - 5432 - }; - let scheme = if kind == "mysql" { "mysql" } else { "postgres" }; - if !self.proxy_url.is_empty() && self.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current database driver".into(), - )); - } - let mut query = Vec::new(); - if kind == "postgresql" { - query.push(if self.ssl { - "sslmode=require".to_string() - } else { - "sslmode=disable".to_string() - }); - if !self.ca_certificate_path.is_empty() { - query.push(format!( - "sslrootcert={}", - urlencoding::encode(&self.ca_certificate_path) - )); - } - if !self.server_name.is_empty() { - query.push(format!("host={}", urlencoding::encode(&self.server_name))); - } - } else if self.ssl { - query.push("ssl-mode=REQUIRED".to_string()); - if !self.ca_certificate_path.is_empty() { - query.push(format!( - "ssl-ca={}", - urlencoding::encode(&self.ca_certificate_path) - )); - } - } - let suffix = if query.is_empty() { - String::new() - } else { - format!("?{}", query.join("&")) - }; - Ok(format!( - "{scheme}://{}:{}@{}:{port}/{}{suffix}", - urlencoding::encode(&self.username), - urlencoding::encode(&self.password), - self.host, - self.database - )) - } - } - } - - fn with_tunnel(&self) -> Result { - if self.ssh_local_port == 0 { - return Err(( - "invalid_ssh_tunnel".into(), - "SSH local port is required when using a tunnel".into(), - )); - } - let mut connection = self.clone(); - connection.host = "127.0.0.1".into(); - connection.port = self.ssh_local_port; - Ok(connection) - } -} - -struct SshTunnel { - child: Child, -} - -/// Ends the `ssh` client once the method that opened the tunnel is done, -/// whether it finished, returned early with an error, or was cancelled by -/// dropping its future. Without this the client outlives the method and -/// keeps holding the local port. -impl Drop for SshTunnel { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -async fn open_ssh_tunnel(connection: &Connection) -> Result, (String, String)> { - if connection.ssh_host.is_empty() { - return Ok(None); - } - if connection.ssh_username.is_empty() || connection.ssh_local_port == 0 { - return Err(( - "invalid_ssh_tunnel".into(), - "SSH host, username, and local port are required".into(), - )); - } - let target_port = if connection.port > 0 { - connection.port - } else { - match connection.kind.as_str() { - "mysql" | "mariadb" => 3306, - "sqlserver" => 1433, - "mongodb" => 27017, - "redis" => 6379, - "nacos" => 8848, - _ => 5432, - } - }; - let target = format!("{}:{target_port}", connection.host); - let destination = format!("{}@{}", connection.ssh_username, connection.ssh_host); - let mut command = Command::new("ssh"); - command - .arg("-N") - .arg("-o") - .arg("BatchMode=yes") - .arg("-o") - .arg("ExitOnForwardFailure=yes") - .arg("-L") - .arg(format!("{}:{}", connection.ssh_local_port, target)) - .arg("-p") - .arg( - if connection.ssh_port == 0 { - 22 - } else { - connection.ssh_port - } - .to_string(), - ); - if !connection.ssh_key_path.is_empty() { - command.arg("-i").arg(&connection.ssh_key_path); - } - if !connection.proxy_url.is_empty() { - command - .arg("-o") - .arg(format!("ProxyCommand={}", connection.proxy_url)); - } - let mut child = command - .arg(destination) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| ("ssh_tunnel_failed".into(), error.to_string()))?; - tokio::time::sleep(Duration::from_millis(180)).await; - if let Some(status) = child - .try_wait() - .map_err(|error| ("ssh_tunnel_failed".into(), error.to_string()))? - { - return Err(( - "ssh_tunnel_failed".into(), - format!("SSH tunnel exited with status {status}"), - )); - } - Ok(Some(SshTunnel { child })) -} - -async fn connect(connection: &Connection) -> Result { - AnyPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&connection.url()?) - .await - .map_err(|e| { - ( - "connection_failed".into(), - redact(e.to_string(), &connection.password), - ) - }) -} - -async fn list_tables(pool: &AnyPool, kind: &str, schema: &str) -> DbResult { - let sql = match kind { - "mysql" => "SELECT table_name AS table_name, CAST(table_type AS CHAR) AS table_type FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name", - "postgresql" => "SELECT CAST(table_name AS TEXT) AS table_name, CAST(table_type AS TEXT) AS table_type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name", - _ => "SELECT name AS table_name, type AS table_type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name", - }; - let mut q = sqlx::query(sql); - if kind == "postgresql" { - q = q.bind(if schema.is_empty() { "public" } else { schema }); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_databases(pool: &AnyPool, kind: &str) -> DbResult { - let sql = match kind { - "mysql" => "SELECT SCHEMA_NAME AS name FROM information_schema.schemata ORDER BY SCHEMA_NAME", - "postgresql" => "SELECT schema_name AS name FROM information_schema.schemata WHERE catalog_name = current_database() ORDER BY schema_name", - _ => return Ok(json!([])), - }; - let rows = rows_value(sqlx::query(sql).fetch_all(pool).await.map_err(db_error)?)?; - let names = rows - .as_array() - .into_iter() - .flat_map(|rows| rows.iter()) - .filter_map(|row| row.get("name").and_then(Value::as_str)) - .map(|name| Value::String(name.to_string())) - .collect(); - Ok(Value::Array(names)) -} - -async fn describe_table(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT column_name AS column_name, CAST(data_type AS CHAR) AS data_type, CAST(is_nullable AS CHAR) AS is_nullable, CAST(column_default AS CHAR) AS column_default, CAST(column_key AS CHAR) AS column_key FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position".to_string(), - "postgresql" => r#"SELECT CAST(c.column_name AS TEXT) AS column_name, CAST(c.data_type AS TEXT) AS data_type, - CAST(c.is_nullable AS TEXT) AS is_nullable, CAST(c.column_default AS TEXT) AS column_default, - CASE WHEN EXISTS ( - SELECT 1 FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name=kcu.constraint_name AND tc.constraint_schema=kcu.constraint_schema - WHERE tc.constraint_type='PRIMARY KEY' AND tc.table_schema=c.table_schema - AND tc.table_name=c.table_name AND kcu.column_name=c.column_name - ) THEN 'PRI' ELSE '' END AS column_key - FROM information_schema.columns c - WHERE c.table_schema=$1 AND c.table_name=$2 ORDER BY c.ordinal_position"#.to_string(), - _ => format!("PRAGMA table_info({})", quote_identifier(kind, table)?), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - metadata_rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_indexes(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT CAST(index_name AS CHAR) AS index_name, CAST(CONCAT('CREATE ', CASE WHEN index_type = 'FULLTEXT' THEN 'FULLTEXT ' WHEN index_type = 'SPATIAL' THEN 'SPATIAL ' WHEN non_unique = 0 THEN 'UNIQUE ' ELSE '' END, 'INDEX `', REPLACE(index_name, '`', '``'), '` ON `', REPLACE(table_name, '`', '``'), '` (', GROUP_CONCAT(CONCAT('`', REPLACE(column_name, '`', '``'), '`', CASE WHEN sub_part IS NULL THEN '' ELSE CONCAT('(', sub_part, ')') END, CASE WHEN collation = 'D' THEN ' DESC' ELSE '' END) ORDER BY seq_in_index SEPARATOR ', '), ')') AS CHAR) AS definition FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name <> 'PRIMARY' GROUP BY index_name, index_type, non_unique, table_name ORDER BY index_name".to_string(), - "postgresql" => "SELECT CAST(indexname AS TEXT) AS index_name, CAST(indexdef AS TEXT) AS definition FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 ORDER BY indexname".to_string(), - _ => "SELECT name AS index_name, sql AS definition FROM sqlite_master WHERE type='index' AND tbl_name = ? AND sql IS NOT NULL ORDER BY name".to_string(), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "sqlite" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_foreign_keys(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT constraint_name, column_name, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND table_name = ? AND referenced_table_name IS NOT NULL ORDER BY constraint_name, ordinal_position".to_string(), - "postgresql" => "SELECT CAST(tc.constraint_name AS TEXT) AS constraint_name, CAST(kcu.column_name AS TEXT) AS column_name, CAST(ccu.table_name AS TEXT) AS referenced_table_name, CAST(ccu.column_name AS TEXT) AS referenced_column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name=kcu.constraint_name AND tc.constraint_schema=kcu.constraint_schema JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.constraint_schema=tc.constraint_schema WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema=$1 AND tc.table_name=$2 ORDER BY tc.constraint_name, kcu.ordinal_position".to_string(), - _ => format!("PRAGMA foreign_key_list({})", quote_identifier(kind, table)?), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_objects(pool: &AnyPool, kind: &str, schema: &str, object_kind: &str) -> DbResult { - let requested = if object_kind.is_empty() { - "all" - } else { - object_kind - }; - let sql = match (kind, requested) { - ("mysql", "views") => "SELECT table_name AS object_name, 'view' AS object_kind, view_definition AS definition FROM information_schema.views WHERE table_schema=DATABASE() ORDER BY table_name".to_string(), - ("mysql", "routines" | "procedures" | "functions") => "SELECT routine_name AS object_name, routine_type AS object_kind, routine_definition AS definition FROM information_schema.routines WHERE routine_schema=DATABASE() ORDER BY routine_name".to_string(), - ("mysql", "triggers") => "SELECT trigger_name AS object_name, 'trigger' AS object_kind, action_statement AS definition FROM information_schema.triggers WHERE trigger_schema=DATABASE() ORDER BY trigger_name".to_string(), - ("mysql", "sequences") => "SELECT CAST(NULL AS CHAR) AS object_name, 'sequence' AS object_kind, CAST(NULL AS CHAR) AS definition WHERE 1=0".to_string(), - ("mysql", _) => "SELECT table_name AS object_name, table_type AS object_kind, CAST(NULL AS CHAR) AS definition FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY table_name".to_string(), - ("postgresql", "views") => "SELECT CAST(viewname AS TEXT) AS object_name, 'view' AS object_kind, CAST(definition AS TEXT) AS definition FROM pg_views WHERE schemaname=$1 ORDER BY viewname".to_string(), - ("postgresql", "routines" | "procedures" | "functions") => "SELECT CAST(p.proname AS TEXT) AS object_name, CASE WHEN p.prokind='p' THEN 'procedure' ELSE 'function' END AS object_kind, CAST(pg_get_functiondef(p.oid) AS TEXT) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname=$1 ORDER BY p.proname".to_string(), - ("postgresql", "triggers") => "SELECT CAST(t.tgname AS TEXT) AS object_name, 'trigger' AS object_kind, CAST(pg_get_triggerdef(t.oid) AS TEXT) AS definition FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND NOT t.tgisinternal ORDER BY t.tgname".to_string(), - ("postgresql", "sequences") => "SELECT CAST(c.relname AS TEXT) AS object_name, 'sequence' AS object_kind, CAST(NULL AS TEXT) AS definition FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relkind='S' ORDER BY c.relname".to_string(), - ("postgresql", _) => "SELECT CAST(c.relname AS TEXT) AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'S' THEN 'sequence' ELSE c.relkind::TEXT END AS object_kind, CAST(NULL AS TEXT) AS definition FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relkind IN ('r','v','S') ORDER BY c.relname".to_string(), - ("sqlite", "views") => "SELECT name AS object_name, 'view' AS object_kind, sql AS definition FROM sqlite_master WHERE type='view' ORDER BY name".to_string(), - ("sqlite", "triggers") => "SELECT name AS object_name, 'trigger' AS object_kind, sql AS definition FROM sqlite_master WHERE type='trigger' ORDER BY name".to_string(), - ("sqlite", "sequences" | "routines" | "procedures" | "functions") => "SELECT NULL AS object_name, 'unsupported' AS object_kind, NULL AS definition WHERE 1=0".to_string(), - ("sqlite", _) => "SELECT name AS object_name, type AS object_kind, sql AS definition FROM sqlite_master WHERE type IN ('table','view','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY name".to_string(), - _ => return Err(("unsupported_database".into(), "Unsupported database type".into())), - }; - let mut query = sqlx::query(&sql); - if kind == "postgresql" { - query = query.bind(if schema.is_empty() { "public" } else { schema }); - } - rows_value(query.fetch_all(pool).await.map_err(db_error)?) -} - -async fn schema_change(direct: &DirectPool, kind: &str, params: &Params) -> DbResult { - let table = params.table.trim(); - validate_identifier(table)?; - let qualified = if !params.schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, ¶ms.schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let operation = params.operation.as_str(); - let sql = match operation { - "addColumn" => { - validate_identifier(¶ms.name)?; - let data_type = safe_sql_fragment(¶ms.data_type, "column type")?; - let mut sql = format!( - "ALTER TABLE {qualified} ADD COLUMN {} {data_type}", - quote_identifier(kind, ¶ms.name)? - ); - if !params.nullable { - sql.push_str(" NOT NULL"); - } - if !params.default_value.is_empty() { - sql.push_str(&format!( - " DEFAULT {}", - safe_sql_fragment(¶ms.default_value, "default")? - )); - } - sql - } - "renameColumn" => { - validate_identifier(¶ms.old_name)?; - validate_identifier(¶ms.name)?; - format!( - "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", - quote_identifier(kind, ¶ms.old_name)?, - quote_identifier(kind, ¶ms.name)? - ) - } - "dropColumn" => { - validate_identifier(¶ms.name)?; - format!( - "ALTER TABLE {qualified} DROP COLUMN {}", - quote_identifier(kind, ¶ms.name)? - ) - } - "createIndex" => { - validate_identifier(¶ms.index_name)?; - let columns = quoted_columns(kind, ¶ms.index_columns)?; - if columns.is_empty() { - return Err(( - "invalid_schema_change".into(), - "At least one index column is required".into(), - )); - } - format!( - "CREATE INDEX {} ON {qualified} ({})", - quote_identifier(kind, ¶ms.index_name)?, - columns.join(", ") - ) - } - "dropIndex" => { - validate_identifier(¶ms.index_name)?; - if kind == "mysql" { - format!( - "DROP INDEX {} ON {qualified}", - quote_identifier(kind, ¶ms.index_name)? - ) - } else { - format!("DROP INDEX {}", quote_identifier(kind, ¶ms.index_name)?) - } - } - "addForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - validate_identifier(¶ms.referenced_table)?; - let columns = quoted_columns(kind, ¶ms.index_columns)?; - let referenced = quoted_columns(kind, ¶ms.referenced_columns)?; - if columns.is_empty() || referenced.is_empty() || columns.len() != referenced.len() { - return Err(( - "invalid_schema_change".into(), - "Foreign-key columns must be paired".into(), - )); - } - format!( - "ALTER TABLE {qualified} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", - quote_identifier(kind, ¶ms.constraint_name)?, - columns.join(", "), - quote_identifier(kind, ¶ms.referenced_table)?, - referenced.join(", ") - ) - } - "dropForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - if kind == "mysql" { - format!( - "ALTER TABLE {qualified} DROP FOREIGN KEY {}", - quote_identifier(kind, ¶ms.constraint_name)? - ) - } else { - format!( - "ALTER TABLE {qualified} DROP CONSTRAINT {}", - quote_identifier(kind, ¶ms.constraint_name)? - ) - } - } - "createView" => { - validate_identifier(¶ms.name)?; - if params.sql.trim().is_empty() { - return Err(( - "invalid_schema_change".into(), - "View SQL is required".into(), - )); - } - format!( - "CREATE VIEW {} AS {}", - quote_identifier(kind, ¶ms.name)?, - require_single_sql(¶ms.sql)? - ) - } - "dropView" => { - validate_identifier(¶ms.name)?; - format!("DROP VIEW {}", quote_identifier(kind, ¶ms.name)?) - } - _ => { - return Err(( - "invalid_schema_change".into(), - format!("Unsupported schema operation: {operation}"), - )) - } - }; - execute_direct(direct, &sql, &[]).await -} - -fn quoted_columns(kind: &str, columns: &[String]) -> Result, (String, String)> { - columns - .iter() - .map(|column| { - validate_identifier(column)?; - quote_identifier(kind, column) - }) - .collect() -} - -fn safe_sql_fragment(value: &str, label: &str) -> Result { - let value = value.trim(); - if value.is_empty() - || value.contains(';') - || value.contains("--") - || value.contains("/*") - || value.contains("*/") - || value.chars().any(|character| character.is_control()) - { - return Err(("invalid_schema_change".into(), format!("Invalid {label}"))); - } - Ok(value.to_string()) -} - -fn require_single_sql(sql: &str) -> Result { - let trimmed = sql.trim(); - if trimmed.is_empty() || trimmed.contains(';') { - return Err(( - "invalid_query".into(), - "A single SQL statement is required".into(), - )); - } - Ok(trimmed.to_string()) -} - -async fn page_table( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - table: &str, - limit: u32, - offset: u32, - filters: &[Filter], - sort: &[Sort], -) -> DbResult { - validate_identifier(table)?; - let qualified = if !schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let (where_sql, values) = filter_clause(kind, filters)?; - let order_sql = sort_clause(kind, sort)?; - let limit = limit.clamp(1, 1000); - let sql = - format!("SELECT * FROM {qualified}{where_sql}{order_sql} LIMIT {limit} OFFSET {offset}"); - let count_sql = format!("SELECT COUNT(*) AS total_rows FROM {qualified}{where_sql}"); - let mut result = query_direct(direct, &sql, &values, limit).await?; - let count = bind_values(sqlx::query(&count_sql), &values)? - .fetch_one(pool) - .await - .map_err(db_error)?; - let total = count.try_get::(0).map_err(db_error)?; - result - .as_object_mut() - .expect("query result object") - .insert("totalRows".into(), json!(total)); - Ok(result) -} - -fn filter_clause(kind: &str, filters: &[Filter]) -> Result<(String, Vec), (String, String)> { - let mut predicates = Vec::new(); - let mut values = Vec::new(); - for filter in filters { - let column = quote_identifier(kind, &filter.column)?; - let index = values.len() + 1; - let predicate = match filter.operator.as_str() { - "equals" => { - values.push(filter.value.clone()); - format!("{column} = {}", placeholder(kind, index)) - } - "notEquals" => { - values.push(filter.value.clone()); - format!("{column} <> {}", placeholder(kind, index)) - } - "greaterThan" => { - values.push(filter.value.clone()); - format!("{column} > {}", placeholder(kind, index)) - } - "lessThan" => { - values.push(filter.value.clone()); - format!("{column} < {}", placeholder(kind, index)) - } - "contains" => { - values.push(Value::String(format!( - "%{}%", - scalar_string(&filter.value)? - ))); - format!("{column} LIKE {}", placeholder(kind, index)) - } - "startsWith" => { - values.push(Value::String(format!("{}%", scalar_string(&filter.value)?))); - format!("{column} LIKE {}", placeholder(kind, index)) - } - "isNull" => format!("{column} IS NULL"), - "isNotNull" => format!("{column} IS NOT NULL"), - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter operator: {value}"), - )) - } - }; - let join = match filter.join.as_str() { - "and" => "AND", - "or" => "OR", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter join: {value}"), - )) - } - }; - predicates.push((join, predicate)); - } - Ok(( - if predicates.is_empty() { - String::new() - } else { - let mut clause = predicates[0].1.clone(); - for (join, predicate) in predicates.iter().skip(1) { - clause.push_str(&format!(" {join} {predicate}")); - } - format!(" WHERE ({clause})") - }, - values, - )) -} - -fn sort_clause(kind: &str, sort: &[Sort]) -> Result { - let mut items = Vec::new(); - for item in sort { - items.push(format!( - "{} {}", - quote_identifier(kind, &item.column)?, - if item.descending { "DESC" } else { "ASC" } - )); - } - Ok(if items.is_empty() { - String::new() - } else { - format!(" ORDER BY {}", items.join(", ")) - }) -} - -fn scalar_string(value: &Value) -> Result { - match value { - Value::String(value) => Ok(value.clone()), - Value::Number(value) => Ok(value.to_string()), - Value::Bool(value) => Ok(value.to_string()), - _ => Err(( - "invalid_filter".into(), - "Text filters require a scalar value".into(), - )), - } -} - -async fn query_direct(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - require_sql(sql)?; - let maximum = limit.clamp(1, 100_000) as usize; - let mut rows = Vec::with_capacity(maximum.min(200)); - let (truncated, columns) = match pool { - DirectPool::MySql(pool) => { - let mut stream = bind_mysql(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, mysql_row_json).await? - } - DirectPool::Postgres(pool) => { - let mut stream = bind_postgres(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, postgres_row_json).await? - } - DirectPool::Sqlite(pool) => { - let mut stream = bind_sqlite(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, sqlite_row_json).await? - } - }; - Ok(json!({"columns": columns, "truncated": truncated, "rows": rows})) -} - -async fn collect_query_rows<'a, R, S>( - stream: &mut S, - maximum: usize, - destination: &mut Vec, - decode: fn(&R) -> Result, -) -> Result<(bool, Vec), (String, String)> -where - S: futures_util::TryStream + Unpin, - R: Row, -{ - let mut columns = Vec::new(); - while let Some(row) = stream.try_next().await.map_err(db_error)? { - if columns.is_empty() { - columns = row - .columns() - .iter() - .map(|column| column.name().to_string()) - .collect(); - } - if destination.len() == maximum { - return Ok((true, columns)); - } - destination.push(decode(&row)?); - } - Ok((false, columns)) -} - -fn bind_mysql<'q>( - mut query: sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments>, - values: &'q [Value], -) -> Result, (String, String)> { - for value in values { - query = bind_mysql_value(query, value); - } - Ok(query) -} -fn bind_postgres<'q>( - mut query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, - values: &'q [Value], -) -> Result, (String, String)> { - for value in values { - query = bind_postgres_value(query, value); - } - Ok(query) -} -fn bind_sqlite<'q>( - mut query: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>>, - values: &'q [Value], -) -> Result>, (String, String)> { - for value in values { - query = bind_sqlite_value(query, value); - } - Ok(query) -} - -fn binary_input(object: &Map) -> Option<&str> { - if object.len() != 1 { - return None; - } - object - .get("binary") - .or_else(|| object.get("base64")) - .and_then(Value::as_str) -} - -fn tagged_binary(bytes: impl AsRef<[u8]>) -> Value { - json!({"binary": BASE64.encode(bytes.as_ref())}) -} - -macro_rules! bind_value_fn { - ($name:ident, $db:ty, $args:ty) => { - fn $name<'q>( - query: sqlx::query::Query<'q, $db, $args>, - value: &'q Value, - ) -> sqlx::query::Query<'q, $db, $args> { - match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - Value::Object(v) if v.get("decimal").and_then(Value::as_str).is_some() => { - match v["decimal"] - .as_str() - .unwrap() - .parse::() - { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["decimal"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("date").and_then(Value::as_str).is_some() => { - match v["date"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["date"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("time").and_then(Value::as_str).is_some() => { - match v["time"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["time"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("datetime").and_then(Value::as_str).is_some() => { - match v["datetime"] - .as_str() - .unwrap() - .parse::() - { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["datetime"].as_str().unwrap()), - } - } - Value::Object(v) - if v.get("timestampWithTimeZone") - .and_then(Value::as_str) - .is_some() => - { - match chrono::DateTime::parse_from_rfc3339( - v["timestampWithTimeZone"].as_str().unwrap(), - ) { - Ok(value) => query.bind(value.with_timezone(&chrono::Utc)), - Err(_) => query.bind(v["timestampWithTimeZone"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("uuid").and_then(Value::as_str).is_some() => { - match v["uuid"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["uuid"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("json").is_some() => { - query.bind(sqlx::types::Json(v["json"].clone())) - } - Value::Object(v) if binary_input(v).is_some() => { - match BASE64.decode(binary_input(v).unwrap()) { - Ok(value) => query.bind(value), - Err(_) => query.bind(Vec::::new()), - } - } - other => query.bind(other.to_string()), - } - } - }; -} -bind_value_fn!(bind_mysql_value, MySql, sqlx::mysql::MySqlArguments); -bind_value_fn!(bind_postgres_value, Postgres, sqlx::postgres::PgArguments); - -fn bind_sqlite_value<'q>( - query: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>>, - value: &'q Value, -) -> sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>> { - match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - Value::Object(v) if binary_input(v).is_some() => { - query.bind(BASE64.decode(binary_input(v).unwrap()).unwrap_or_default()) - } - Value::Object(v) if v.get("json").is_some() => query.bind(v["json"].to_string()), - Value::Object(v) => { - let tagged = [ - "decimal", - "date", - "time", - "datetime", - "timestampWithTimeZone", - "uuid", - ] - .iter() - .find_map(|key| v.get(*key).and_then(Value::as_str)); - query.bind(tagged.unwrap_or_else(|| "")) - } - other => query.bind(other.to_string()), - } -} - -fn mysql_row_json(row: &MySqlRow) -> Result { - typed_row_json(row, mysql_value) -} -fn postgres_row_json(row: &PgRow) -> Result { - typed_row_json(row, postgres_value) -} -fn sqlite_row_json(row: &SqliteRow) -> Result { - typed_row_json(row, sqlite_value) -} - -fn typed_row_json( - row: &R, - decode: fn(&R, usize, &str) -> Value, -) -> Result -where - R: Row, - usize: sqlx::ColumnIndex, -{ - let mut object = Map::new(); - for (index, column) in row.columns().iter().enumerate() { - let raw = row - .try_get_raw(index) - .map_err(|e| ("database_error".into(), e.to_string()))?; - let value = if raw.is_null() { - Value::Null - } else { - decode(row, index, column.type_info().name()) - }; - object.insert(column.name().to_string(), value); - } - Ok(Value::Object(object)) -} - -fn mysql_value(row: &MySqlRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "DECIMAL" | "NEWDECIMAL" => row - .try_get::(index) - .map(|v| json!({"decimal": v.to_string()})) - .unwrap_or_else(|_| mysql_text(row, index)), - "DATE" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| mysql_text(row, index)), - "TIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| mysql_text(row, index)), - "DATETIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .or_else(|_| { - row.try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - }) - .unwrap_or_else(|_| mysql_text(row, index)), - "TIMESTAMP" => row - .try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - .or_else(|_| { - row.try_get::(index) - .map(|v| Value::String(v.to_string())) - }) - .or_else(|_| { - row.try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - }) - .unwrap_or_else(|_| mysql_text(row, index)), - "BOOLEAN" | "BOOL" => row - .try_get::(index) - .map(Value::Bool) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .unwrap_or_else(|_| mysql_text(row, index)), - "JSON" => row - .try_get::(index) - .unwrap_or_else(|_| mysql_text(row, index)), - "BLOB" | "BINARY" | "VARBINARY" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| mysql_text(row, index)), - "FLOAT" | "DOUBLE" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| mysql_text(row, index)), - value if value == "TINYINT" => row - .try_get::(index) - .map(|v| json!(v)) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .unwrap_or_else(|_| mysql_text(row, index)), - value if value.contains("INT") || value == "YEAR" => row - .try_get::(index) - .map(|v| json!(v)) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .unwrap_or_else(|_| mysql_text(row, index)), - _ => mysql_text(row, index), - } -} -fn mysql_text(row: &MySqlRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .or_else(|_| row.try_get_unchecked::(index).map(Value::String)) - .or_else(|_| { - row.try_get::, _>(index) - .map(|value| mysql_bytes_value(&value)) - }) - .or_else(|_| { - row.try_get_unchecked::, _>(index) - .map(|value| mysql_bytes_value(&value)) - }) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn mysql_bytes_value(value: &[u8]) -> Value { - match String::from_utf8(value.to_vec()) { - Ok(text) => Value::String(text), - Err(_) => tagged_binary(value), - } -} - -fn postgres_value(row: &PgRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "NUMERIC" => row - .try_get::(index) - .map(|v| json!({"decimal": v.to_string()})) - .unwrap_or_else(|_| pg_text(row, index)), - "DATE" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIMESTAMP" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIMESTAMPTZ" => row - .try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - .unwrap_or_else(|_| pg_text(row, index)), - "UUID" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "JSON" | "JSONB" => row - .try_get::(index) - .unwrap_or_else(|_| pg_text(row, index)), - "BYTEA" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| pg_text(row, index)), - "BOOL" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| pg_text(row, index)), - "INT2" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "INT4" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "INT8" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "FLOAT4" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "FLOAT8" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - _ => pg_text(row, index), - } -} -fn pg_text(row: &PgRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn sqlite_value(row: &SqliteRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "INTEGER" | "INT" | "INT64" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| sqlite_text(row, index)), - "REAL" | "FLOAT" | "DOUBLE" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| sqlite_text(row, index)), - "BLOB" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| sqlite_text(row, index)), - "BOOL" | "BOOLEAN" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| sqlite_text(row, index)), - _ => sqlite_text(row, index), - } -} -fn sqlite_text(row: &SqliteRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -async fn execute(pool: &AnyPool, sql: &str, values: &[Value]) -> DbResult { - require_sql(sql)?; - let result = bind_values(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)?; - Ok(json!({"rowsAffected": result.rows_affected()})) -} - -async fn execute_direct(pool: &DirectPool, sql: &str, values: &[Value]) -> DbResult { - require_sql(sql)?; - let affected = match pool { - DirectPool::MySql(pool) => bind_mysql(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - DirectPool::Postgres(pool) => bind_postgres(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - DirectPool::Sqlite(pool) => bind_sqlite(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - }; - Ok(json!({"rowsAffected": affected})) -} - -async fn explain(pool: &AnyPool, kind: &str, sql: &str, format: &str) -> DbResult { - let sql = require_single_sql(sql)?; - let explain_sql = match kind { - "postgresql" if format.eq_ignore_ascii_case("json") || format.is_empty() => { - format!("EXPLAIN (FORMAT JSON) {sql}") - } - "postgresql" => format!("EXPLAIN {sql}"), - "sqlite" => format!("EXPLAIN QUERY PLAN {sql}"), - _ => format!("EXPLAIN {sql}"), - }; - let rows = sqlx::query(&explain_sql) - .fetch_all(pool) - .await - .map_err(db_error)?; - Ok( - json!({"format": if kind == "postgresql" && (format.is_empty() || format.eq_ignore_ascii_case("json")) { "json" } else { "rows" }, "rows": rows_value(rows)?, "truncated": false}), - ) -} - -async fn diagnostics(pool: &AnyPool, kind: &str, params: &Params) -> DbResult { - let diagnostic_kind = if params.diagnostic_kind.is_empty() { - "tableSize" - } else { - params.diagnostic_kind.as_str() - }; - if diagnostic_kind == "indexes" { - let rows = list_indexes(pool, kind, ¶ms.schema, ¶ms.table).await?; - return Ok(json!({"rows": rows, "truncated": false})); - } - let sql = match (kind, diagnostic_kind) { - ("mysql", "tableSize") => "SELECT table_name, table_rows, data_length, index_length, (data_length + index_length) AS total_bytes FROM information_schema.tables WHERE table_schema=DATABASE() AND (? = '' OR table_name = ?) ORDER BY total_bytes DESC".to_string(), - ("postgresql", "tableSize") => "SELECT c.relname AS table_name, c.reltuples AS estimated_rows, pg_total_relation_size(c.oid) AS total_bytes, pg_relation_size(c.oid) AS table_bytes FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND ($2='' OR c.relname=$2) AND c.relkind IN ('r','m') ORDER BY total_bytes DESC".to_string(), - ("sqlite", "tableSize") => "SELECT name AS table_name, (SELECT page_count * page_size FROM pragma_page_count, pragma_page_size) AS total_bytes FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND (?='' OR name=?) ORDER BY name".to_string(), - ("mysql", "locks") => "SELECT trx_id, trx_started, trx_state, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started".to_string(), - ("postgresql", "locks") => "SELECT pid, locktype, mode, granted, relation::regclass AS relation, transactionid, query FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid=l.pid WHERE NOT granted OR l.relation IS NOT NULL ORDER BY granted, pid".to_string(), - ("sqlite", "locks") => "SELECT 'SQLite uses file-level locking; active lock state is not exposed by the driver' AS message".to_string(), - ("mysql", "slowQueries") => "SELECT DIGEST_TEXT AS query, COUNT_STAR AS executions, AVG_TIMER_WAIT/1000000 AS avg_ms, SUM_TIMER_WAIT/1000000 AS total_ms, SUM_ROWS_AFFECTED AS rows_affected FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT IS NOT NULL ORDER BY SUM_TIMER_WAIT DESC LIMIT 100".to_string(), - ("postgresql", "slowQueries") => "SELECT query, calls AS executions, mean_exec_time AS avg_ms, total_exec_time AS total_ms, rows FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 100".to_string(), - ("sqlite", "slowQueries") => "SELECT NULL AS query, 0 AS executions, 0 AS avg_ms, 0 AS total_ms WHERE 1=0".to_string(), - ("mysql", "schemaImpact") => "SELECT TABLE_NAME AS table_name, CONSTRAINT_NAME AS constraint_name, REFERENCED_TABLE_NAME AS referenced_table_name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL AND (?='' OR REFERENCED_TABLE_NAME=?) ORDER BY TABLE_NAME".to_string(), - ("postgresql", "schemaImpact") => "SELECT CAST(tc.table_name AS TEXT) AS table_name, CAST(tc.constraint_name AS TEXT) AS constraint_name, CAST(ccu.table_name AS TEXT) AS referenced_table_name FROM information_schema.table_constraints tc JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.constraint_schema=tc.constraint_schema WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema=$1 AND ($2='' OR ccu.table_name=$2) ORDER BY tc.table_name".to_string(), - ("sqlite", "schemaImpact") => "SELECT m.name AS table_name, f.id AS foreign_key_id, f.table AS referenced_table_name FROM sqlite_master m, pragma_foreign_key_list(m.name) f WHERE m.type='table' AND (?='' OR f.table=?) ORDER BY m.name".to_string(), - (_, "dataQuality") => return data_quality(pool, kind, params).await, - _ => return Err(("unsupported_diagnostic".into(), format!("Unsupported diagnostic: {diagnostic_kind}"))), - }; - let mut query = sqlx::query(&sql); - match (kind, diagnostic_kind) { - ("postgresql", "tableSize" | "locks" | "slowQueries") => {} - ("postgresql", "schemaImpact") => { - query = query - .bind(if params.schema.is_empty() { - "public" - } else { - params.schema.as_str() - }) - .bind(¶ms.table); - } - ("mysql" | "sqlite", "tableSize" | "schemaImpact") => { - query = query.bind(¶ms.table).bind(¶ms.table); - } - _ => {} - } - if kind == "postgresql" && diagnostic_kind == "tableSize" { - query = query - .bind(if params.schema.is_empty() { - "public" - } else { - params.schema.as_str() - }) - .bind(¶ms.table); - } - rows_value(query.fetch_all(pool).await.map_err(db_error)?) -} - -async fn data_quality(pool: &AnyPool, kind: &str, params: &Params) -> DbResult { - validate_identifier(¶ms.table)?; - let description = describe_table(pool, kind, ¶ms.schema, ¶ms.table).await?; - let rows = description.as_array().cloned().unwrap_or_default(); - let mut results = Vec::new(); - for row in rows { - let column = row - .get("column_name") - .and_then(Value::as_str) - .or_else(|| row.get("name").and_then(Value::as_str)) - .unwrap_or_default(); - if column.is_empty() { - continue; - } - let qualified = if !params.schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, ¶ms.schema)?, - quote_identifier(kind, ¶ms.table)? - ) - } else { - quote_identifier(kind, ¶ms.table)? - }; - let sql = format!("SELECT COUNT(*) AS total_rows, SUM(CASE WHEN {} IS NULL THEN 1 ELSE 0 END) AS null_rows FROM {qualified}", quote_identifier(kind, column)?); - let values = rows_value(sqlx::query(&sql).fetch_all(pool).await.map_err(db_error)?)?; - if let Some(value) = values.as_array().and_then(|items| items.first()) { - results.push(json!({"column": column, "metrics": value})); - } - } - Ok(json!({"rows": results, "truncated": false})) -} - -async fn run_transaction(pool: &DirectPool, _kind: &str, statements: &[Statement]) -> DbResult { - if statements.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let mut affected = 0_u64; - match pool { - DirectPool::MySql(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_mysql(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Postgres(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_postgres(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_sqlite(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn ensure_write_allowed( - connection: &Connection, - sql: &str, - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection && is_dangerous_sql(&connection.kind, sql) && !confirmed { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -fn ensure_mutations_allowed( - connection: &Connection, - mutations: &[Mutation], - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection - && mutations.iter().any(|mutation| mutation.action == "delete") - && !confirmed - { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -fn ensure_transaction_allowed( - connection: &Connection, - statements: &[Statement], - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection - && statements - .iter() - .any(|statement| is_dangerous_sql(&connection.kind, &statement.sql)) - && !confirmed - { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -/// Decides whether production protection must ask before running `sql`. -/// -/// The script is lexed rather than pattern-matched so that line breaks, comments, -/// string literals and quoted identifiers cannot hide or fake a keyword. A -/// statement is dangerous when it restores a backup, drops or truncates -/// anything, or contains an `UPDATE`/`DELETE` whose own clause has no `WHERE` -/// (a `WHERE` inside a subquery does not bound the outer write). Every -/// statement of a multi-statement script is checked. -fn is_dangerous_sql(kind: &str, sql: &str) -> bool { - sql_statements(kind, sql) - .iter() - .any(|statement| is_dangerous_statement(statement)) -} - -/// Structural SQL token. Literals, quoted identifiers and comments produce no -/// token, so their contents never read as keywords. -#[derive(Debug, PartialEq)] -enum SqlToken { - /// Unquoted word, uppercased. - Word(String), - Open, - Close, -} - -/// Splits `sql` into top-level statements of structural tokens. -fn sql_statements(kind: &str, sql: &str) -> Vec> { - // MySQL and MariaDB treat backslash as an escape inside quotes by default; - // the other dialects only escape by doubling the quote character. - let backslash_escapes = matches!(kind, "mysql" | "mariadb"); - let chars: Vec = sql.chars().collect(); - let mut statements = vec![Vec::new()]; - let mut index = 0; - while index < chars.len() { - let character = chars[index]; - let next = chars.get(index + 1).copied(); - let tokens = statements - .last_mut() - .expect("statement list is never empty"); - match character { - '-' if next == Some('-') => { - while index < chars.len() && chars[index] != '\n' { - index += 1; - } - } - '/' if next == Some('*') => { - // PostgreSQL nests block comments; a nested opener in other - // dialects is rare enough that over-skipping is acceptable. - let mut depth = 0; - while index < chars.len() { - if chars[index] == '/' && chars.get(index + 1) == Some(&'*') { - depth += 1; - index += 2; - } else if chars[index] == '*' && chars.get(index + 1) == Some(&'/') { - depth -= 1; - index += 2; - if depth == 0 { - break; - } - } else { - index += 1; - } - } - } - '\'' => { - // PostgreSQL E'...' strings accept backslash escapes. - let escape_string = tokens.last() == Some(&SqlToken::Word("E".into())) - && index > 0 - && chars[index - 1].eq_ignore_ascii_case(&'e'); - if escape_string { - tokens.pop(); - } - index = skip_quoted(&chars, index, '\'', backslash_escapes || escape_string); - } - '"' => index = skip_quoted(&chars, index, '"', backslash_escapes), - '`' => index = skip_quoted(&chars, index, '`', false), - '[' if kind == "sqlserver" => index = skip_quoted(&chars, index, ']', false), - '$' => match dollar_quote_tag(&chars, index) { - Some(tag) => { - let start = index + tag.len(); - index = (start..=chars.len().saturating_sub(tag.len())) - .find(|&end| chars[end..end + tag.len()] == tag[..]) - .map_or(chars.len(), |end| end + tag.len()); - } - None => index += 1, - }, - '(' => { - tokens.push(SqlToken::Open); - index += 1; - } - ')' => { - tokens.push(SqlToken::Close); - index += 1; - } - ';' => { - statements.push(Vec::new()); - index += 1; - } - _ if character.is_alphanumeric() || character == '_' => { - let start = index; - while index < chars.len() - && (chars[index].is_alphanumeric() || matches!(chars[index], '_' | '$')) - { - index += 1; - } - let word: String = chars[start..index].iter().collect(); - tokens.push(SqlToken::Word(word.to_uppercase())); - } - _ => index += 1, - } - } - statements.retain(|tokens| !tokens.is_empty()); - statements -} - -/// Returns the index just past the closing `close` of a quoted run that opens -/// at `start`. A doubled closing character is an escaped literal character. -fn skip_quoted(chars: &[char], start: usize, close: char, backslash_escapes: bool) -> usize { - let mut index = start + 1; - while index < chars.len() { - if backslash_escapes && chars[index] == '\\' { - index += 2; - } else if chars[index] == close { - if chars.get(index + 1) == Some(&close) { - index += 2; - } else { - return index + 1; - } - } else { - index += 1; - } - } - chars.len() -} - -/// Returns the `$tag$` opener at `start`, if any. `$1` style placeholders are -/// not dollar quotes because a tag cannot start with a digit. -fn dollar_quote_tag(chars: &[char], start: usize) -> Option> { - let mut index = start + 1; - if chars - .get(index) - .is_some_and(|character| character.is_ascii_digit()) - { - return None; - } - while chars - .get(index) - .is_some_and(|character| character.is_alphanumeric() || *character == '_') - { - index += 1; - } - (chars.get(index) == Some(&'$')).then(|| chars[start..=index].to_vec()) -} - -fn is_dangerous_statement(tokens: &[SqlToken]) -> bool { - let word = |index: usize| match tokens.get(index) { - Some(SqlToken::Word(word)) => Some(word.as_str()), - _ => None, - }; - if word(0) == Some("RESTORE") { - return true; - } - let mut depths = Vec::with_capacity(tokens.len()); - let mut depth = 0usize; - for token in tokens { - if *token == SqlToken::Close { - depth = depth.saturating_sub(1); - } - depths.push(depth); - if *token == SqlToken::Open { - depth += 1; - } - } - (0..tokens.len()).any(|index| match word(index) { - // TRUNCATE(x, d) is MySQL's numeric function, not the statement. - Some("DROP" | "TRUNCATE") => tokens.get(index + 1) != Some(&SqlToken::Open), - // Only a verb opens a write: at statement start, as a subquery or CTE - // body after `(`, or as the main statement after a `WITH ... )` list. - // This excludes FOR UPDATE, ON DELETE CASCADE, GRANT UPDATE, trigger - // events and MERGE/upsert actions, which are bounded elsewhere. - Some("UPDATE" | "DELETE") - if index == 0 || matches!(tokens[index - 1], SqlToken::Open | SqlToken::Close) => - { - let clause_depth = depths[index]; - !tokens[index + 1..] - .iter() - .zip(&depths[index + 1..]) - .take_while(|(_, depth)| **depth >= clause_depth) - .any(|(token, depth)| { - *depth == clause_depth && *token == SqlToken::Word("WHERE".into()) - }) - } - _ => false, - }) -} - -async fn apply_changes( - pool: &DirectPool, - kind: &str, - schema: &str, - mutations: &[Mutation], -) -> DbResult { - if mutations.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let mut affected = 0_u64; - match pool { - DirectPool::MySql(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_mysql(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Postgres(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_postgres(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_sqlite(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn mutation_sql( - kind: &str, - schema: &str, - mutation: &Mutation, -) -> Result<(String, Vec), (String, String)> { - validate_identifier(&mutation.table)?; - let table = if !schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, &mutation.table)? - ) - } else { - quote_identifier(kind, &mutation.table)? - }; - let columns = |map: &Map| -> Result, (String, String)> { - map.keys().map(|key| quote_identifier(kind, key)).collect() - }; - match mutation.action.as_str() { - "insert" => { - if mutation.values.is_empty() { - let sql = if kind == "mysql" { - format!("INSERT INTO {table} () VALUES ()") - } else { - format!("INSERT INTO {table} DEFAULT VALUES") - }; - return Ok((sql, Vec::new())); - } - let names = columns(&mutation.values)?; - let placeholders = placeholders(kind, 1, names.len()); - Ok(( - format!( - "INSERT INTO {table} ({}) VALUES ({})", - names.join(", "), - placeholders.join(", ") - ), - mutation.values.values().cloned().collect(), - )) - } - "update" => { - require_key(&mutation.key)?; - if mutation.values.is_empty() { - return Err(( - "invalid_mutation".into(), - "Update values cannot be empty".into(), - )); - } - let names = columns(&mutation.values)?; - let key_names = columns(&mutation.key)?; - let assignments: Vec = names - .iter() - .enumerate() - .map(|(index, name)| format!("{name} = {}", placeholder(kind, index + 1))) - .collect(); - let predicates: Vec = key_names - .iter() - .enumerate() - .map(|(index, name)| { - format!("{name} = {}", placeholder(kind, names.len() + index + 1)) - }) - .collect(); - let mut values: Vec = mutation.values.values().cloned().collect(); - values.extend(mutation.key.values().cloned()); - Ok(( - format!( - "UPDATE {table} SET {} WHERE {}", - assignments.join(", "), - predicates.join(" AND ") - ), - values, - )) - } - "delete" => { - require_key(&mutation.key)?; - let names = columns(&mutation.key)?; - let predicates: Vec = names - .iter() - .enumerate() - .map(|(index, name)| format!("{name} = {}", placeholder(kind, index + 1))) - .collect(); - Ok(( - format!("DELETE FROM {table} WHERE {}", predicates.join(" AND ")), - mutation.key.values().cloned().collect(), - )) - } - action => Err(( - "invalid_mutation".into(), - format!("Unsupported mutation action: {action}"), - )), - } -} - -fn require_key(key: &Map) -> Result<(), (String, String)> { - if key.is_empty() { - Err(( - "unsafe_mutation".into(), - "Update and delete require a primary key".into(), - )) - } else { - Ok(()) - } -} - -fn placeholders(kind: &str, start: usize, count: usize) -> Vec { - (start..start + count) - .map(|index| placeholder(kind, index)) - .collect() -} - -fn placeholder(kind: &str, index: usize) -> String { - if kind == "postgresql" { - format!("${index}") - } else { - "?".into() - } -} - -async fn export_csv(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - let result = query_direct(pool, sql, values, limit).await?; - let rows = result["rows"].as_array().cloned().unwrap_or_default(); - let headers: Vec = result["columns"] - .as_array() - .map(|columns| { - columns - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect() - }) - .filter(|columns: &Vec| !columns.is_empty()) - .unwrap_or_else(|| { - rows.first() - .and_then(Value::as_object) - .map(|row| row.keys().cloned().collect()) - .unwrap_or_default() - }); - let mut writer = csv::Writer::from_writer(Vec::new()); - writer.write_record(&headers).map_err(csv_error)?; - for row in rows { - let object = row - .as_object() - .ok_or_else(|| ("export_failed".into(), "Invalid row".into()))?; - writer - .write_record( - headers - .iter() - .map(|key| csv_value(object.get(key).unwrap_or(&Value::Null))), - ) - .map_err(csv_error)?; - } - let bytes = writer - .into_inner() - .map_err(|e| ("export_failed".into(), e.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn export_json(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - let result = query_direct(pool, sql, values, limit).await?; - let bytes = serde_json::to_vec_pretty(&result["rows"]) - .map_err(|e| ("export_failed".into(), e.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn import_data( - pool: &DirectPool, - kind: &str, - schema: &str, - table: &str, - data: &str, - format: &str, -) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - let rows = if format == "csv" { - parse_csv(&bytes)? - } else { - parse_json(&bytes)? - }; - if rows.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - if rows.len() > 100_000 { - return Err(( - "import_too_large".into(), - "A single import is limited to 100000 rows".into(), - )); - } - let mutations: Vec = rows - .into_iter() - .map(|values| Mutation { - action: "insert".into(), - table: table.into(), - values, - key: Map::new(), - }) - .collect(); - apply_changes(pool, kind, schema, &mutations).await -} - -fn parse_csv(bytes: &[u8]) -> Result>, (String, String)> { - let mut reader = csv::ReaderBuilder::new().flexible(false).from_reader(bytes); - let headers: Vec = reader - .headers() - .map_err(csv_error)? - .iter() - .map(str::to_string) - .collect(); - for header in &headers { - validate_identifier(header)?; - } - reader - .records() - .map(|record| { - let record = record.map_err(csv_error)?; - Ok(headers - .iter() - .zip(record.iter()) - .map(|(key, value)| (key.clone(), Value::String(value.into()))) - .collect()) - }) - .collect() -} - -fn parse_json(bytes: &[u8]) -> Result>, (String, String)> { - let rows: Vec> = serde_json::from_slice(bytes).map_err(|e| { - ( - "invalid_import".into(), - format!("Expected an array of JSON objects: {e}"), - ) - })?; - for row in &rows { - for key in row.keys() { - validate_identifier(key)?; - } - } - Ok(rows) -} - -async fn import_sql(pool: &AnyPool, data: &str) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - import_sql_bytes(pool, &bytes).await -} - -async fn import_sql_file(pool: &AnyPool, path: &str) -> DbResult { - if path.trim().is_empty() { - return Err(("invalid_import".into(), "SQL file path is required".into())); - } - let bytes = tokio::fs::read(path) - .await - .map_err(|error| ("invalid_import".into(), error.to_string()))?; - import_sql_bytes(pool, &bytes).await -} - -async fn restore_sql(pool: &AnyPool, kind: &str, schema: &str, data: &str) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - restore_sql_bytes(pool, kind, schema, &bytes).await -} - -async fn restore_sql_file(pool: &AnyPool, kind: &str, schema: &str, path: &str) -> DbResult { - if path.trim().is_empty() { - return Err(("invalid_import".into(), "SQL file path is required".into())); - } - let bytes = tokio::fs::read(path) - .await - .map_err(|error| ("invalid_import".into(), error.to_string()))?; - restore_sql_bytes(pool, kind, schema, &bytes).await -} - -async fn restore_sql_bytes(pool: &AnyPool, kind: &str, schema: &str, bytes: &[u8]) -> DbResult { - let script = validated_sql_script(bytes)?; - if script.trim().is_empty() { - return Err(( - "invalid_import".into(), - "SQL restore file cannot be empty".into(), - )); - } - drop_database_objects(pool, kind, schema).await?; - execute_sql_script(pool, script).await -} - -async fn import_sql_bytes(pool: &AnyPool, bytes: &[u8]) -> DbResult { - let script = validated_sql_script(bytes)?; - execute_sql_script(pool, script).await -} - -fn validated_sql_script(bytes: &[u8]) -> Result<&str, (String, String)> { - if bytes.len() > 256 * 1024 * 1024 { - return Err(( - "import_too_large".into(), - "SQL import is limited to 256 MB per request".into(), - )); - } - let script = std::str::from_utf8(&bytes).map_err(|e| { - ( - "invalid_import".into(), - format!("SQL file must be UTF-8: {e}"), - ) - })?; - Ok(script) -} - -async fn execute_sql_script(pool: &AnyPool, script: &str) -> DbResult { - if script.trim().is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let result = sqlx::raw_sql(script) - .execute(pool) - .await - .map_err(db_error)?; - Ok(json!({"rowsAffected": result.rows_affected()})) -} - -async fn export_sql( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, -) -> DbResult { - let mut output = Vec::new(); - write_sql_backup( - &mut output, - pool, - direct, - kind, - schema, - selected, - include_structure, - include_data, - limit, - ) - .await?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(output)})) -} - -async fn write_sql_backup( - output: &mut W, - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, -) -> Result<(), (String, String)> { - let schema = if schema.is_empty() && kind == "postgresql" { - "public" - } else { - schema - }; - let objects = database_objects(pool, kind, schema).await?; - let selected_set: std::collections::HashSet<&str> = - selected.iter().map(String::as_str).collect(); - for name in selected { - validate_identifier(name)?; - } - let objects: Vec<(String, String)> = objects - .into_iter() - .filter(|(name, _)| selected_set.is_empty() || selected_set.contains(name.as_str())) - .collect(); - write_export( - output, - "-- Lithe SQL backup\n-- Generated by lithe-db-sidecar\n\n", - )?; - match kind { - "mysql" => write_export(output, "SET FOREIGN_KEY_CHECKS=0;\n\n")?, - "sqlite" => write_export(output, "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n\n")?, - "postgresql" => write_export(output, "BEGIN;\n\n")?, - _ => {} - } - if include_structure { - write_export(output, "-- Remove existing objects before restore\n")?; - for (name, object_type) in objects.iter().rev() { - if let Some(statement) = object_drop_sql(kind, schema, name, object_type)? { - write_export(output, &statement)?; - write_export(output, ";\n")?; - } - } - write_export(output, "\n")?; - for (name, object_type) in &objects { - let ddl = object_ddl(pool, kind, schema, name, object_type).await?; - if !ddl.trim().is_empty() { - write_export( - output, - &format!( - "-- Structure for {name}\n{};\n\n", - ddl.trim().trim_end_matches(';') - ), - )?; - } - } - if kind == "postgresql" { - let table_names: Vec<&str> = objects - .iter() - .filter(|(_, object_type)| object_type.eq_ignore_ascii_case("base table")) - .map(|(name, _)| name.as_str()) - .collect(); - let foreign_keys = postgres_foreign_key_ddls(pool, schema, &table_names).await?; - if !foreign_keys.is_empty() { - write_export(output, "-- Foreign key constraints\n")?; - for statement in foreign_keys { - write_export(output, &statement)?; - write_export(output, ";\n")?; - } - write_export(output, "\n")?; - } - } - } - if include_data { - for (name, object_type) in &objects { - if !object_type.eq_ignore_ascii_case("table") - && !object_type.eq_ignore_ascii_case("base table") - { - continue; - } - write_export(output, &format!("-- Data for {name}\n"))?; - write_table_insert_sql(output, direct, kind, schema, name, limit).await?; - if kind == "postgresql" { - write_export(output, &postgres_sequence_sql(pool, schema, name).await?)?; - } - write_export(output, "\n")?; - } - } - match kind { - "mysql" => write_export(output, "SET FOREIGN_KEY_CHECKS=1;\n")?, - "sqlite" => write_export(output, "COMMIT;\nPRAGMA foreign_keys=ON;\n")?, - "postgresql" => write_export(output, "COMMIT;\n")?, - _ => {} - } - Ok(()) -} - -async fn export_sql_to_file( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, - path: &str, -) -> DbResult { - if path.trim().is_empty() { - return Err(( - "invalid_export".into(), - "Output file path is required".into(), - )); - } - let file = File::create(path).map_err(export_io_error)?; - let mut output = BufWriter::new(file); - write_sql_backup( - &mut output, - pool, - direct, - kind, - schema, - selected, - include_structure, - include_data, - limit, - ) - .await?; - output.flush().map_err(export_io_error)?; - drop(output); - let (byte_count, sha256) = sha256_file(path)?; - Ok(json!({ - "path": path, - "byteCount": byte_count, - "sha256": sha256 - })) -} - -fn write_export(output: &mut W, text: &str) -> Result<(), (String, String)> { - output.write_all(text.as_bytes()).map_err(export_io_error) -} - -fn export_io_error(error: io::Error) -> (String, String) { - ("export_failed".into(), error.to_string()) -} - -fn sha256_file(path: &str) -> Result<(u64, String), (String, String)> { - let file = File::open(path).map_err(export_io_error)?; - let mut reader = BufReader::new(file); - let mut digest = Sha256::new(); - let mut buffer = [0u8; 64 * 1024]; - let mut byte_count = 0u64; - loop { - let bytes_read = reader.read(&mut buffer).map_err(export_io_error)?; - if bytes_read == 0 { - break; - } - digest.update(&buffer[..bytes_read]); - byte_count += bytes_read as u64; - } - Ok((byte_count, hex::encode(digest.finalize()))) -} - -async fn database_objects( - pool: &AnyPool, - kind: &str, - schema: &str, -) -> Result, (String, String)> { - let (sql, bind_schema) = match kind { - "mysql" => ("SELECT table_name, table_type FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY CASE WHEN table_type='BASE TABLE' THEN 0 ELSE 1 END, table_name", false), - "postgresql" => ("SELECT CAST(table_name AS TEXT), CAST(table_type AS TEXT) FROM information_schema.tables WHERE table_schema=$1 ORDER BY CASE WHEN table_type='BASE TABLE' THEN 0 ELSE 1 END, table_name", true), - _ => ("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name", false), - }; - let mut query = sqlx::query(sql); - if bind_schema { - query = query.bind(schema); - } - let rows = query.fetch_all(pool).await.map_err(db_error)?; - rows.iter() - .map(|row| Ok((row_string(row, 0)?, row_string(row, 1)?))) - .collect() -} - -async fn drop_database_objects( - pool: &AnyPool, - kind: &str, - schema: &str, -) -> Result<(), (String, String)> { - let schema = if schema.is_empty() && kind == "postgresql" { - "public" - } else { - schema - }; - match kind { - "mysql" => { - sqlx::query("SET FOREIGN_KEY_CHECKS=0") - .execute(pool) - .await - .map_err(db_error)?; - } - "sqlite" => { - sqlx::query("PRAGMA foreign_keys=OFF") - .execute(pool) - .await - .map_err(db_error)?; - } - _ => {} - } - let objects = database_objects(pool, kind, schema).await?; - for (name, object_type) in objects.iter().rev() { - if let Some(statement) = object_drop_sql(kind, schema, name, object_type)? { - sqlx::query(&statement) - .execute(pool) - .await - .map_err(db_error)?; - } - } - Ok(()) -} - -fn object_drop_sql( - kind: &str, - schema: &str, - name: &str, - object_type: &str, -) -> Result, (String, String)> { - let qualified = if kind == "postgresql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, name)? - ) - } else { - quote_identifier(kind, name)? - }; - if object_type.eq_ignore_ascii_case("view") { - return Ok(Some(format!("DROP VIEW IF EXISTS {qualified}"))); - } - if object_type.eq_ignore_ascii_case("table") || object_type.eq_ignore_ascii_case("base table") { - return Ok(Some(format!( - "DROP TABLE IF EXISTS {qualified}{}", - if kind == "postgresql" { " CASCADE" } else { "" } - ))); - } - Ok(None) -} - -async fn object_ddl( - pool: &AnyPool, - kind: &str, - schema: &str, - name: &str, - object_type: &str, -) -> Result { - validate_identifier(name)?; - match kind { - "sqlite" => { - let row = sqlx::query( - "SELECT sql FROM sqlite_master WHERE name=? AND type IN ('table','view')", - ) - .bind(name) - .fetch_one(pool) - .await - .map_err(db_error)?; - let mut ddl = row_string(&row, 0)?; - if object_type.eq_ignore_ascii_case("table") { - let indexes = sqlx::query("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL ORDER BY name").bind(name).fetch_all(pool).await.map_err(db_error)?; - for index in indexes { - ddl.push_str(";\n"); - ddl.push_str(&row_string(&index, 0)?); - } - } - Ok(ddl) - } - "mysql" => { - let sql = format!( - "SHOW CREATE {} {}", - if object_type.eq_ignore_ascii_case("view") { - "VIEW" - } else { - "TABLE" - }, - quote_identifier(kind, name)? - ); - let row = sqlx::query(&sql).fetch_one(pool).await.map_err(db_error)?; - row_string(&row, 1) - } - "postgresql" if object_type.eq_ignore_ascii_case("view") => { - let row = sqlx::query("SELECT 'CREATE OR REPLACE VIEW ' || quote_ident($1) || '.' || quote_ident($2) || ' AS ' || pg_get_viewdef((quote_ident($1)||'.'||quote_ident($2))::regclass, true)") - .bind(schema).bind(name).fetch_one(pool).await.map_err(db_error)?; - row_string(&row, 0) - } - "postgresql" => postgres_table_ddl(pool, schema, name).await, - _ => Err(("unsupported_database".into(), kind.into())), - } -} - -async fn postgres_table_ddl( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - let column_sql = r#"SELECT quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid,a.atttypmod) || - CASE WHEN a.attidentity='a' THEN ' GENERATED ALWAYS AS IDENTITY' WHEN a.attidentity='d' THEN ' GENERATED BY DEFAULT AS IDENTITY' - WHEN d.adbin IS NOT NULL THEN ' DEFAULT ' || pg_get_expr(d.adbin,d.adrelid) ELSE '' END || - CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END - FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace - LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum - WHERE n.nspname=$1 AND c.relname=$2 AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum"#; - let rows = sqlx::query(column_sql) - .bind(schema) - .bind(table) - .fetch_all(pool) - .await - .map_err(db_error)?; - let mut definitions: Vec = rows - .iter() - .map(|row| row_string(row, 0)) - .collect::>()?; - let constraints = sqlx::query("SELECT 'CONSTRAINT '||quote_ident(con.conname)||' '||pg_get_constraintdef(con.oid,true) FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname=$2 AND con.contype <> 'f' ORDER BY con.conname") - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - definitions.extend( - constraints - .iter() - .map(|row| row_string(row, 0)) - .collect::, _>>()?, - ); - let qualified = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let sequence_ddl = postgres_table_sequence_ddl(pool, schema, table).await?; - let mut ddl = String::new(); - if !sequence_ddl.is_empty() { - ddl.push_str(&sequence_ddl); - ddl.push_str(";\n"); - } - ddl.push_str(&format!( - "CREATE TABLE {qualified} (\n {}\n)", - definitions.join(",\n ") - )); - let indexes = sqlx::query("SELECT pg_get_indexdef(i.indexrelid) FROM pg_index i JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace WHERE n.nspname=$1 AND t.relname=$2 AND NOT i.indisprimary AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid=i.indexrelid) ORDER BY i.indexrelid") - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - for index in indexes { - ddl.push_str(";\n"); - ddl.push_str(&row_string(&index, 0)?); - } - Ok(ddl) -} - -async fn postgres_table_sequence_ddl( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - // Identity columns create their own sequence as part of CREATE TABLE. For - // legacy serial columns PostgreSQL stores a nextval() default that refers - // to a separate, owned sequence, so restore must recreate it first. - let rows = sqlx::query("SELECT quote_ident(sn.nspname)||'.'||quote_ident(seq.relname), quote_ident(a.attname) FROM pg_attribute a JOIN pg_class t ON t.oid=a.attrelid JOIN pg_namespace tn ON tn.oid=t.relnamespace JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum JOIN pg_depend dep ON dep.refobjid=a.attrelid AND dep.refobjsubid=a.attnum AND dep.deptype='a' JOIN pg_class seq ON seq.oid=dep.objid AND seq.relkind='S' JOIN pg_namespace sn ON sn.oid=seq.relnamespace WHERE tn.nspname=$1 AND t.relname=$2 AND a.attnum>0 AND NOT a.attisdropped AND a.attidentity='' ORDER BY a.attnum") - .bind(schema) - .bind(table) - .fetch_all(pool) - .await - .map_err(db_error)?; - let qualified_table = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let mut statements = Vec::new(); - for row in rows { - let sequence = row_string(&row, 0)?; - let column = row_string(&row, 1)?; - statements.push(format!( - "CREATE SEQUENCE {sequence};\nALTER SEQUENCE {sequence} OWNED BY {qualified_table}.{column}" - )); - } - Ok(statements.join(";\n")) -} - -async fn postgres_foreign_key_ddls( - pool: &AnyPool, - schema: &str, - selected_tables: &[&str], -) -> Result, (String, String)> { - if selected_tables.is_empty() { - return Ok(Vec::new()); - } - let selected: std::collections::HashSet<&str> = selected_tables.iter().copied().collect(); - let table_rows = sqlx::query("SELECT CAST(c.relname AS TEXT), 'ALTER TABLE '||quote_ident(n.nspname)||'.'||quote_ident(c.relname)||' ADD CONSTRAINT '||quote_ident(con.conname)||' '||pg_get_constraintdef(con.oid,true) FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND con.contype='f' ORDER BY c.relname, con.conname") - .bind(schema).fetch_all(pool).await.map_err(db_error)?; - let mut statements = Vec::new(); - for row in &table_rows { - let table = row_string(row, 0)?; - if selected.contains(table.as_str()) { - statements.push(row_string(row, 1)?); - } - } - Ok(statements) -} - -async fn postgres_sequence_sql( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - let rows = sqlx::query(r#"SELECT CAST(a.attname AS TEXT), CAST(pg_get_serial_sequence(format('%I.%I',$1,$2),a.attname) AS TEXT) - FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace - LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum - WHERE n.nspname=$1 AND c.relname=$2 AND a.attnum>0 AND NOT a.attisdropped - AND (a.attidentity<>'' OR pg_get_expr(d.adbin,d.adrelid) LIKE 'nextval(%')"#) - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - let qualified = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let mut output = String::new(); - for row in &rows { - let column = row_string(row, 0)?; - let sequence = row_string(row, 1)?; - output.push_str(&format!( - "SELECT setval({}::regclass, COALESCE(MAX({}), 1), COUNT(*) > 0) FROM {};\n", - sql_literal("postgresql", &Value::String(sequence))?, - quote_identifier("postgresql", &column)?, - qualified - )); - } - Ok(output) -} - -async fn write_table_insert_sql( - output: &mut W, - pool: &DirectPool, - kind: &str, - schema: &str, - table: &str, - limit: u32, -) -> Result<(), (String, String)> { - let qualified = if !schema.is_empty() && kind == "postgresql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let sql = if limit == 0 { - format!("SELECT * FROM {qualified}") - } else { - format!( - "SELECT * FROM {qualified} LIMIT {}", - limit.clamp(1, 100_000) - ) - }; - let mut raw_columns = None; - match pool { - DirectPool::MySql(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - mysql_row_json(&row)?, - )?; - } - } - DirectPool::Postgres(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - postgres_row_json(&row)?, - )?; - } - } - DirectPool::Sqlite(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - sqlite_row_json(&row)?, - )?; - } - } - } - Ok(()) -} - -fn write_insert_row( - output: &mut W, - kind: &str, - qualified: &str, - raw_columns: &mut Option>, - row: Value, -) -> Result<(), (String, String)> { - let object = row.as_object().ok_or_else(|| { - ( - "export_failed".into(), - "A database row could not be represented as an object".into(), - ) - })?; - let columns = raw_columns.get_or_insert_with(|| object.keys().cloned().collect()); - let quoted_columns = columns - .iter() - .map(|column| quote_identifier(kind, column)) - .collect::, _>>()?; - let values = columns - .iter() - .map(|column| sql_literal(kind, object.get(column).unwrap_or(&Value::Null))) - .collect::, _>>()?; - write_export( - output, - &format!( - "INSERT INTO {qualified} ({}) VALUES ({});\n", - quoted_columns.join(", "), - values.join(", ") - ), - ) -} - -fn sql_literal(kind: &str, value: &Value) -> Result { - match value { - Value::Null => Ok("NULL".into()), - Value::Bool(value) => Ok(if *value { "TRUE" } else { "FALSE" }.into()), - Value::Number(value) => Ok(value.to_string()), - Value::String(value) => Ok(format!("'{}'", value.replace('\'', "''"))), - Value::Object(object) if binary_input(object).is_some() => { - let bytes = BASE64 - .decode(binary_input(object).unwrap()) - .map_err(|e| ("export_failed".into(), e.to_string()))?; - let hex = bytes - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - Ok(match kind { - "mysql" => format!("0x{hex}"), - "postgresql" => format!("decode('{hex}','hex')"), - _ => format!("X'{hex}'"), - }) - } - other => Ok(format!("'{}'", other.to_string().replace('\'', "''"))), - } -} - -fn row_string(row: &sqlx::any::AnyRow, index: usize) -> Result { - row.try_get::(index).map_err(db_error) -} - -fn bind_values<'q>( - mut query: sqlx::query::Query<'q, Any, sqlx::any::AnyArguments<'q>>, - values: &'q [Value], -) -> Result>, (String, String)> { - for value in values { - query = match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - other => query.bind(other.to_string()), - }; - } - Ok(query) -} - -fn rows_value(rows: Vec) -> DbResult { - Ok(Value::Array( - rows.iter().map(row_json).collect::>()?, - )) -} - -fn metadata_rows_value(rows: Vec) -> DbResult { - let mut values = Vec::with_capacity(rows.len()); - for row in &rows { - let mut value = row_json(row)?; - if let Value::Object(object) = &mut value { - for key in ["data_type", "is_nullable", "column_key", "column_default"] { - let Some(Value::Object(binary)) = object.get(key) else { - continue; - }; - let Some(encoded) = binary_input(binary) else { - continue; - }; - let Ok(bytes) = BASE64.decode(encoded) else { - continue; - }; - let Ok(text) = String::from_utf8(bytes) else { - continue; - }; - object.insert(key.to_string(), Value::String(text)); - } - } - values.push(value); - } - Ok(Value::Array(values)) -} - -fn row_json(row: &sqlx::any::AnyRow) -> Result { - let mut object = Map::new(); - for (index, column) in row.columns().iter().enumerate() { - let raw = row.try_get_raw(index).map_err(db_error)?; - let value = if raw.is_null() { - Value::Null - } else { - // SQLite PRAGMA/expression columns may declare NULL despite a typed value. - typed_value(row, index, raw.type_info().name()) - }; - object.insert(column.name().to_string(), value); - } - Ok(Value::Object(object)) -} - -fn typed_value(row: &sqlx::any::AnyRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "BOOL" | "BOOLEAN" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| text_value(row, index)), - "INT" | "INT2" | "INT4" | "INT8" | "INT16" | "INT32" | "INT64" | "INTEGER" | "BIGINT" - | "SMALLINT" | "TINYINT" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| text_value(row, index)), - "FLOAT" | "FLOAT4" | "FLOAT8" | "REAL" | "DOUBLE" | "DOUBLE PRECISION" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| text_value(row, index)), - "BLOB" | "BYTEA" | "BINARY" | "VARBINARY" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| text_value(row, index)), - _ => text_value(row, index), - } -} - -fn text_value(row: &sqlx::any::AnyRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn validate_identifier(value: &str) -> Result<(), (String, String)> { - if !value.is_empty() && !value.contains('\0') { - Ok(()) - } else { - Err(( - "invalid_identifier".into(), - format!("Invalid SQL identifier: {value}"), - )) - } -} -fn quote_identifier(kind: &str, value: &str) -> Result { - validate_identifier(value)?; - Ok(if kind == "mysql" { - format!("`{}`", value.replace('`', "``")) - } else { - format!("\"{}\"", value.replace('"', "\"\"")) - }) -} -fn require_sql(sql: &str) -> Result<(), (String, String)> { - if sql.trim().is_empty() { - Err(("invalid_query".into(), "SQL is required".into())) - } else { - Ok(()) - } -} -fn csv_value(value: &Value) -> String { - match value { - Value::Null => String::new(), - Value::String(v) => v.clone(), - other => other.to_string(), - } -} -fn db_error(error: sqlx::Error) -> (String, String) { - ("database_error".into(), error.to_string()) -} -fn csv_error(error: csv::Error) -> (String, String) { - ("export_failed".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} - -impl Response { - fn success(id: impl Into, result: Value) -> Self { - Self { - id: id.into(), - ok: true, - result: Some(result), - error: None, - } - } - fn failure(id: impl Into, code: impl Into, message: impl Into) -> Self { - Self { - id: id.into(), - ok: false, - result: None, - error: Some(ErrorBody { - code: code.into(), - message: message.into(), - }), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(unix)] - #[test] - fn dropping_a_tunnel_ends_its_ssh_client() { - let child = Command::new("sleep").arg("30").spawn().unwrap(); - let pid = child.id().to_string(); - drop(SshTunnel { child }); - // The client was killed and reaped, so the PID no longer exists. - let alive = Command::new("kill").args(["-0", &pid]).status().unwrap(); - assert!(!alive.success(), "ssh client {pid} outlived its tunnel"); - } - - #[test] - fn connections_read_the_proxy_url_lithe_sends() { - for key in ["proxyURL", "proxyUrl"] { - let connection: Connection = serde_json::from_value(json!({ - "kind": "mysql", - key: "ssh -W %h:%p bastion", - })) - .unwrap(); - assert_eq!(connection.proxy_url, "ssh -W %h:%p bastion", "{key}"); - } - } - - #[tokio::test] - async fn sqlite_metadata_preserves_primary_key_ordinals() { - sqlx::any::install_default_drivers(); - // In-memory metadata protects both the grid icon and keyed mutations. - tokio::time::timeout(Duration::from_secs(5), async { - let pool = AnyPoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); - sqlx::query("CREATE TABLE single_key (id INTEGER PRIMARY KEY, label TEXT)") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "CREATE TABLE composite_key (a INTEGER, b INTEGER, label TEXT, PRIMARY KEY (a, b))", - ) - .execute(&pool) - .await - .unwrap(); - let single = describe_table(&pool, "sqlite", "", "single_key") - .await - .unwrap(); - let composite = describe_table(&pool, "sqlite", "", "composite_key") - .await - .unwrap(); - pool.close().await; - assert_eq!(single[0]["pk"], json!(1)); - assert_eq!(single[1]["pk"], json!(0)); - assert_eq!(single[0]["cid"], json!(0)); - assert_eq!(single[1]["notnull"], json!(0)); - assert_eq!(single[1]["dflt_value"], Value::Null); - assert_eq!(composite[0]["pk"], json!(1)); - assert_eq!(composite[1]["pk"], json!(2)); - assert_eq!(composite[2]["pk"], json!(0)); - }) - .await - .expect("SQLite metadata test exceeded its local deadline"); - } - - #[test] - fn rejects_update_without_key() { - let mutation = Mutation { - action: "update".into(), - table: "items".into(), - values: Map::from_iter([("name".into(), json!("changed"))]), - key: Map::new(), - }; - assert_eq!( - mutation_sql("mysql", "", &mutation).unwrap_err().0, - "unsafe_mutation" - ); - } - - #[test] - fn postgres_mutations_use_numbered_placeholders() { - let mutation = Mutation { - action: "update".into(), - table: "items".into(), - values: Map::from_iter([("name".into(), json!("changed"))]), - key: Map::from_iter([("id".into(), json!(7))]), - }; - let (sql, values) = mutation_sql("postgresql", "public", &mutation).unwrap(); - assert_eq!( - sql, - "UPDATE \"public\".\"items\" SET \"name\" = $1 WHERE \"id\" = $2" - ); - assert_eq!(values, vec![json!("changed"), json!(7)]); - } - - #[test] - fn empty_inserts_use_database_defaults() { - let mutation = Mutation { - action: "insert".into(), - table: "items".into(), - values: Map::new(), - key: Map::new(), - }; - assert_eq!( - mutation_sql("mysql", "", &mutation).unwrap().0, - "INSERT INTO `items` () VALUES ()" - ); - assert_eq!( - mutation_sql("postgresql", "public", &mutation).unwrap().0, - "INSERT INTO \"public\".\"items\" DEFAULT VALUES" - ); - assert_eq!( - mutation_sql("sqlite", "", &mutation).unwrap().0, - "INSERT INTO \"items\" DEFAULT VALUES" - ); - } - - #[test] - fn filters_quote_identifiers_and_bind_values() { - let filters = vec![Filter { - column: "name".into(), - operator: "contains".into(), - value: json!("lit"), - join: "and".into(), - }]; - let (sql, values) = filter_clause("sqlite", &filters).unwrap(); - assert_eq!(sql, " WHERE (\"name\" LIKE ?)"); - assert_eq!(values, vec![json!("%lit%")]); - let unusual = vec![Filter { - column: "显示 \"名称\"".into(), - operator: "equals".into(), - value: json!(1), - join: "and".into(), - }]; - assert_eq!( - filter_clause("sqlite", &unusual).unwrap().0, - " WHERE (\"显示 \"\"名称\"\"\" = ?)" - ); - assert_eq!(quote_identifier("mysql", "a`b").unwrap(), "`a``b`"); - assert_eq!(quote_identifier("sqlite", "a\"b").unwrap(), "\"a\"\"b\""); - assert_eq!( - quote_identifier("sqlite", "bad\0name").unwrap_err().0, - "invalid_identifier" - ); - } - - #[test] - fn filters_support_or_join() { - let filters = vec![ - Filter { - column: "name".into(), - operator: "equals".into(), - value: json!("missing"), - join: "and".into(), - }, - Filter { - column: "score".into(), - operator: "equals".into(), - value: json!(1), - join: "or".into(), - }, - ]; - let (sql, values) = filter_clause("sqlite", &filters).unwrap(); - assert_eq!(sql, " WHERE (\"name\" = ? OR \"score\" = ?)"); - assert_eq!(values, vec![json!("missing"), json!(1)]); - } - - #[test] - fn sql_literals_preserve_quotes_and_binary_values() { - assert_eq!( - sql_literal("mysql", &json!("O'Reilly")).unwrap(), - "'O''Reilly'" - ); - assert_eq!( - sql_literal("sqlite", &json!({"base64":"AP8="})).unwrap(), - "X'00ff'" - ); - assert_eq!( - sql_literal("sqlite", &json!({"binary":"AP8="})).unwrap(), - "X'00ff'" - ); - assert_eq!( - sql_literal("postgresql", &json!({"base64":"AP8="})).unwrap(), - "decode('00ff','hex')" - ); - } - - #[test] - fn binary_values_use_an_explicit_tag_without_accepting_mixed_objects() { - assert_eq!(tagged_binary([0, 255]), json!({"binary":"AP8="})); - let explicit = json!({"binary":"AP8="}); - assert_eq!(binary_input(explicit.as_object().unwrap()), Some("AP8=")); - let legacy = json!({"base64":"AP8="}); - assert_eq!(binary_input(legacy.as_object().unwrap()), Some("AP8=")); - let mixed = json!({"binary":"AP8=","label":"payload"}); - assert_eq!(binary_input(mixed.as_object().unwrap()), None); - } - - #[test] - fn mysql_text_fallback_preserves_utf8_names_and_binary_payloads() { - assert_eq!(mysql_bytes_value(b"analytics"), json!("analytics")); - assert_eq!(mysql_bytes_value(&[0, 255]), json!({"binary":"AP8="})); - } - - #[test] - fn restore_drops_existing_objects_before_replaying_snapshot() { - assert_eq!( - object_drop_sql("sqlite", "", "records", "table").unwrap(), - Some("DROP TABLE IF EXISTS \"records\"".into()) - ); - assert_eq!( - object_drop_sql("mysql", "", "orders", "BASE TABLE").unwrap(), - Some("DROP TABLE IF EXISTS `orders`".into()) - ); - assert_eq!( - object_drop_sql("postgresql", "public", "active_users", "view").unwrap(), - Some("DROP VIEW IF EXISTS \"public\".\"active_users\"".into()) - ); - assert_eq!( - object_drop_sql("postgresql", "public", "users", "base table").unwrap(), - Some("DROP TABLE IF EXISTS \"public\".\"users\" CASCADE".into()) - ); - } - - #[test] - fn write_protection_classifies_dangerous_sql() { - for sql in [ - "RESTORE", - "DROP TABLE users", - "drop table users", - "TRUNCATE users", - "ALTER TABLE users DROP COLUMN email", - "UPDATE users SET active = 0", - "DELETE FROM users", - "DELETE\nFROM users", - // Comments must not hide the verb from the check. - "-- cleanup\nDELETE FROM users", - "/* cleanup */ DELETE FROM users", - // A WHERE in a comment, literal or subquery does not bound the write. - "DELETE FROM users -- WHERE id = 1", - "UPDATE users SET note = 'WHERE id = 1'", - "UPDATE users SET rank = (SELECT max(rank) FROM ranks WHERE ranks.id = 1)", - "DELETE FROM \"where\"", - "WITH stale AS (SELECT id FROM users WHERE active = 0) DELETE FROM users", - "WITH gone AS (DELETE FROM users RETURNING id) SELECT count(*) FROM gone", - "SELECT 1; DELETE FROM users", - "UPDATE users SET active = 0 WHERE id = 1; DELETE FROM users", - ] { - assert!(is_dangerous_sql("postgresql", sql), "{sql}"); - } - for sql in [ - "SELECT * FROM users", - "INSERT INTO users (name) VALUES ('drop table')", - // Regression: a WHERE on its own line or after a tab used to be missed. - "UPDATE users SET active = 0 WHERE id = 1", - "UPDATE users\nSET active = 0\nWHERE id = 1", - "DELETE FROM users\tWHERE id = 1", - "DELETE FROM users\nwhere id = 1;", - "WITH stale AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM stale)", - "SELECT * FROM users FOR UPDATE", - "CREATE TABLE posts (author_id INT REFERENCES users(id) ON DELETE CASCADE)", - "INSERT INTO users (id) VALUES (1) ON DUPLICATE KEY UPDATE id = id", - "GRANT UPDATE, DELETE ON users TO app", - "SELECT TRUNCATE(1.25, 1)", - "SELECT $$DELETE FROM users$$", - "SELECT $body$ DROP TABLE users $body$", - "SELECT * FROM users WHERE id = $1", - "/* outer /* nested */ DROP TABLE users */ SELECT 1", - "", - "-- only a comment", - ] { - assert!(!is_dangerous_sql("postgresql", sql), "{sql}"); - } - } - - #[test] - fn write_protection_check_matches_execute_guard_without_connecting() { - let request = |production_protection: bool, sql: &str| { - json!({ - "connection": {"kind": "postgresql", "productionProtection": production_protection}, - "sql": sql, - }) - }; - let requires = |value: Value| { - check_write_protection(value).unwrap()["requiresConfirmation"] - .as_bool() - .unwrap() - }; - assert!(requires(request(true, "UPDATE users SET active = 0"))); - assert!(!requires(request( - true, - "UPDATE users SET active = 0\nWHERE id = 1" - ))); - assert!(!requires(request(false, "DROP TABLE users"))); - } - - #[test] - fn write_protection_lexes_quotes_per_dialect() { - // MySQL backslash-escapes quotes, so the WHERE stays inside the literal. - let sql = r"UPDATE users SET note = 'it\' WHERE 1 = 1 --'"; - assert!(is_dangerous_sql("mysql", sql)); - assert!(is_dangerous_sql("mariadb", sql)); - // Standard strings end at the first quote; E'' strings escape like MySQL. - assert!(!is_dangerous_sql( - "postgresql", - r"UPDATE users SET path = 'C:\' WHERE id = 1" - )); - assert!(is_dangerous_sql( - "postgresql", - r"UPDATE users SET note = E'it\' WHERE 1 = 1 --'" - )); - // SQL Server bracket identifiers can contain keywords. - assert!(is_dangerous_sql("sqlserver", "DELETE FROM [where]")); - assert!(!is_dangerous_sql( - "sqlserver", - "DELETE FROM [users] WHERE [id] = 1" - )); - } - - #[test] - fn tls_url_contains_requested_postgres_options_without_password_leakage() { - let connection = Connection { - kind: "postgresql".into(), - host: "db.example".into(), - port: 5432, - username: "alice".into(), - password: "secret".into(), - database: "app".into(), - path: String::new(), - ssl: true, - ca_certificate_path: "/tmp/ca.pem".into(), - server_name: "db.example".into(), - ssh_host: String::new(), - ssh_port: 0, - ssh_username: String::new(), - ssh_key_path: String::new(), - ssh_local_port: 0, - proxy_url: String::new(), - read_only: false, - production_protection: false, - }; - let url = connection.url().unwrap(); - assert!(url.contains("sslmode=require")); - assert!(url.contains("sslrootcert=%2Ftmp%2Fca.pem")); - assert!(redact("connection failed: secret".to_string(), "secret").contains("***")); - } -} diff --git a/rust/lithe-db-sidecar/src/mongodb_adapter.rs b/rust/lithe-db-sidecar/src/mongodb_adapter.rs deleted file mode 100644 index 877c1462e..000000000 --- a/rust/lithe-db-sidecar/src/mongodb_adapter.rs +++ /dev/null @@ -1,534 +0,0 @@ -use futures_util::TryStreamExt; -use mongodb::{ - bson::{doc, Bson, Document}, - options::ClientOptions, - Client, IndexModel, -}; -use serde_json::{json, Map, Value}; -use std::{collections::BTreeMap, time::Duration}; - -use super::{ - ensure_mutations_allowed, ensure_write_allowed, open_ssh_tunnel, Connection, DbResult, Filter, - Mutation, Params, Sort, -}; - -pub(super) async fn method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let client = connect(&connection).await?; - let database_name = if connection.database.trim().is_empty() { - "admin" - } else { - connection.database.trim() - }; - let database = client.database(database_name); - match method { - "testConnection" => { - database - .run_command(doc! { "ping": 1 }) - .await - .map_err(driver_error)?; - Ok(json!({"connected": true})) - } - "listTables" => { - let names = database - .list_collection_names() - .await - .map_err(driver_error)?; - Ok(Value::Array( - names - .into_iter() - .map(|name| json!({"table_name": name, "table_type": "collection"})) - .collect(), - )) - } - "describeTable" => describe_collection(&database, ¶ms.table).await, - "listIndexes" => list_indexes(&database, ¶ms.table).await, - "listForeignKeys" => Ok(Value::Array(Vec::new())), - "listObjects" => { - if !params.object_kind.is_empty() && params.object_kind != "tables" { - return Ok(Value::Array(Vec::new())); - } - let names = database - .list_collection_names() - .await - .map_err(driver_error)?; - Ok(Value::Array( - names - .into_iter() - .map(|name| json!({"object_name": name, "object_kind": "collection"})) - .collect(), - )) - } - "pageTable" => page_collection(&database, ¶ms).await, - "query" => find_json(&database, ¶ms).await, - "applyChanges" => { - ensure_mutations_allowed( - &connection, - ¶ms.mutations, - params.confirmed, - params.allow_write, - )?; - apply_changes(&database, ¶ms.mutations).await - } - "execute" => { - ensure_write_allowed( - &connection, - ¶ms.sql, - params.confirmed, - params.allow_write, - )?; - Err(( - "unsupported_operation".into(), - "Use the MongoDB collection editor for document writes.".into(), - )) - } - _ => Err(( - "unsupported_operation".into(), - format!("{method} is not available for MongoDB in this version."), - )), - } -} - -async fn connect(connection: &Connection) -> Result { - let host = connection.host.trim(); - let uri = mongo_uri(connection); - let timeout = Duration::from_secs(8); - let mut options = tokio::time::timeout(timeout, ClientOptions::parse(&uri)) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "MongoDB connection timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - })?; - options.connect_timeout = Some(timeout); - options.server_selection_timeout = Some(timeout); - if !host.starts_with("mongodb+srv://") && !host.contains(',') { - options.direct_connection = Some(true); - } - Client::with_options(options).map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }) -} - -fn mongo_uri(connection: &Connection) -> String { - let host = connection.host.trim(); - if host.starts_with("mongodb://") || host.starts_with("mongodb+srv://") { - if connection.ssl && !host.to_ascii_lowercase().contains("tls=") { - format!( - "{host}{}tls=true", - if host.contains('?') { "&" } else { "?" } - ) - } else { - host.to_string() - } - } else { - let credentials = if connection.username.is_empty() { - String::new() - } else { - format!( - "{}:{}@", - urlencoding::encode(&connection.username), - urlencoding::encode(&connection.password) - ) - }; - let port = if connection.port == 0 { - 27017 - } else { - connection.port - }; - let database = if connection.database.trim().is_empty() { - "admin" - } else { - connection.database.trim() - }; - let mut query = Vec::new(); - if !connection.username.is_empty() { - // MongoDB users are commonly created in admin while the selected database is an app database. - query.push("authSource=admin"); - } - if connection.ssl { - query.push("tls=true"); - } - let suffix = if query.is_empty() { - String::new() - } else { - format!("?{}", query.join("&")) - }; - format!("mongodb://{credentials}{host}:{port}/{database}{suffix}") - } -} - -async fn describe_collection(database: &mongodb::Database, collection_name: &str) -> DbResult { - let collection = database.collection::(collection_name); - let mut cursor = collection - .find(doc! {}) - .limit(50) - .await - .map_err(driver_error)?; - let mut fields = BTreeMap::::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - for (name, value) in document { - fields - .entry(name) - .or_insert_with(|| bson_type(&value).to_string()); - } - } - if fields.is_empty() { - fields.insert("_id".into(), "objectId".into()); - } - Ok(Value::Array( - fields - .into_iter() - .map(|(name, data_type)| { - json!({ - "column_name": name, - "data_type": data_type, - "is_nullable": "YES", - "column_default": Value::Null, - "column_key": if name == "_id" { "PRI" } else { "" } - }) - }) - .collect(), - )) -} - -async fn list_indexes(database: &mongodb::Database, collection_name: &str) -> DbResult { - let collection = database.collection::(collection_name); - let mut cursor = collection.list_indexes().await.map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(index) = cursor.try_next().await.map_err(driver_error)? { - rows.push(index_json(index)); - } - Ok(Value::Array(rows)) -} - -fn index_json(index: IndexModel) -> Value { - let name = index - .options - .as_ref() - .and_then(|options| options.name.clone()) - .unwrap_or_default(); - json!({"index_name": name, "definition": serde_json::to_string(&index.keys).unwrap_or_default()}) -} - -async fn page_collection(database: &mongodb::Database, params: &Params) -> DbResult { - let collection = database.collection::(¶ms.table); - let filter = mongo_filter(¶ms.filters)?; - let sort = mongo_sort(¶ms.sort); - let limit = params.limit.clamp(1, 1_000); - let total = collection - .count_documents(filter.clone()) - .await - .map_err(driver_error)?; - let mut action = collection - .find(filter) - .skip(params.offset as u64) - .limit(limit as i64); - if !sort.is_empty() { - action = action.sort(sort); - } - let mut cursor = action.await.map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - rows.push(document_json(document)); - } - Ok(json!({"rows": rows, "truncated": false, "totalRows": total})) -} - -async fn find_json(database: &mongodb::Database, params: &Params) -> DbResult { - let source = params.sql.trim(); - let filter = if source.is_empty() { - Document::new() - } else { - let value: Value = serde_json::from_str(source).map_err(|error| { - ( - "invalid_query".into(), - format!("MongoDB filter must be JSON: {error}"), - ) - })?; - json_object_to_document(value)? - }; - let collection_name = params.table.trim(); - if collection_name.is_empty() { - return Err(( - "invalid_query".into(), - "Select a MongoDB collection first.".into(), - )); - } - let collection = database.collection::(collection_name); - let limit = params.limit.clamp(1, 10_000); - let mut cursor = collection - .find(filter) - .limit(limit as i64) - .await - .map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - rows.push(document_json(document)); - } - Ok(json!({"rows": rows, "truncated": false})) -} - -async fn apply_changes(database: &mongodb::Database, mutations: &[Mutation]) -> DbResult { - let mut affected = 0_u64; - for mutation in mutations { - let collection = database.collection::(&mutation.table); - match mutation.action.as_str() { - "insert" => { - collection - .insert_one(map_to_document(&mutation.values)?) - .await - .map_err(driver_error)?; - affected += 1; - } - "update" => { - let result = collection - .update_one( - map_to_document(&mutation.key)?, - doc! { "$set": map_to_document(&mutation.values)? }, - ) - .await - .map_err(driver_error)?; - affected += result.modified_count; - } - "delete" => { - let result = collection - .delete_one(map_to_document(&mutation.key)?) - .await - .map_err(driver_error)?; - affected += result.deleted_count; - } - action => { - return Err(( - "invalid_mutation".into(), - format!("Unsupported MongoDB mutation: {action}"), - )) - } - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn mongo_filter(filters: &[Filter]) -> Result { - let mut result: Option = None; - for filter in filters { - let value = json_to_bson(filter.value.clone())?; - let expression = match filter.operator.as_str() { - "equals" => value, - "notEquals" => Bson::Document(doc! { "$ne": value }), - "greaterThan" => Bson::Document(doc! { "$gt": value }), - "lessThan" => Bson::Document(doc! { "$lt": value }), - "contains" => Bson::Document( - doc! { "$regex": regex_escape(&bson_scalar_text(&value)?), "$options": "i" }, - ), - "startsWith" => Bson::Document( - doc! { "$regex": format!("^{}", regex_escape(&bson_scalar_text(&value)?)), "$options": "i" }, - ), - "isNull" => Bson::Null, - "isNotNull" => Bson::Document(doc! { "$ne": Bson::Null }), - operator => { - return Err(( - "invalid_filter".into(), - format!("Unsupported MongoDB filter operator: {operator}"), - )) - } - }; - let mut predicate = Document::new(); - predicate.insert(filter.column.clone(), expression); - result = Some(match result { - None => predicate, - Some(previous) => { - let operator = match filter.join.as_str() { - "and" => "$and", - "or" => "$or", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported MongoDB filter join: {value}"), - )) - } - }; - let mut combined = Document::new(); - combined.insert( - operator, - Bson::Array(vec![Bson::Document(previous), Bson::Document(predicate)]), - ); - combined - } - }); - } - Ok(result.unwrap_or_default()) -} - -fn mongo_sort(sort: &[Sort]) -> Document { - sort.iter() - .map(|item| { - ( - item.column.clone(), - if item.descending { - Bson::Int32(-1) - } else { - Bson::Int32(1) - }, - ) - }) - .collect() -} - -fn map_to_document(map: &Map) -> Result { - map.iter() - .map(|(key, value)| Ok((key.clone(), json_to_bson(value.clone())?))) - .collect() -} - -fn json_object_to_document(value: Value) -> Result { - match json_to_bson(value)? { - Bson::Document(document) => Ok(document), - _ => Err(( - "invalid_value".into(), - "A MongoDB document must be a JSON object.".into(), - )), - } -} - -fn json_to_bson(value: Value) -> Result { - Bson::try_from(value).map_err(|error| ("invalid_value".into(), error.to_string())) -} - -fn document_json(document: Document) -> Value { - serde_json::to_value(document).unwrap_or_else(|_| Value::Object(Map::new())) -} - -fn bson_type(value: &Bson) -> &'static str { - match value { - Bson::Double(_) => "double", - Bson::String(_) => "string", - Bson::Array(_) => "array", - Bson::Document(_) => "document", - Bson::Boolean(_) => "bool", - Bson::Null => "null", - Bson::Int32(_) => "int", - Bson::Int64(_) => "long", - Bson::ObjectId(_) => "objectId", - Bson::DateTime(_) => "date", - Bson::Binary(_) => "binary", - Bson::Decimal128(_) => "decimal", - _ => "value", - } -} - -fn regex_escape(value: &str) -> String { - value - .chars() - .flat_map(|character| { - if ".*+?^${}()|[]\\".contains(character) { - vec!['\\', character] - } else { - vec![character] - } - }) - .collect() -} - -fn bson_scalar_text(value: &Bson) -> Result { - match value { - Bson::String(value) => Ok(value.clone()), - Bson::Int32(value) => Ok(value.to_string()), - Bson::Int64(value) => Ok(value.to_string()), - Bson::Double(value) => Ok(value.to_string()), - Bson::Boolean(value) => Ok(value.to_string()), - _ => Err(( - "invalid_filter".into(), - "MongoDB text filters require a scalar value.".into(), - )), - } -} - -fn driver_error(error: mongodb::error::Error) -> (String, String) { - ("mongodb_error".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} - -#[cfg(test)] -mod tests { - use super::*; - - fn connection( - host: &str, - username: &str, - password: &str, - database: &str, - ssl: bool, - ) -> Connection { - Connection { - kind: "mongodb".into(), - host: host.into(), - port: 27017, - username: username.into(), - password: password.into(), - database: database.into(), - path: String::new(), - ssl, - ca_certificate_path: String::new(), - server_name: String::new(), - ssh_host: String::new(), - ssh_port: 0, - ssh_username: String::new(), - ssh_key_path: String::new(), - ssh_local_port: 0, - proxy_url: String::new(), - read_only: false, - production_protection: false, - } - } - - #[test] - fn host_connections_authenticate_against_admin_by_default() { - let uri = mongo_uri(&connection( - "127.0.0.1", - "root", - "secret", - "lithe_test", - false, - )); - assert_eq!( - uri, - "mongodb://root:secret@127.0.0.1:27017/lithe_test?authSource=admin" - ); - } - - #[test] - fn explicit_mongodb_uri_keeps_its_options() { - let uri = mongo_uri(&connection( - "mongodb://root:secret@db.example/lithe_test?authSource=custom", - "", - "", - "", - true, - )); - assert_eq!( - uri, - "mongodb://root:secret@db.example/lithe_test?authSource=custom&tls=true" - ); - } -} diff --git a/rust/lithe-db-sidecar/src/sqlserver_adapter.rs b/rust/lithe-db-sidecar/src/sqlserver_adapter.rs deleted file mode 100644 index d6bbac71c..000000000 --- a/rust/lithe-db-sidecar/src/sqlserver_adapter.rs +++ /dev/null @@ -1,682 +0,0 @@ -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use serde_json::{json, Map, Value}; -use std::{borrow::Cow, time::Duration}; -use tiberius::{AuthMethod, Client, ColumnData, Config, FromSql}; -use tokio::net::TcpStream; -use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; - -use super::{ - ensure_mutations_allowed, ensure_transaction_allowed, ensure_write_allowed, open_ssh_tunnel, - parse_csv, parse_json, scalar_string, validate_identifier, Connection, DbResult, Filter, - Mutation, Params, Sort, -}; - -type SqlServerClient = Client>; - -pub(super) async fn method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let mut client = connect(&connection).await?; - match method { - "testConnection" => { - query(&mut client, "SELECT 1 AS connected", 1).await?; - Ok(json!({"connected": true})) - } - "listTables" => query( - &mut client, - "SELECT TABLE_NAME AS table_name, TABLE_TYPE AS table_type FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' ORDER BY TABLE_NAME", - 10_000, - ).await, - "describeTable" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT c.COLUMN_NAME AS column_name, c.DATA_TYPE AS data_type, c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, CASE WHEN tc.CONSTRAINT_TYPE='PRIMARY KEY' THEN 'PRI' ELSE '' END AS column_key FROM INFORMATION_SCHEMA.COLUMNS c LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu ON kcu.TABLE_SCHEMA=c.TABLE_SCHEMA AND kcu.TABLE_NAME=c.TABLE_NAME AND kcu.COLUMN_NAME=c.COLUMN_NAME LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=kcu.CONSTRAINT_NAME AND tc.CONSTRAINT_TYPE='PRIMARY KEY' WHERE c.TABLE_SCHEMA='dbo' AND c.TABLE_NAME={table} ORDER BY c.ORDINAL_POSITION" - ), 10_000).await - } - "listIndexes" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT i.name AS index_name, i.type_desc AS definition FROM sys.indexes i JOIN sys.tables t ON t.object_id=i.object_id JOIN sys.schemas s ON s.schema_id=t.schema_id WHERE s.name='dbo' AND t.name={table} AND i.name IS NOT NULL ORDER BY i.name" - ), 10_000).await - } - "listForeignKeys" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT fk.name AS constraint_name, pc.name AS column_name, rt.name AS referenced_table_name, rc.name AS referenced_column_name FROM sys.foreign_key_columns fkc JOIN sys.foreign_keys fk ON fk.object_id=fkc.constraint_object_id JOIN sys.tables pt ON pt.object_id=fkc.parent_object_id JOIN sys.schemas ps ON ps.schema_id=pt.schema_id JOIN sys.columns pc ON pc.object_id=pt.object_id AND pc.column_id=fkc.parent_column_id JOIN sys.tables rt ON rt.object_id=fkc.referenced_object_id JOIN sys.columns rc ON rc.object_id=rt.object_id AND rc.column_id=fkc.referenced_column_id WHERE ps.name='dbo' AND pt.name={table} ORDER BY fk.name, fkc.constraint_column_id" - ), 10_000).await - } - "listObjects" => list_objects(&mut client, ¶ms.object_kind).await, - "pageTable" => page_table(&mut client, ¶ms).await, - "query" => { - if !params.values.is_empty() { - return Err(("unsupported_parameters".into(), "SQL Server parameter values are not available in this version.".into())); - } - query(&mut client, ¶ms.sql, params.limit.clamp(1, 10_000) as usize).await - } - "execute" => { - ensure_write_allowed(&connection, ¶ms.sql, params.confirmed, params.allow_write)?; - execute(&mut client, ¶ms.sql).await - } - "applyChanges" => { - ensure_mutations_allowed(&connection, ¶ms.mutations, params.confirmed, params.allow_write)?; - apply_changes(&mut client, ¶ms.mutations).await - } - "schemaChange" => { - ensure_write_allowed(&connection, "ALTER TABLE", params.confirmed, params.allow_write)?; - let sql = schema_change_sql(¶ms)?; - execute(&mut client, &sql).await - } - "transaction" => { - ensure_transaction_allowed(&connection, ¶ms.statements, params.confirmed, params.allow_write)?; - run_transaction(&mut client, ¶ms).await - } - "exportCsv" => export_csv(&mut client, ¶ms.sql, params.limit).await, - "exportJson" => export_json(&mut client, ¶ms.sql, params.limit).await, - "importCsv" | "importJson" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data(&mut client, ¶ms, method == "importCsv").await - } - "diagnostics" if params.diagnostic_kind == "tableSize" => query( - &mut client, - "SELECT t.name AS table_name, SUM(p.rows) AS estimated_rows, SUM(a.total_pages) * 8192 AS total_bytes FROM sys.tables t JOIN sys.indexes i ON t.object_id=i.object_id JOIN sys.partitions p ON i.object_id=p.object_id AND i.index_id=p.index_id JOIN sys.allocation_units a ON p.partition_id=a.container_id GROUP BY t.name ORDER BY total_bytes DESC", - 1_000, - ).await, - _ => Err(("unsupported_operation".into(), format!("{method} is not available for SQL Server in this version."))), - } -} - -async fn connect(connection: &Connection) -> Result { - let mut config = Config::new(); - config.host(connection.host.trim()); - config.port(if connection.port == 0 { - 1433 - } else { - connection.port - }); - config.authentication(AuthMethod::sql_server( - &connection.username, - &connection.password, - )); - if !connection.database.trim().is_empty() { - config.database(connection.database.trim()); - } - if connection.ssl { - config.encryption(tiberius::EncryptionLevel::Required); - // SQL Server installations commonly use an internal/self-signed leaf. - // The connection remains encrypted; custom CA verification can be added - // without changing the persisted profile format. - config.trust_cert(); - } else { - config.encryption(tiberius::EncryptionLevel::Off); - } - let timeout = Duration::from_secs(8); - let tcp = tokio::time::timeout(timeout, TcpStream::connect(config.get_addr())) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "SQL Server connection timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - })?; - tokio::time::timeout(timeout, Client::connect(config, tcp.compat_write())) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "SQL Server handshake timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }) -} - -async fn query(client: &mut SqlServerClient, sql: &str, limit: usize) -> DbResult { - let stream = client.simple_query(sql).await.map_err(driver_error)?; - let rows = stream.into_first_result().await.map_err(driver_error)?; - let truncated = rows.len() > limit; - let values = rows - .into_iter() - .take(limit) - .map(|row| { - let mut object = Map::new(); - for (column, cell) in row.cells() { - object.insert(column.name().to_string(), cell_json(cell)); - } - Value::Object(object) - }) - .collect::>(); - Ok(json!({"rows": values, "truncated": truncated})) -} - -async fn execute(client: &mut SqlServerClient, sql: &str) -> DbResult { - let result = client.execute(sql, &[]).await.map_err(driver_error)?; - Ok(json!({"rowsAffected": result.total()})) -} - -async fn list_objects(client: &mut SqlServerClient, requested: &str) -> DbResult { - let sql = match requested { - "views" => "SELECT v.name AS object_name, 'view' AS object_kind, OBJECT_DEFINITION(v.object_id) AS definition FROM sys.views v JOIN sys.schemas s ON s.schema_id=v.schema_id WHERE s.name='dbo' ORDER BY v.name", - "routines" => "SELECT o.name AS object_name, o.type_desc AS object_kind, OBJECT_DEFINITION(o.object_id) AS definition FROM sys.objects o JOIN sys.schemas s ON s.schema_id=o.schema_id WHERE s.name='dbo' AND o.type IN ('P','FN','IF','TF') ORDER BY o.name", - "triggers" => "SELECT tr.name AS object_name, 'trigger' AS object_kind, OBJECT_DEFINITION(tr.object_id) AS definition FROM sys.triggers tr WHERE tr.parent_class=1 ORDER BY tr.name", - "sequences" => "SELECT seq.name AS object_name, 'sequence' AS object_kind, CAST(NULL AS nvarchar(max)) AS definition FROM sys.sequences seq JOIN sys.schemas s ON s.schema_id=seq.schema_id WHERE s.name='dbo' ORDER BY seq.name", - _ => "SELECT TABLE_NAME AS object_name, TABLE_TYPE AS object_kind, CAST(NULL AS nvarchar(max)) AS definition FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='dbo' ORDER BY TABLE_NAME", - }; - query(client, sql, 10_000).await -} - -async fn page_table(client: &mut SqlServerClient, params: &Params) -> DbResult { - validate_identifier(¶ms.table)?; - let table = quote_identifier(¶ms.table); - let where_sql = filter_clause(¶ms.filters)?; - let order_sql = sort_clause(¶ms.sort)?; - let limit = params.limit.clamp(1, 1_000); - let sql = format!("SELECT * FROM [dbo].{table}{where_sql}{order_sql} OFFSET {} ROWS FETCH NEXT {limit} ROWS ONLY", params.offset); - let mut result = query(client, &sql, limit as usize).await?; - let count_sql = format!("SELECT COUNT_BIG(*) AS total_rows FROM [dbo].{table}{where_sql}"); - let count = query(client, &count_sql, 1).await?; - let total = count - .get("rows") - .and_then(Value::as_array) - .and_then(|rows| rows.first()) - .and_then(|row| row.get("total_rows")) - .and_then(Value::as_i64) - .unwrap_or(0); - result - .as_object_mut() - .expect("query result") - .insert("totalRows".into(), json!(total)); - Ok(result) -} - -async fn apply_changes(client: &mut SqlServerClient, mutations: &[Mutation]) -> DbResult { - execute(client, "BEGIN TRANSACTION").await?; - let mut affected = 0_u64; - for mutation in mutations { - let sql = mutation_sql(mutation)?; - match client.execute(sql, &[]).await { - Ok(result) => affected += result.total(), - Err(error) => { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(driver_error(error)); - } - } - } - client - .execute("COMMIT TRANSACTION", &[]) - .await - .map_err(driver_error)?; - Ok(json!({"rowsAffected": affected})) -} - -async fn run_transaction(client: &mut SqlServerClient, params: &Params) -> DbResult { - client - .execute("BEGIN TRANSACTION", &[]) - .await - .map_err(driver_error)?; - let mut affected = 0_u64; - for statement in ¶ms.statements { - if !statement.values.is_empty() { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(( - "unsupported_parameters".into(), - "SQL Server transaction parameters are not available in this version.".into(), - )); - } - match client.execute(&statement.sql, &[]).await { - Ok(result) => affected += result.total(), - Err(error) => { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(driver_error(error)); - } - } - } - client - .execute("COMMIT TRANSACTION", &[]) - .await - .map_err(driver_error)?; - Ok(json!({"rowsAffected": affected})) -} - -async fn export_csv(client: &mut SqlServerClient, sql: &str, limit: u32) -> DbResult { - let result = query(client, sql, limit.clamp(1, 100_000) as usize).await?; - let rows = result["rows"].as_array().cloned().unwrap_or_default(); - let headers: Vec = rows - .first() - .and_then(Value::as_object) - .map(|row| row.keys().cloned().collect()) - .unwrap_or_default(); - let mut writer = csv::Writer::from_writer(Vec::new()); - writer.write_record(&headers).map_err(super::csv_error)?; - for row in rows { - let object = row - .as_object() - .ok_or_else(|| ("export_failed".into(), "Invalid SQL Server row.".into()))?; - writer - .write_record( - headers - .iter() - .map(|key| super::csv_value(object.get(key).unwrap_or(&Value::Null))), - ) - .map_err(super::csv_error)?; - } - let bytes = writer - .into_inner() - .map_err(|error| ("export_failed".into(), error.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn export_json(client: &mut SqlServerClient, sql: &str, limit: u32) -> DbResult { - let result = query(client, sql, limit.clamp(1, 100_000) as usize).await?; - let bytes = serde_json::to_vec_pretty(&result["rows"]) - .map_err(|error| ("export_failed".into(), error.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn import_data(client: &mut SqlServerClient, params: &Params, is_csv: bool) -> DbResult { - let bytes = BASE64.decode(¶ms.data).map_err(|error| { - ( - "invalid_import".into(), - format!("Invalid base64 data: {error}"), - ) - })?; - let rows = if is_csv { - parse_csv(&bytes)? - } else { - parse_json(&bytes)? - }; - let mutations = rows - .into_iter() - .map(|values| Mutation { - action: "insert".into(), - table: params.table.clone(), - values, - key: Map::new(), - }) - .collect::>(); - apply_changes(client, &mutations).await -} - -fn schema_change_sql(params: &Params) -> Result { - validate_identifier(¶ms.table)?; - let table = format!("[dbo].{}", quote_identifier(¶ms.table)); - match params.operation.as_str() { - "addColumn" => { - validate_identifier(¶ms.name)?; - let data_type = super::safe_sql_fragment(¶ms.data_type, "column type")?; - Ok(format!( - "ALTER TABLE {table} ADD {} {data_type}{}{}", - quote_identifier(¶ms.name), - if params.nullable { - " NULL" - } else { - " NOT NULL" - }, - if params.default_value.is_empty() { - String::new() - } else { - format!( - " DEFAULT {}", - super::safe_sql_fragment(¶ms.default_value, "default")? - ) - } - )) - } - "renameColumn" => { - validate_identifier(¶ms.old_name)?; - validate_identifier(¶ms.name)?; - Ok(format!( - "EXEC sp_rename N'dbo.{}.{}', {}, 'COLUMN'", - params.table.replace('\'', "''"), - params.old_name.replace('\'', "''"), - sql_string(¶ms.name) - )) - } - "dropColumn" => { - validate_identifier(¶ms.name)?; - Ok(format!( - "ALTER TABLE {table} DROP COLUMN {}", - quote_identifier(¶ms.name) - )) - } - "createIndex" => { - validate_identifier(¶ms.index_name)?; - let columns = params - .index_columns - .iter() - .map(|column| { - validate_identifier(column)?; - Ok(quote_identifier(column)) - }) - .collect::, (String, String)>>()?; - if columns.is_empty() { - return Err(( - "invalid_schema_change".into(), - "At least one index column is required.".into(), - )); - } - Ok(format!( - "CREATE INDEX {} ON {table} ({})", - quote_identifier(¶ms.index_name), - columns.join(", ") - )) - } - "dropIndex" => { - validate_identifier(¶ms.index_name)?; - Ok(format!( - "DROP INDEX {} ON {table}", - quote_identifier(¶ms.index_name) - )) - } - "addForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - validate_identifier(¶ms.referenced_table)?; - let columns = quoted_names(¶ms.index_columns)?; - let referenced = quoted_names(¶ms.referenced_columns)?; - if columns.is_empty() || columns.len() != referenced.len() { - return Err(( - "invalid_schema_change".into(), - "Foreign-key columns must be paired.".into(), - )); - } - Ok(format!( - "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES [dbo].{} ({})", - quote_identifier(¶ms.constraint_name), - columns.join(", "), - quote_identifier(¶ms.referenced_table), - referenced.join(", ") - )) - } - "dropForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - Ok(format!( - "ALTER TABLE {table} DROP CONSTRAINT {}", - quote_identifier(¶ms.constraint_name) - )) - } - operation => Err(( - "invalid_schema_change".into(), - format!("Unsupported SQL Server schema operation: {operation}"), - )), - } -} - -fn quoted_names(values: &[String]) -> Result, (String, String)> { - values - .iter() - .map(|value| { - validate_identifier(value)?; - Ok(quote_identifier(value)) - }) - .collect() -} - -fn mutation_sql(mutation: &Mutation) -> Result { - validate_identifier(&mutation.table)?; - let table = quote_identifier(&mutation.table); - match mutation.action.as_str() { - "insert" => { - if mutation.values.is_empty() { - return Ok(format!("INSERT INTO [dbo].{table} DEFAULT VALUES")); - } - let columns = mutation - .values - .keys() - .map(|name| { - validate_identifier(name)?; - Ok(quote_identifier(name)) - }) - .collect::, (String, String)>>()?; - let values = mutation - .values - .values() - .map(sql_value) - .collect::, _>>()?; - Ok(format!( - "INSERT INTO [dbo].{table} ({}) VALUES ({})", - columns.join(", "), - values.join(", ") - )) - } - "update" => { - let values = assignments(&mutation.values)?; - let keys = predicates(&mutation.key)?; - Ok(format!("UPDATE [dbo].{table} SET {values} WHERE {keys}")) - } - "delete" => Ok(format!( - "DELETE FROM [dbo].{table} WHERE {}", - predicates(&mutation.key)? - )), - action => Err(( - "invalid_mutation".into(), - format!("Unsupported mutation: {action}"), - )), - } -} - -fn assignments(values: &Map) -> Result { - if values.is_empty() { - return Err(( - "invalid_mutation".into(), - "Update values are required.".into(), - )); - } - values - .iter() - .map(|(name, value)| { - validate_identifier(name)?; - Ok(format!( - "{} = {}", - quote_identifier(name), - sql_value(value)? - )) - }) - .collect::, _>>() - .map(|items| items.join(", ")) -} - -fn predicates(values: &Map) -> Result { - if values.is_empty() { - return Err(("invalid_mutation".into(), "A row key is required.".into())); - } - values - .iter() - .map(|(name, value)| { - validate_identifier(name)?; - Ok(if value.is_null() { - format!("{} IS NULL", quote_identifier(name)) - } else { - format!("{} = {}", quote_identifier(name), sql_value(value)?) - }) - }) - .collect::, _>>() - .map(|items| items.join(" AND ")) -} - -fn filter_clause(filters: &[Filter]) -> Result { - let mut predicates = Vec::new(); - for filter in filters { - validate_identifier(&filter.column)?; - let column = quote_identifier(&filter.column); - let predicate = match filter.operator.as_str() { - "equals" => format!("{column} = {}", sql_value(&filter.value)?), - "notEquals" => format!("{column} <> {}", sql_value(&filter.value)?), - "greaterThan" => format!("{column} > {}", sql_value(&filter.value)?), - "lessThan" => format!("{column} < {}", sql_value(&filter.value)?), - "contains" => format!( - "{column} LIKE {}", - sql_string(&format!("%{}%", scalar_string(&filter.value)?)) - ), - "startsWith" => format!( - "{column} LIKE {}", - sql_string(&format!("{}%", scalar_string(&filter.value)?)) - ), - "isNull" => format!("{column} IS NULL"), - "isNotNull" => format!("{column} IS NOT NULL"), - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter operator: {value}"), - )) - } - }; - let join = match filter.join.as_str() { - "and" => "AND", - "or" => "OR", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter join: {value}"), - )) - } - }; - predicates.push((join, predicate)); - } - Ok(if predicates.is_empty() { - String::new() - } else { - let mut clause = predicates[0].1.clone(); - for (join, predicate) in predicates.iter().skip(1) { - clause.push_str(&format!(" {join} {predicate}")); - } - format!(" WHERE ({clause})") - }) -} - -fn sort_clause(sort: &[Sort]) -> Result { - if sort.is_empty() { - return Ok(" ORDER BY (SELECT NULL)".into()); - } - let items = sort - .iter() - .map(|item| { - validate_identifier(&item.column)?; - Ok(format!( - "{} {}", - quote_identifier(&item.column), - if item.descending { "DESC" } else { "ASC" } - )) - }) - .collect::, (String, String)>>()?; - Ok(format!(" ORDER BY {}", items.join(", "))) -} - -fn quote_identifier(value: &str) -> String { - format!("[{}]", value.replace(']', "]]")) -} -fn sql_string(value: &str) -> String { - format!("N'{}'", value.replace('\'', "''")) -} -fn sql_value(value: &Value) -> Result { - Ok(match value { - Value::Null => "NULL".into(), - Value::Bool(value) => { - if *value { - "1".into() - } else { - "0".into() - } - } - Value::Number(value) => value.to_string(), - Value::String(value) => sql_string(value), - Value::Object(object) if object.len() == 1 && object.contains_key("datetime") => { - sql_string(object["datetime"].as_str().unwrap_or("")) - } - Value::Object(object) if object.len() == 1 && object.contains_key("uuid") => { - sql_string(object["uuid"].as_str().unwrap_or("")) - } - _ => { - return Err(( - "invalid_value".into(), - "SQL Server table edits require scalar values.".into(), - )) - } - }) -} - -fn cell_json(cell: &ColumnData<'static>) -> Value { - if let Ok(Some(value)) = <&str as FromSql>::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let ColumnData::Numeric(Some(value)) = cell { - return Value::String(format_numeric(value.value(), value.scale())); - } - if let ColumnData::Binary(Some(value)) = cell { - return json!({"binary": BASE64.encode(value.as_ref())}); - } - if let ColumnData::String(Some(Cow::Borrowed(value))) = cell { - return Value::String((*value).to_string()); - } - Value::Null -} - -fn format_numeric(value: i128, scale: u8) -> String { - if scale == 0 { - return value.to_string(); - } - let digits = value.unsigned_abs().to_string(); - let scale = scale as usize; - let sign = if value < 0 { "-" } else { "" }; - if digits.len() > scale { - let (integer, fraction) = digits.split_at(digits.len() - scale); - format!("{sign}{integer}.{fraction}") - } else { - format!("{sign}0.{digits:0>scale$}") - } -} - -fn driver_error(error: tiberius::error::Error) -> (String, String) { - ("sqlserver_error".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} diff --git a/scripts/build-database-mcp.sh b/scripts/build-database-mcp.sh deleted file mode 100755 index 0f8874537..000000000 --- a/scripts/build-database-mcp.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -ARCH="${LITHE_ARCH:-$(uname -m)}" -MANIFEST="$ROOT_DIR/rust/Cargo.toml" -DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" -OUTPUT_DIR="$DIST_ROOT/database-mcp" - -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" -mkdir -p "$OUTPUT_DIR" - -build_arch() { - local architecture="$1" - local target - case "$architecture" in - arm64) target="aarch64-apple-darwin" ;; - x86_64) target="x86_64-apple-darwin" ;; - *) print -u2 -- "Unsupported database MCP architecture: $architecture"; exit 1 ;; - esac - - cargo build --manifest-path "$MANIFEST" --release --target "$target" -p lithe-db-mcp >&2 - print -r -- "$ROOT_DIR/rust/target/$target/release/lithe-db-mcp" -} - -case "$ARCH" in - arm64|x86_64) - binary=$(build_arch "$ARCH") - output="$OUTPUT_DIR/lithe-db-mcp-$ARCH" - cp "$binary" "$output" - print -r -- "$output" - ;; - universal) - arm_binary=$(build_arch arm64) - intel_binary=$(build_arch x86_64) - output="$OUTPUT_DIR/lithe-db-mcp" - lipo -create "$arm_binary" "$intel_binary" -output "$output" - print -r -- "$output" - ;; - *) - print -u2 -- "Unsupported database MCP architecture: $ARCH" - exit 1 - ;; -esac diff --git a/scripts/build-database-sidecar.sh b/scripts/build-database-sidecar.sh deleted file mode 100755 index 61315c4e1..000000000 --- a/scripts/build-database-sidecar.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -ARCH="${LITHE_ARCH:-$(uname -m)}" -MANIFEST="$ROOT_DIR/rust/Cargo.toml" -DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" -OUTPUT_DIR="$DIST_ROOT/database-sidecar" - -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" -mkdir -p "$OUTPUT_DIR" - -build_arch() { - local architecture="$1" - local target - case "$architecture" in - arm64) target="aarch64-apple-darwin" ;; - x86_64) target="x86_64-apple-darwin" ;; - esac - local rust_sysroot - rust_sysroot=$(rustc --print sysroot) - if [[ ! -d "$rust_sysroot/lib/rustlib/$target" ]]; then - print -u2 -- "Rust target $target is not installed. Install it with: rustup target add $target" - return 2 - fi - cargo build --manifest-path "$MANIFEST" --release --target "$target" -p lithe-db-sidecar >&2 - print -r -- "$ROOT_DIR/rust/target/$target/release/lithe-db-sidecar" -} - -case "$ARCH" in - arm64|x86_64) - binary=$(build_arch "$ARCH") - cp "$binary" "$OUTPUT_DIR/lithe-db-sidecar-$ARCH" - print -r -- "$OUTPUT_DIR/lithe-db-sidecar-$ARCH" - ;; - universal) - arm_binary=$(build_arch arm64) - intel_binary=$(build_arch x86_64) - lipo -create "$arm_binary" "$intel_binary" -output "$OUTPUT_DIR/lithe-db-sidecar" - print -r -- "$OUTPUT_DIR/lithe-db-sidecar" - ;; - *) - print -u2 -- "Unsupported database sidecar architecture: $ARCH" - exit 1 - ;; -esac diff --git a/scripts/ci-workflow-gates.test.mjs b/scripts/ci-workflow-gates.test.mjs index d576859ae..18e92cae3 100644 --- a/scripts/ci-workflow-gates.test.mjs +++ b/scripts/ci-workflow-gates.test.mjs @@ -15,7 +15,7 @@ const commonBindings = { const workflows = [ { id: 'macos', - selected: ['swift', 'swift_database', 'rust_core', 'macos_release'], + selected: ['swift', 'rust_core', 'macos_release'], lanes: [ { lane: 'swift', job: 'swift-tests', run: 'run_swift' }, { lane: 'rust', job: 'rust-core-tests', run: 'run_rust' }, @@ -29,7 +29,7 @@ const workflows = [ RUN_SWIFT: 'needs.changes.outputs.run_swift', RUN_RUST: 'needs.changes.outputs.run_rust', RUN_RELEASE: 'needs.changes.outputs.run_release', - SWIFT_SELECTED: "needs.changes.outputs.swift == 'true' || needs.changes.outputs.swift_database == 'true' || needs.changes.outputs.rust_core == 'true'", + SWIFT_SELECTED: "needs.changes.outputs.swift == 'true' || needs.changes.outputs.rust_core == 'true'", RUST_CORE_SELECTED: 'needs.changes.outputs.rust_core', RELEASE_SELECTED: 'needs.changes.outputs.macos_release', }, @@ -68,16 +68,6 @@ const workflows = [ RUN_PLUGINS: 'needs.changes.outputs.run_plugins', }, }, - { - id: 'database', - selected: ['rust_database'], - lanes: [{ lane: 'database', job: 'rust-database-tests' }], - bindings: { - CHANGES_RESULT: 'needs.changes.result', - RUST_DATABASE_SELECTED: 'needs.changes.outputs.rust_database', - RUST_DATABASE_RESULT: 'needs.rust-database-tests.result', - }, - }, { id: 'remote', selected: ['rust_remote'], diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index eec2203f3..d03a2acc1 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -40,9 +40,7 @@ rust_diff_is_comment_only() { swift=false plugins=false -swift_database=false rust_core=false -rust_database=false rust_remote=false macos_release=false windows=false @@ -55,9 +53,7 @@ git_validation=false enable_all_validation() { swift=true plugins=true - swift_database=true rust_core=true - rust_database=true rust_remote=true macos_release=true windows=true @@ -92,10 +88,7 @@ classify_path() { enable_all_validation ;; .github/workflows/ci-macos.yml) - # The shared Swift build job also hosts the database filtered - # suite, so a workflow change exercises both. swift=true - swift_database=true rust_core=true macos_release=true git_validation=true @@ -103,10 +96,6 @@ classify_path() { .github/workflows/ci-plugins.yml) plugins=true ;; - .github/workflows/ci-database.yml) - swift_database=true - rust_database=true - ;; .github/workflows/ci-remote.yml|scripts/verify-remote-workspace.sh) rust_remote=true ;; @@ -123,7 +112,6 @@ classify_path() { .github/actions/prepare-macos-dependency-cache/*|.github/actions/save-macos-cargo-build-cache/*|.github/actions/setup-macos-toolchain/*|.swift-version) swift=true plugins=true - swift_database=true rust_core=true macos_release=true git_validation=true @@ -133,7 +121,6 @@ classify_path() { # product test lane, but does not affect release packaging. swift=true plugins=true - swift_database=true rust_core=true windows=true windows_rust=true @@ -142,11 +129,10 @@ classify_path() { .github/*|docs/*|.agents/*|.idea/*|.gitignore|license) ;; package.swift|package.resolved) - # SwiftPM graph changes can affect the main suite and both isolated - # module suites even when no source path changes. + # SwiftPM graph changes can affect the main and plugin suites even + # when no source path changes. swift=true plugins=true - swift_database=true macos_release=true git_validation=true ;; @@ -179,50 +165,31 @@ classify_path() { swift=true plugins=true ;; - macos/sources/lithedatabasemodule/*|macos/tests/lithedatabasemoduletests/*) - swift_database=true - if [[ "$lowercase_path" == macos/sources/* ]]; then - macos_release=true - fi - ;; - macos/sources/lithe/platform/macos/persistence/macdatabaserecoverystore.swift|macos/sources/lithe/platform/macos/process/macdatabasesidecarlocator.swift|macos/sources/lithe/platform/macos/storage/macdatabaseadapters.swift|macos/sources/lithe/theme/databasebrandicon.swift|macos/sources/lithe/views/database/*|macos/resources/databaseicons/*) - swift_database=true - macos_release=true - ;; macos/sources/lithe/models/appmodel/appmodel.swift) - # This is a shared app composition point for the database module. + # The app model composes every workbench feature, so a change is + # also verified in the packaged app. swift=true - swift_database=true macos_release=true ;; macos/sources/lithe/platform/macos/macservicecontainer.swift) - # The service container composes both database and plugin runtime dependencies. + # The service container composes the plugin runtime dependencies. swift=true plugins=true - swift_database=true macos_release=true ;; macos/sources/lithe/views/workbench/workbenchview.swift) - # The root workbench composes both database and plugin-owned UI. + # The root workbench composes plugin-owned UI. # Plugin tests cover plugin models and packages rather than this # view, and it is edited by most workbench pull requests, so the # Swift lane's full compile covers it without the plugin runner. swift=true - swift_database=true - macos_release=true - ;; - macos/sources/lithemoduleapi/catalog/builtinmodulecatalog.swift) - swift=true - plugins=true - swift_database=true macos_release=true ;; macos/sources/lithemoduleapi/*|macos/sources/litheapplicationkernel/*|macos/sources/lithecorecontracts/*) # These contracts and runtime primitives are direct dependencies of - # both isolated Swift module suites. + # both the main and the plugin Swift suites. swift=true plugins=true - swift_database=true macos_release=true ;; macos/sources/lithelanguageintelligencemodule/*) @@ -231,21 +198,6 @@ classify_path() { plugins=true macos_release=true ;; - macos/tests/lithetests/lithecorelogictests.swift) - # This legacy mixed suite still contains the database integration - # tests; both filtered lanes must run until the suite is separated. - swift=true - swift_database=true - ;; - macos/resources/*.lproj/*) - # Localization files contain database copy and are assembled into - # the package. Their plugin-facing coverage lives in - # AppLocalizationTests, which the Swift lane runs, so UI copy - # changes do not reserve the separate plugin runner. - swift=true - swift_database=true - macos_release=true - ;; macos/sources/litherustcore/*) rust_core=true swift=true @@ -308,18 +260,11 @@ classify_path() { ;; rust/cargo.toml|rust/cargo.lock) rust_core=true - rust_database=true rust_remote=true macos_release=true windows=true windows_rust=true ;; - rust/lithe-db-mcp/*|rust/lithe-db-sidecar/*) - rust_database=true - # Database helpers are shipped as macOS executables, so Linux crate - # tests are not sufficient to validate their production target. - macos_release=true - ;; rust/lithe-remote-protocol/*|rust/lithe-remote-server/*|rust/lithe-remote-client/*) # The remote workspace crates are not linked into a shipped product # yet. Their Linux lane also exercises the server's Linux adapter, @@ -330,7 +275,6 @@ classify_path() { # Unknown Rust workspace paths fail closed across all Rust-backed # products because workspace configuration can affect every crate. rust_core=true - rust_database=true macos_release=true windows=true windows_rust=true @@ -361,7 +305,6 @@ classify_path() { scripts/test-macos.sh|scripts/test-git-performance-baseline.sh|scripts/run-git-performance-verifier.mjs) swift=true plugins=true - swift_database=true git_validation=true ;; scripts/package-macos-localizations.sh) @@ -372,7 +315,7 @@ classify_path() { ;; scripts/measure-macos-performance-baseline.sh) # The baseline harness assembles a macOS release app, without changing - # shared Core behavior or the isolated database/plugin test lanes. + # shared Core behavior or the isolated plugin test lane. macos_release=true ;; scripts/verify-official-plugins.sh|scripts/build-official-plugins.sh) @@ -388,20 +331,12 @@ classify_path() { windows=true windows_rust=true ;; - scripts/build-database-mcp.sh|scripts/build-database-sidecar.sh) - rust_database=true - macos_release=true - ;; - scripts/database-sidecar-smoke.sh|scripts/database-validation-smoke.sh) - rust_database=true - ;; scripts/*sparkle*|scripts/import-macos-signing.sh|scripts/build-macos.sh|scripts/verify-macos-app-build-safety.sh|scripts/verify-runtime-bundle-immutability.sh|scripts/verify-macos-package.sh|scripts/macos13sdkcompatibility.h|scripts/ld-macos13-compat.sh|scripts/package-app.sh|scripts/preview.sh|scripts/stamp-macos-app-build-info.sh|scripts/create-dmg.sh|scripts/create-macos-update-manifest.rb|scripts/test-macos-update-manifest.rb|scripts/prepare-jdtls.sh|scripts/prepare-jdk.sh) macos_release=true ;; scripts/verify-download-cache.mjs|scripts/test-verify-download-cache.mjs|scripts/reuse-worktree-resources.mjs|scripts/test-reuse-worktree-resources.mjs|scripts/worktree-resources.json) swift=true plugins=true - swift_database=true rust_core=true macos_release=true windows=true @@ -414,7 +349,6 @@ classify_path() { scripts/validate-macos-dependency-caches.sh|scripts/prepare-macos-dependencies.sh) swift=true plugins=true - swift_database=true rust_core=true macos_release=true ;; @@ -423,9 +357,6 @@ classify_path() { ;; scripts/prepare-lithe-pr-review.mjs|scripts/test-prepare-lithe-pr-review.mjs|scripts/run-lithe-codex-with-timeout.sh|scripts/update-repo-charts.py) ;; - infra/docker/*) - rust_database=true - ;; *) # New or unclassified repository areas are validated conservatively # until their ownership is made explicit above. @@ -477,7 +408,6 @@ if [[ "${LITHE_CI_DRAFT_PULL_REQUEST:-false}" == "true" ]]; then # Marking the pull request ready for review triggers a full classification. swift=false plugins=false - swift_database=false rust_core=false macos_release=false windows=false @@ -497,14 +427,12 @@ case "$ci_scope" in pull_request) ;; post_merge) package_affected=false - for lane in "$swift" "$plugins" "$swift_database" "$rust_core" "$macos_release"; do + for lane in "$swift" "$plugins" "$rust_core" "$macos_release"; do if [[ "$lane" == true ]]; then package_affected=true; fi done swift=false plugins=false - swift_database=false rust_core=false - rust_database=false rust_remote=false macos_release="$package_affected" windows=false @@ -517,9 +445,7 @@ case "$ci_scope" in release_promotion) swift=false plugins=false - swift_database=false rust_core=false - rust_database=false rust_remote=false macos_release=false windows=false @@ -537,9 +463,7 @@ esac printf 'swift=%s\n' "$swift" printf 'plugins=%s\n' "$plugins" -printf 'swift_database=%s\n' "$swift_database" printf 'rust_core=%s\n' "$rust_core" -printf 'rust_database=%s\n' "$rust_database" printf 'rust_remote=%s\n' "$rust_remote" printf 'macos_release=%s\n' "$macos_release" printf 'windows=%s\n' "$windows" diff --git a/scripts/database-sidecar-smoke.sh b/scripts/database-sidecar-smoke.sh deleted file mode 100755 index b4ad82bc3..000000000 --- a/scripts/database-sidecar-smoke.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -SIDECAR="${LITHE_DB_SIDECAR_EXECUTABLE:-$ROOT_DIR/rust/target/debug/lithe-db-sidecar}" - -if [[ ! -x "$SIDECAR" ]]; then - print -u2 -- "Database sidecar is not executable: $SIDECAR" - exit 2 -fi - -response=$(print -rn -- '{"id":"smoke","method":"capabilities","params":{}}' | "$SIDECAR") -for database_type in mysql postgresql sqlite; do - if ! print -r -- "$response" | grep -q '"'$database_type'"'; then - print -u2 -- "Capabilities did not include $database_type." - exit 1 - fi -done -for feature in transactionalCrud csvImportExport jsonImportExport; do - if ! print -r -- "$response" | grep -q '"'$feature'"'; then - print -u2 -- "Capabilities did not include $feature." - exit 1 - fi -done -print -r -- "$response" diff --git a/scripts/database-validation-smoke.sh b/scripts/database-validation-smoke.sh deleted file mode 100755 index 276b5f76d..000000000 --- a/scripts/database-validation-smoke.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -COMPOSE_FILE="$ROOT_DIR/infra/docker/database-validation/compose.yaml" -SIDECAR="${LITHE_DB_SIDECAR_EXECUTABLE:-$ROOT_DIR/rust/target/debug/lithe-db-sidecar}" -BIND_ADDRESS="${LITHE_BIND_ADDRESS:-127.0.0.1}" -MARIADB_PORT="${LITHE_MARIADB_PORT:-53307}" -MONGODB_PORT="${LITHE_MONGODB_PORT:-57019}" -REDIS_PORT="${LITHE_REDIS_PORT:-6381}" -DB_PASSWORD="${LITHE_DB_PASSWORD:-lithe_root}" - -if [[ ! -x "$SIDECAR" ]]; then - print -u2 -- "Database sidecar is not executable: $SIDECAR" - print -u2 -- "Build it with: cargo build --manifest-path $ROOT_DIR/rust/Cargo.toml -p lithe-db-sidecar" - exit 2 -fi - -docker compose -f "$COMPOSE_FILE" config --quiet -docker compose -f "$COMPOSE_FILE" up -d mariadb mongodb redis - -wait_for_health() { - local container="$1" - local attempt - local health_status - for attempt in {1..60}; do - health_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true) - if [[ "$health_status" == "healthy" ]]; then - return 0 - fi - if [[ "$health_status" == "unhealthy" || "$health_status" == "exited" || "$health_status" == "dead" ]]; then - docker logs "$container" >&2 || true - print -u2 -- "$container did not become healthy (status: $health_status)" - return 1 - fi - sleep 1 - done - docker logs "$container" >&2 || true - print -u2 -- "$container healthcheck timed out" - return 1 -} - -wait_for_health lithe-database-validation-mariadb -wait_for_health lithe-database-validation-mongodb -wait_for_health lithe-database-validation-redis -docker compose -f "$COMPOSE_FILE" up --no-deps redis-seed - -typeset -g LAST_RESPONSE - -request() { - local request_id="$1" - local method="$2" - local params="$3" - LAST_RESPONSE=$(print -rn -- "{\"id\":\"$request_id\",\"method\":\"$method\",\"params\":$params}" | "$SIDECAR" || true) - print -r -- "$LAST_RESPONSE" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -if not response.get("ok"): - error = response.get("error", {}) - code = error.get("code", "unknown") - message = error.get("message", "unknown error") - raise SystemExit(f"sidecar request failed: {code}: {message}") -' -} - -assert_result_contains() { - local needle="$1" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -needle = sys.argv[1] -if needle not in json.dumps(response.get("result"), ensure_ascii=False): - raise SystemExit(f"expected result to contain {needle!r}") -' "$needle" -} - -assert_result() { - local check="$1" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -check = sys.argv[1] -result = response.get("result") -if check == "mariadb_rows": - rows = result.get("rows", []) - assert len(rows) == 2 and rows[0]["empty_value"] == "" and rows[0]["nullable_note"] is None -elif check == "mariadb_page": - assert result.get("totalRows") == 2 and len(result.get("rows", [])) == 1 -elif check == "mongo_page": - assert result.get("totalRows") == 2 and len(result.get("rows", [])) == 2 -elif check == "mongo_query": - assert len(result.get("rows", [])) == 1 and result["rows"][0]["name"] == "中文 / emoji 🚀" -elif check == "redis_scan": - keys = {item["key"] for item in result.get("keys", [])} - assert {"profile:42", "session:42", "queue:jobs", "ttl:short"}.issubset(keys) -elif check == "redis_hash": - assert result["type"] == "hash" and {entry["field"] for entry in result["hashEntries"]} == {"user_id", "locale"} -else: - raise SystemExit(f"unknown assertion {check!r}") -' "$check" -} - -mariadb='{"kind":"mariadb","host":"'$BIND_ADDRESS'","port":'$MARIADB_PORT',"username":"root","password":"'$DB_PASSWORD'","database":"lithe_test"}' -mongodb='{"kind":"mongodb","host":"'$BIND_ADDRESS'","port":'$MONGODB_PORT',"username":"root","password":"'$DB_PASSWORD'","database":"lithe_test"}' -redis='{"kind":"redis","host":"'$BIND_ADDRESS'","port":'$REDIS_PORT',"password":"'$DB_PASSWORD'","database":"0"}' - -print -r -- "MariaDB: test connection, metadata, typed values, and pagination" -request mariadb-connect testConnection '{"connection":'$mariadb'}' -request mariadb-tables listTables '{"connection":'$mariadb'}' -assert_result_contains records -request mariadb-columns describeTable '{"connection":'$mariadb',"table":"records"}' -assert_result_contains empty_value -assert_result_contains tiny_value -request mariadb-query query '{"connection":'$mariadb',"sql":"SELECT id, empty_value, nullable_note, flag, tiny_value, amount FROM records ORDER BY id","limit":10}' -assert_result mariadb_rows -request mariadb-page pageTable '{"connection":'$mariadb',"table":"records","limit":1,"offset":1,"sort":[{"column":"id"}]}' -assert_result mariadb_page - -print -r -- "MongoDB: test connection, collections, indexes, documents, and filters" -request mongodb-connect testConnection '{"connection":'$mongodb'}' -request mongodb-tables listTables '{"connection":'$mongodb'}' -assert_result_contains items -request mongodb-indexes listIndexes '{"connection":'$mongodb',"table":"items"}' -assert_result_contains items_name_idx -request mongodb-page pageTable '{"connection":'$mongodb',"table":"items","limit":10,"sort":[{"column":"score","descending":true}]}' -assert_result mongo_page -request mongodb-query query '{"connection":'$mongodb',"table":"items","sql":"{\"name\":\"中文 / emoji 🚀\"}","limit":10}' -assert_result mongo_query - -print -r -- "Redis: test connection, scan, string/hash/list metadata, and key detail" -request redis-connect testConnection '{"connection":'$redis'}' -request redis-scan redisScan '{"connection":'$redis',"cursor":"0","pattern":"*","count":100,"includeSize":true}' -assert_result redis_scan -request redis-string redisGetKey '{"connection":'$redis',"key":"profile:42"}' -assert_result_contains 'Alice / demo' -request redis-hash redisGetKey '{"connection":'$redis',"key":"session:42"}' -assert_result redis_hash -request redis-list redisGetKey '{"connection":'$redis',"key":"queue:jobs"}' -assert_result_contains 'list' - -print -r -- "Database validation smoke passed. Containers remain running for manual Lithe verification." diff --git a/scripts/measure-macos-performance-baseline.sh b/scripts/measure-macos-performance-baseline.sh index 4e7f86591..5241ec2c0 100755 --- a/scripts/measure-macos-performance-baseline.sh +++ b/scripts/measure-macos-performance-baseline.sh @@ -109,7 +109,6 @@ prepare_baseline_app() { cp -R "$project_root/.artifacts/editor/macos" "$app_dir/Contents/Resources/MonacoEditor" cp -R "$project_root/macos/Resources/IDEAIcons" "$app_dir/Contents/Resources/IDEAIcons" cp -R "$project_root/macos/Resources/GitGraph" "$app_dir/Contents/Resources/GitGraph" - cp -R "$project_root/macos/Resources/DatabaseIcons" "$app_dir/Contents/Resources/DatabaseIcons" zsh "$project_root/scripts/package-macos-localizations.sh" \ "$project_root/macos/Resources" "$app_dir/Contents/Resources" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $bundle_identifier" \ diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 447d32363..96a93eac5 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -49,40 +49,6 @@ fi rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" -database_sidecar="${LITHE_DB_SIDECAR_EXECUTABLE:-}" -if [[ -z "$database_sidecar" && "${LITHE_SKIP_DATABASE_SIDECAR:-0}" != "1" ]]; then - database_sidecar=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-database-sidecar.sh") -fi -if [[ -n "$database_sidecar" ]]; then - if [[ ! -x "$database_sidecar" ]]; then - print -u2 -- "LITHE_DB_SIDECAR_EXECUTABLE is not executable: $database_sidecar" - exit 1 - fi - if [[ "$ARCH" == "universal" ]] && \ - ! lipo "$database_sidecar" -verify_arch arm64 x86_64 >/dev/null 2>&1; then - print -u2 -- "Universal packaging requires a fat database helper with arm64 and x86_64 slices" - exit 1 - fi - mkdir -p "$APP_DIR/Contents/Helpers" - cp "$database_sidecar" "$APP_DIR/Contents/Helpers/lithe-db-sidecar" -fi -database_mcp="${LITHE_DB_MCP_EXECUTABLE:-}" -if [[ -z "$database_mcp" && "${LITHE_SKIP_DATABASE_MCP:-0}" != "1" ]]; then - database_mcp=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-database-mcp.sh") -fi -if [[ -n "$database_mcp" ]]; then - if [[ ! -x "$database_mcp" ]]; then - print -u2 -- "LITHE_DB_MCP_EXECUTABLE is not executable: $database_mcp" - exit 1 - fi - if [[ "$ARCH" == "universal" ]] && \ - ! lipo "$database_mcp" -verify_arch arm64 x86_64 >/dev/null 2>&1; then - print -u2 -- "Universal packaging requires a fat database MCP helper with arm64 and x86_64 slices" - exit 1 - fi - mkdir -p "$APP_DIR/Contents/Helpers" - cp "$database_mcp" "$APP_DIR/Contents/Helpers/lithe-db-mcp" -fi ide_mcp=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-ide-mcp.sh") mkdir -p "$APP_DIR/Contents/Helpers" cp "$ide_mcp" "$APP_DIR/Contents/Helpers/lithe-mcp" @@ -154,7 +120,6 @@ zsh "$ROOT_DIR/scripts/configure-sparkle-app.sh" "$APP_DIR/Contents/Info.plist" cp "$ROOT_DIR/macos/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R "$ROOT_DIR/macos/Resources/IDEAIcons" "$APP_DIR/Contents/Resources/IDEAIcons" cp -R "$ROOT_DIR/macos/Resources/GitGraph" "$APP_DIR/Contents/Resources/GitGraph" -cp -R "$ROOT_DIR/macos/Resources/DatabaseIcons" "$APP_DIR/Contents/Resources/DatabaseIcons" cp -R "$ROOT_DIR/macos/Resources/Fonts" "$APP_DIR/Contents/Resources/Fonts" cp -R "$ROOT_DIR/.artifacts/editor/macos" "$APP_DIR/Contents/Resources/MonacoEditor" zsh "$ROOT_DIR/scripts/package-macos-localizations.sh" \ diff --git a/scripts/plan-ci-lane-reuse.mjs b/scripts/plan-ci-lane-reuse.mjs index fa083bcc9..e78387d41 100644 --- a/scripts/plan-ci-lane-reuse.mjs +++ b/scripts/plan-ci-lane-reuse.mjs @@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'; // outputs that select it. Keep in sync with the workflows' job `if:` lines. export const WORKFLOW_LANES = Object.freeze({ macos: [ - { lane: 'swift', job: 'Swift tests', selected: (o) => o.swift || o.swift_database || o.rust_core }, + { lane: 'swift', job: 'Swift tests', selected: (o) => o.swift || o.rust_core }, { lane: 'rust', job: 'Rust Core tests', selected: (o) => o.rust_core }, { lane: 'release', job: 'Release package verification', prefix: true, selected: (o) => o.macos_release }, ], diff --git a/scripts/preview.sh b/scripts/preview.sh index 5067fd4a8..c8bafe979 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -59,14 +59,6 @@ case "$TRIPLE" in arm64-apple-macosx) RUST_TARGET="aarch64-apple-darwin" ;; x86_64-apple-macosx) RUST_TARGET="x86_64-apple-darwin" ;; esac -MACOSX_DEPLOYMENT_TARGET=13.0 \ - CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ - cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-db-sidecar --target "$RUST_TARGET" -cp "rust/target/macos/$RUST_TARGET/debug/lithe-db-sidecar" "$APP_DIR/Contents/Helpers/lithe-db-sidecar" -MACOSX_DEPLOYMENT_TARGET=13.0 \ - CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ - cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-db-mcp --target "$RUST_TARGET" -cp "rust/target/macos/$RUST_TARGET/debug/lithe-db-mcp" "$APP_DIR/Contents/Helpers/lithe-db-mcp" MACOSX_DEPLOYMENT_TARGET=13.0 \ CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-ide-host --features mcp --bin lithe-mcp --target "$RUST_TARGET" @@ -77,7 +69,6 @@ zsh "$ROOT_DIR/scripts/embed-sparkle.sh" "$APP_DIR" cp macos/Resources/AppIcon.icns "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R macos/Resources/IDEAIcons "$APP_DIR/Contents/Resources/IDEAIcons" cp -R macos/Resources/GitGraph "$APP_DIR/Contents/Resources/GitGraph" -cp -R macos/Resources/DatabaseIcons "$APP_DIR/Contents/Resources/DatabaseIcons" cp -R macos/Resources/Fonts "$APP_DIR/Contents/Resources/Fonts" cp -R "$ROOT_DIR/.artifacts/editor/macos" "$APP_DIR/Contents/Resources/MonacoEditor" zsh "$ROOT_DIR/scripts/package-macos-localizations.sh" \ diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 91c7c6f5f..662c6ba47 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -23,21 +23,16 @@ mkdir -p \ Plugins/win/ExampleSupport \ macos/Sources/Lithe/Platform/MacOS/Plugins \ macos/Sources/Lithe/Views/Community \ - macos/Sources/Lithe/Views/Database \ macos/Sources/Lithe/Core/Rust \ macos/Sources/LitheCoreContracts \ - macos/Sources/LitheDatabaseModule \ Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule \ - macos/Tests/LitheDatabaseModuleTests \ Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests \ macos/Tests/LitheTests \ macos/Sources/LitheGitModule \ macos/Tests/LitheGitPerformanceTests \ - infra/docker/database-validation \ rust/lithe-core/src/tests \ rust/lithe-core/src/lsp/languages \ rust/lithe-core/tests \ - rust/lithe-db-sidecar/src \ shared/fixtures/core \ windows/tauri/src \ windows/tauri/src-tauri/src @@ -45,22 +40,16 @@ printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 1 }' > rust/lithe-core/ printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(1, 1); }' > rust/lithe-core/src/lsp/tests.rs printf '%s\n' 'pub fn normalize() {}' > rust/lithe-core/src/lsp/languages/java_tests.rs printf '%s\n' '#[test]' 'fn value_is_one() { assert_eq!(1, 1); }' > rust/lithe-core/tests/value.rs -printf '%s\n' 'fn main() {}' > rust/lithe-db-sidecar/src/main.rs -printf '%s\n' 'services: {}' > infra/docker/database-validation/compose.yaml printf '%s\n' '#!/bin/zsh' 'print -- package' > scripts/verify-macos-package.sh printf '%s\n' 'struct App {}' > macos/Sources/Lithe/App.swift printf '%s\n' 'struct RustCoreBridge {}' > macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift printf '%s\n' 'struct PluginManager {}' > macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift -printf '%s\n' 'struct DatabaseView {}' > macos/Sources/Lithe/Views/Database/DatabaseView.swift printf '%s\n' 'struct CoreContracts {}' > macos/Sources/LitheCoreContracts/CoreContracts.swift -printf '%s\n' 'struct DatabaseModule {}' > macos/Sources/LitheDatabaseModule/DatabaseModule.swift printf '%s\n' 'struct GoSupportModule {}' > Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule/GoSupportModule.swift printf '%s\n' 'struct AppTests {}' > macos/Tests/LitheTests/AppTests.swift printf '%s\n' 'struct GitFeature {}' > macos/Sources/LitheGitModule/GitFeature.swift printf '%s\n' 'struct GitPerformanceTests {}' > macos/Tests/LitheGitPerformanceTests/GitPerformanceTests.swift printf '%s\n' 'struct PluginManagerTests {}' > macos/Tests/LitheTests/PluginManagerTests.swift -printf '%s\n' 'struct LitheCoreLogicTests {}' > macos/Tests/LitheTests/LitheCoreLogicTests.swift -printf '%s\n' 'struct DatabaseTests {}' > macos/Tests/LitheDatabaseModuleTests/DatabaseTests.swift printf '%s\n' 'struct GoSupportTests {}' > Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportTests.swift printf '%s\n' '{"id":"dev.lithe.go-support"}' > Plugins/mac/Official/GoSupport/plugin.json printf '%s\n' '{"id":"dev.lithe.win.example-support"}' > Plugins/win/ExampleSupport/plugin.json @@ -76,24 +65,20 @@ BASE_REVISION="$(git rev-parse HEAD)" classification() { local swift="$1" local plugins="$2" - local swift_database="$3" - local rust_core="$4" - local rust_database="$5" - local macos_release="$6" - local windows="$7" - local windows_rust="$8" - local rust_comments="$9" - local metadata="${10}" - local git_validation="${11:-false}" - local java_jdt="${12:-false}" - local ci_scope="${13:-pull_request}" - local rust_remote="${14:-false}" + local rust_core="$3" + local macos_release="$4" + local windows="$5" + local windows_rust="$6" + local rust_comments="$7" + local metadata="${8}" + local git_validation="${9:-false}" + local java_jdt="${10:-false}" + local ci_scope="${11:-pull_request}" + local rust_remote="${12:-false}" printf 'swift=%s\n' "$swift" printf 'plugins=%s\n' "$plugins" - printf 'swift_database=%s\n' "$swift_database" printf 'rust_core=%s\n' "$rust_core" - printf 'rust_database=%s\n' "$rust_database" printf 'rust_remote=%s\n' "$rust_remote" printf 'macos_release=%s\n' "$macos_release" printf 'windows=%s\n' "$windows" @@ -130,7 +115,6 @@ modify_rust_comment() { printf '%s\n' '//! Updated test module.' 'pub fn value() modify_rust_code() { printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 2 }' > rust/lithe-core/src/lib.rs; } modify_rust_test() { printf '%s\n' '#[test]' 'fn value_is_two() { assert_eq!(2, 2); }' > rust/lithe-core/tests/value.rs; } modify_rust_source_test() { printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(2, 2); }' > rust/lithe-core/src/lsp/tests.rs; } -modify_database_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > rust/lithe-db-sidecar/src/main.rs; } modify_remote_rust() { mkdir -p rust/lithe-remote-server/src printf '%s\n' '//! Remote server.' > rust/lithe-remote-server/src/lib.rs @@ -139,7 +123,6 @@ modify_remote_workflow() { mkdir -p .github/workflows printf '%s\n' 'name: Remote Workspace CI' > .github/workflows/ci-remote.yml } -modify_database_docker() { printf '%s\n' 'services:' ' mariadb: {}' > infra/docker/database-validation/compose.yaml; } modify_macos_package_verifier() { printf '%s\n' '#!/bin/zsh' 'print -- updated-package' > scripts/verify-macos-package.sh; } modify_localization_packaging() { printf '%s\n' '#!/bin/zsh' 'print -- localizations' > scripts/package-macos-localizations.sh; } modify_macos_performance_baseline() { printf '%s\n' '#!/bin/zsh' 'print -- baseline' > scripts/measure-macos-performance-baseline.sh; } @@ -156,10 +139,6 @@ modify_windows_plugin_manifest() { printf '%s\n' '{"id":"dev.lithe.win.example-s modify_plugin_host_source() { printf '%s\n' 'struct UpdatedPluginManager {}' > macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift; } modify_plugin_host_test() { printf '%s\n' 'struct UpdatedPluginManagerTests {}' > macos/Tests/LitheTests/PluginManagerTests.swift; } modify_shared_swift_contract() { printf '%s\n' 'struct UpdatedCoreContracts {}' > macos/Sources/LitheCoreContracts/CoreContracts.swift; } -modify_database_swift() { printf '%s\n' 'struct UpdatedDatabaseModule {}' > macos/Sources/LitheDatabaseModule/DatabaseModule.swift; } -modify_database_swift_test() { printf '%s\n' 'struct UpdatedDatabaseTests {}' > macos/Tests/LitheDatabaseModuleTests/DatabaseTests.swift; } -modify_database_app_source() { printf '%s\n' 'struct UpdatedDatabaseView {}' > macos/Sources/Lithe/Views/Database/DatabaseView.swift; } -modify_mixed_core_logic_test() { printf '%s\n' 'struct UpdatedLitheCoreLogicTests {}' > macos/Tests/LitheTests/LitheCoreLogicTests.swift; } modify_feature_matrix() { mkdir -p shared/platform-feature-matrix/features printf '%s\n' '{"id":"updated"}' > shared/platform-feature-matrix/features/example.json @@ -212,10 +191,6 @@ add_bundled_jdt_manifest() { } add_jdk_preparation_script() { printf '%s\n' '#!/bin/zsh' 'print -- jdk' > scripts/prepare-jdk.sh; } add_windows_java_source() { printf '%s\n' 'export const java = true;' > windows/tauri/src/java.ts; } -modify_swift_and_database() { - modify_swift_source - modify_database_swift -} rename_rust_to_markdown() { mkdir -p docs git mv rust/lithe-core/src/lib.rs docs/lib.md @@ -224,157 +199,136 @@ add_chinese_swift_source() { printf '%s\n' 'struct Example {}' > 'macos/Sources/ rename_swift_source() { git mv macos/Sources/Lithe/App.swift macos/Sources/Lithe/RenamedApp.swift } -rename_swift_into_database_module() { - git mv macos/Sources/Lithe/App.swift macos/Sources/LitheDatabaseModule/App.swift +rename_swift_into_contracts_module() { + git mv macos/Sources/Lithe/App.swift macos/Sources/LitheCoreContracts/App.swift } modify_rust_bridge_verifier() { printf '%s\n' '#!/bin/zsh' > scripts/verify-rust-core-swift-bridge.sh; } assert_classification readme \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_readme assert_classification rust-comment \ - "$(classification false false false false false false false false true false)" \ + "$(classification false false false false false false true false)" \ modify_rust_comment assert_classification rust-code \ - "$(classification false false false true false true true true false false)" \ + "$(classification false false true true true true false false)" \ modify_rust_code assert_classification rust-test \ - "$(classification false false false true false false false false false false)" \ + "$(classification false false true false false false false false)" \ modify_rust_test assert_classification rust-source-test \ - "$(classification false false false true false false false false false false)" \ + "$(classification false false true false false false false false)" \ modify_rust_source_test -assert_classification database-rust \ - "$(classification false false false false true true false false false false)" \ - modify_database_rust # Remote workspace crates select only their own Linux lane. assert_classification remote-rust \ - "$(classification false false false false false false false false false false false false pull_request true)" \ + "$(classification false false false false false false false false false false pull_request true)" \ modify_remote_rust assert_classification remote-workflow \ - "$(classification false false false false false false false false false false false false pull_request true)" \ + "$(classification false false false false false false false false false false pull_request true)" \ modify_remote_workflow -assert_classification database-docker \ - "$(classification false false false false true false false false false false)" \ - modify_database_docker assert_classification macos-package-verifier \ - "$(classification false false false false false true false false false false)" \ + "$(classification false false false true false false false false)" \ modify_macos_package_verifier assert_classification swift-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_source assert_classification swift-bridge \ - "$(classification true false false false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_swift_bridge assert_classification git-source \ - "$(classification true false false false false false false false false false true)" \ + "$(classification true false false false false false false false true)" \ modify_git_source assert_classification git-performance-test \ - "$(classification true false false false false false false false false false true)" \ + "$(classification true false false false false false false false true)" \ modify_git_performance_test assert_classification localization-packaging \ - "$(classification true false false false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_localization_packaging assert_classification macos-performance-baseline \ - "$(classification false false false false false true false false false false)" \ + "$(classification false false false true false false false false)" \ modify_macos_performance_baseline assert_classification unknown-script \ - "$(classification true true true true true true true true false false true true pull_request true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ modify_unknown_script assert_classification swift-test \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_test assert_classification plugin-manifest \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_manifest assert_classification plugin-source \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_source assert_classification plugin-test \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_test assert_classification windows-plugin-manifest \ - "$(classification false false false false false false true true false false)" \ + "$(classification false false false false true true false false)" \ modify_windows_plugin_manifest assert_classification plugin-host-source \ - "$(classification true true false false false true false false false false)" \ + "$(classification true true false true false false false false)" \ modify_plugin_host_source assert_classification plugin-host-test \ - "$(classification false true false false false false false false false false)" \ + "$(classification false true false false false false false false)" \ modify_plugin_host_test assert_classification shared-swift-contract \ - "$(classification true true true false false true false false false false)" \ + "$(classification true true false true false false false false)" \ modify_shared_swift_contract -assert_classification database-swift \ - "$(classification false false true false false true false false false false)" \ - modify_database_swift -assert_classification database-swift-test \ - "$(classification false false true false false false false false false false)" \ - modify_database_swift_test -assert_classification database-app-source \ - "$(classification false false true false false true false false false false)" \ - modify_database_app_source -assert_classification mixed-core-logic-test \ - "$(classification true false true false false false false false false false)" \ - modify_mixed_core_logic_test -assert_classification swift-and-database \ - "$(classification true false true false false true false false false false)" \ - modify_swift_and_database assert_classification shared-fixture \ - "$(classification true false false true false false true true false false)" \ + "$(classification true false true false true true false false)" \ modify_shared_fixture assert_classification feature-matrix \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_feature_matrix assert_classification feature-matrix-with-swift \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_feature_matrix_and_swift assert_classification shared-editor \ - "$(classification false false false false false true true false false false)" \ + "$(classification false false false true true false false false)" \ modify_shared_editor assert_classification workbench-view \ - "$(classification true false true false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_workbench_view assert_classification localization-strings \ - "$(classification true false true false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_localization_strings assert_classification windows-frontend \ - "$(classification false false false false false false true false false false)" \ + "$(classification false false false false true false false false)" \ modify_windows_frontend assert_classification windows-rust \ - "$(classification false false false false false false true true false false)" \ + "$(classification false false false false true true false false)" \ modify_windows_rust # Swift Java adapters compile in the Swift lane; only the shared engine and # its Windows consumers select the separate real-JDT integration lane. assert_classification macos-java-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_macos_java_source assert_classification macos-java-test \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_macos_java_test assert_classification java-jdt \ - "$(classification false false false true false true true true false false false true)" \ + "$(classification false false true true true true false false false true)" \ modify_java_jdt assert_classification shared-jdt-engine \ - "$(classification false false false true false true true true false false false true)" \ + "$(classification false false true true true true false false false true)" \ add_shared_jdt_engine assert_classification bundled-jdt-manifest \ - "$(classification true true true true true true true true false false true true pull_request true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ add_bundled_jdt_manifest assert_classification jdk-preparation \ - "$(classification false false false false false true false false false false false true)" \ + "$(classification false false false true false false false false false true)" \ add_jdk_preparation_script assert_classification windows-java-source \ - "$(classification false false false false false false true false false false false true)" \ + "$(classification false false false false true false false false false true)" \ add_windows_java_source assert_classification download-cache-validator \ - "$(classification true true true true false true true true false false)" \ + "$(classification true true true true true true false false)" \ modify_download_cache_validator assert_classification worktree-resource-registry \ - "$(classification true true true true false true true true false false)" \ + "$(classification true true true true true true false false)" \ modify_worktree_resource_registry assert_classification test-stability-runner \ - "$(classification true true true true false false true true false false true)" \ + "$(classification true true true false true true false false true)" \ modify_test_stability_runner modify_swift_version() { printf '%s\n' '6.3.3' > .swift-version; } modify_macos_toolchain_action() { @@ -382,28 +336,28 @@ modify_macos_toolchain_action() { printf '%s\n' 'name: Toolchain fixture' > .github/actions/setup-macos-toolchain/action.yml } assert_classification swift-version \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_swift_version assert_classification macos-toolchain-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_toolchain_action assert_classification macos-cache-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_cache_action assert_classification macos-cache-save-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_cache_save_action assert_classification metadata \ - "$(classification false false false false false false false false false true)" \ + "$(classification false false false false false false false true)" \ modify_metadata assert_classification classifier \ - "$(classification true true true true true true true true false false true true pull_request true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ modify_classifier LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-defers-runner-lanes \ - "$(classification false false false false true false false false false false false false pull_request true)" \ + "$(classification false false false false false false false false false false pull_request true)" \ modify_unknown_script LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-keeps-linux-lanes \ - "$(classification false false false false false false false false true false)" \ + "$(classification false false false false false false true false)" \ modify_rust_comment # A rename classifies both endpoints instead of selecting every lane: the old # Rust source still selects Rust Core, packaging, and Windows Rust. @@ -416,42 +370,42 @@ ONLY_PATHS_DIR="$(mktemp -d)" trap 'rm -rf "$TEST_ROOT" "$ONLY_PATHS_DIR"' EXIT printf '%s\n' 'macos/Sources/Lithe/App.swift' > "$ONLY_PATHS_DIR/only-swift.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-swift.txt" assert_classification only-paths-limits-classification \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_and_rust assert_classification chinese-swift-source-keeps-its-raw-path \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_chinese_swift_source printf '%s\n' 'macos/Sources/Lithe/示例 "quoted".swift' > "$ONLY_PATHS_DIR/only-chinese.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-chinese.txt" assert_classification only-paths-matches-raw-chinese-path \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_chinese_swift_source : > "$ONLY_PATHS_DIR/only-none.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-none.txt" assert_classification only-paths-empty-selects-nothing \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_swift_and_rust LITHE_CI_SCOPE=post_merge assert_classification post-merge-builds-only-the-package \ - "$(classification false false false false false true false false false false false false post_merge)" \ + "$(classification false false false true false false false false false false post_merge)" \ modify_swift_source LITHE_CI_SCOPE=post_merge assert_classification post-merge-skips-windows-only-changes \ - "$(classification false false false false false false false false false false false false post_merge)" \ + "$(classification false false false false false false false false false false post_merge)" \ modify_windows_frontend LITHE_CI_SCOPE=post_merge assert_classification post-merge-skips-documentation \ - "$(classification false false false false false false false false false false false false post_merge)" \ + "$(classification false false false false false false false false false false post_merge)" \ modify_readme LITHE_CI_SCOPE=release_promotion assert_classification release-promotion-reruns-nothing \ - "$(classification false false false false false false false false false false false false release_promotion)" \ + "$(classification false false false false false false false false false false release_promotion)" \ modify_classifier assert_classification rename-rust-to-markdown \ - "$(classification false false false true false true true true false false)" \ + "$(classification false false true true true true false false)" \ rename_rust_to_markdown assert_classification rename-swift-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ rename_swift_source assert_classification rename-swift-across-modules \ - "$(classification true false true false false true false false false false)" \ - rename_swift_into_database_module + "$(classification true true false true false false false false)" \ + rename_swift_into_contracts_module assert_classification rust-bridge-verifier \ - "$(classification false false false true false true false false false false)" \ + "$(classification false false true true false false false false)" \ modify_rust_bridge_verifier printf '%s\n' 'CI change classifier tests passed' diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 33eb710c3..957616393 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -139,8 +139,6 @@ required_executables=( "$app_path/Contents/Frameworks/Sparkle.framework/Sparkle" "$app_path/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" "$app_path/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater" - "$app_path/Contents/Helpers/lithe-db-sidecar" - "$app_path/Contents/Helpers/lithe-db-mcp" "$app_path/Contents/Helpers/lithe-mcp" ) for executable in "${required_executables[@]}"; do diff --git a/scripts/verify-module-boundaries.sh b/scripts/verify-module-boundaries.sh index 382a2360a..401fed982 100755 --- a/scripts/verify-module-boundaries.sh +++ b/scripts/verify-module-boundaries.sh @@ -4,9 +4,9 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" -module_ids=(workspace git search localHistory languageIntelligence execution debug terminal database aiAssistance) -module_types=(WorkspaceFoundation Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance) -module_targets=(LitheWorkspaceModule LitheDatabaseModule LitheGitModule LitheSearchModule LitheLocalHistoryModule LitheLanguageIntelligenceModule LitheExecutionModule LitheDebugModule LitheTerminalModule LitheAIAssistanceModule) +module_ids=(workspace git search localHistory languageIntelligence execution debug terminal aiAssistance) +module_types=(WorkspaceFoundation Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance) +module_targets=(LitheWorkspaceModule LitheGitModule LitheSearchModule LitheLocalHistoryModule LitheLanguageIntelligenceModule LitheExecutionModule LitheDebugModule LitheTerminalModule LitheAIAssistanceModule) for id in "${module_ids[@]}"; do if ! rg -q "static let ${id} = ModuleID" macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift; then @@ -32,7 +32,7 @@ for index in {1..${#module_types[@]}}; do fi done -for contribution_type in Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance; do +for contribution_type in Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance; do if ! rg -q "${contribution_type}Module\.moduleContributions" macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift; then print -u2 "Missing lazy contribution registration: ${contribution_type}Module" exit 1 @@ -57,7 +57,7 @@ if rg -n 'Contents/PlugIns' scripts/package-app.sh scripts/preview.sh; then exit 1 fi -if rg -n "final class (WorkspaceFoundation|Database|Git|Search|History|LanguageIntelligence|Execution|Debug|Terminal|AIAssistance)Module([[:space:]:{])" macos/Sources/Lithe; then +if rg -n "final class (WorkspaceFoundation|Git|Search|History|LanguageIntelligence|Execution|Debug|Terminal|AIAssistance)Module([[:space:]:{])" macos/Sources/Lithe; then print -u2 "Module lifecycle implementation leaked back into the executable target" exit 1 fi @@ -203,32 +203,6 @@ if rg -n 'terminal\.sessions|git\.log|language\.problems|execution\.(maven|run|t exit 1 fi -for legacy_database_file in \ - macos/Sources/Lithe/Application/DatabaseFeatureModel.swift \ - macos/Sources/Lithe/Application/DatabaseSQLSupport.swift \ - macos/Sources/Lithe/Application/DatabaseSchemaDiff.swift \ - macos/Sources/Lithe/Services/DatabaseConnectionStore.swift \ - macos/Sources/Lithe/Services/DatabaseDBXImportService.swift \ - macos/Sources/Lithe/Services/DatabaseSidecarService.swift \ - macos/Sources/Lithe/Core/Ports/DatabaseRecovery.swift; do - if [[ -e "$legacy_database_file" ]]; then - print -u2 "Database implementation leaked outside LitheDatabaseModule: $legacy_database_file" - exit 1 - fi -done - -for database_type in DatabaseFeatureModel DatabaseSidecarService DatabaseConnectionStore DatabaseProcessRunning DatabaseRecoveryStoring; do - rg -q "(class|struct|protocol|enum) ${database_type}" macos/Sources/LitheDatabaseModule || { - print -u2 "Database module is missing real implementation: ${database_type}" - exit 1 - } -done - -if rg -n 'FeatureModuleHandle|ProcessRunner|KeyValueStore|SecureStore|FileStorage' macos/Sources/LitheDatabaseModule | rg -v 'Database(ProcessRunner|PreferenceStore|SecureStore|FileStorage)'; then - print -u2 "Database must own its graph through database-scoped ports" - exit 1 -fi - for language_type in LanguageIntelligenceModule LanguageIntelligenceCapability LanguageIntelligenceServiceGraph; do rg -q "(class|struct|protocol|enum) ${language_type}" macos/Sources/LitheLanguageIntelligenceModule || { print -u2 "Language Intelligence module is missing its owned lifecycle boundary: ${language_type}" diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index df5675051..185c8e1e9 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -95,7 +95,6 @@ fi abort "AI must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.ai-assistance" }.fetch("defaultState") == "disabled" agent = modules.find { |m| m.fetch("id") == "dev.lithe.agent-conversation" } abort "Agent must be disabled and on demand" unless agent.fetch("defaultState") == "disabled" && agent.fetch("activationPolicy") == "onDemand" - abort "Database must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.database" }.fetch("defaultState") == "disabled" allowed_states = ["enabled", "disabled"] allowed_scopes = ["application", "workspace"] allowed_policies = ["eager", "onDemand", "manual"] diff --git a/shared/fixtures/modules/built-in-v1.json b/shared/fixtures/modules/built-in-v1.json index d5ab71276..aafa878de 100644 --- a/shared/fixtures/modules/built-in-v1.json +++ b/shared/fixtures/modules/built-in-v1.json @@ -34,20 +34,6 @@ ], "required": false }, - { - "id": "dev.lithe.database", - "displayName": "Database", - "scope": "workspace", - "defaultState": "disabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.database-workspace"], - "contributions": [ - { "id": "database.workspace", "kind": "toolWindow" } - ], - "required": false - }, { "id": "dev.lithe.debug", "displayName": "Debug", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 49b94af1b..4660db0e9 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -401,7 +401,6 @@ "ai.copyTableMarkdown": "Copy table as Markdown", "ai.couldNotOpenAgentTerminal": "Couldn't open the agent terminal", "ai.couldNotRestartAgentSession": "Couldn't restart the agent session", - "ai.databaseContext": "{type} database", "ai.deleteNamedSkill": "Delete {title}", "ai.deleteSkill": "Delete skill", "ai.deny": "Deny", @@ -885,7 +884,6 @@ "commandPalette.categories.AI": "AI", "commandPalette.categories.Advanced": "Advanced", "commandPalette.categories.CLI": "CLI", - "commandPalette.categories.Database": "Database", "commandPalette.categories.Developer": "Developer", "commandPalette.categories.Editor": "Editor", "commandPalette.categories.Features": "Features", @@ -950,208 +948,12 @@ "customTheme.saveJson": "Save JSON", "customTheme.saveJsonFailed": "Failed to save theme JSON", "customTheme.themeJson": "Theme JSON", - "database.add": "Add", - "database.addColumn": "Add Column", - "database.addDatabase": "Add Database", - "database.addFilter": "Add filter", - "database.addNewRow": "Add New Row", - "database.addRow": "Add Row", - "database.addRowToTable": "Add Row to {table}", - "database.apply": "Apply", - "database.applyQuery": "Apply query", - "database.backToCommands": "Back to commands", - "database.chooseOrDropFile": "Choose or drop a {provider} file", - "database.clearAll": "Clear all", - "database.clearAllFilters": "Clear all filters", - "database.clearRecentQueries": "Clear recent queries", - "database.cmdCtrlEnterToRun": "Cmd/Ctrl+Enter to run", - "database.collections": "Collections", - "database.collectionsCount": "{count} collections", - "database.columnName": "Column name", - "database.columnNumber": "column {number}", - "database.columns": "Columns", "database.connect": "Connect", - "database.connectImmediately": "Connect Immediately", - "database.connectToDatabase": "Connect to Database", - "database.connected": "Connected", - "database.connecting": "Connecting", - "database.connectionName": "Connection Name", - "database.connectionString": "Connection String", - "database.connectionStringMode": "Connection string mode", - "database.connectionTestFailed": "Connection test failed", - "database.connectionTestSuccessful": "Connection test successful", - "database.copyAsJson": "Copy as JSON", - "database.copyExistingData": "Copy Existing Data", - "database.copyQuery": "Copy query", - "database.copyQueryFromHistory": "Copy query from history: {preview}", - "database.copySelection": "Copy selection", - "database.copySelectionWithHeaders": "Copy selection with headers", - "database.copyValue": "Copy value", - "database.copyVisiblePageJson": "Copy visible page as JSON", - "database.copyVisibleQueryPageJson": "Copy visible query page as JSON", - "database.create": "Create", - "database.createNewTable": "Create New Table", - "database.createSlot": "Create Slot", - "database.createSubscription": "Create subscription", - "database.createTable": "Create Table", - "database.currentObject": "current: {name}", - "database.currentPage": "Current page", - "database.data": "Data", - "database.database": "Database", - "database.databaseFile": "Database File", - "database.databaseFilePathMissing": "Database file path is missing.", - "database.databaseRows": "Database rows", - "database.databases": "Databases", - "database.deleteConnection": "Delete {name}", - "database.deleteDocument": "Delete document {id}", - "database.deleteIndex": "Delete Index", - "database.deleteKey": "Delete key", - "database.deleteMaterializedView": "Delete Materialized View", - "database.deleteRow": "Delete Row", - "database.deleteTable": "Delete Table", - "database.deleteView": "Delete View", - "database.detected": "Detected", - "database.disableSubscription": "Disable subscription", - "database.documentNumber": "Document {number}", - "database.documentsCount": "{count} documents", - "database.documentsPerPage": "Documents per page", - "database.dropDatabaseFile": "Drop database file", - "database.dropSubscription": "Drop subscription", - "database.dropSupportedDatabaseFile": "Drop a SQLite or DuckDB database file.", - "database.editRow": "Edit Row", - "database.editRowInTable": "Edit Row in {table}", - "database.emptyFilterResult": "The current filter returned an empty result set.", - "database.enableFailoverSlotSync": "Enable failover slot sync", - "database.enableSubscription": "Enable subscription", - "database.enabled": "Enabled", - "database.enterConnectionString": "Enter a connection string", - "database.enterDatabaseName": "Enter a database name", - "database.enterHost": "Enter a host", - "database.enterTableName": "Enter table name", - "database.enterValidPort": "Enter a valid port", - "database.execute": "Execute", - "database.expandCell": "Click to expand", - "database.expandJson": "Click to expand JSON", - "database.exportAsCsv": "Export as CSV", - "database.exportVisiblePageCsv": "Export visible page as CSV", - "database.exportVisibleQueryPageCsv": "Export visible query page as CSV", - "database.filterBetween": "between", - "database.filterByColumn": "Filter by {column}", - "database.filterContains": "contains", - "database.filterEndsWith": "ends with", - "database.filterEquals": "=", - "database.filterGreaterThan": ">", - "database.filterGreaterThanOrEqual": ">=", - "database.filterIsNotNull": "is not null", - "database.filterIsNull": "is null", - "database.filterLessThan": "<", - "database.filterLessThanOrEqual": "<=", - "database.filterNotEquals": "!=", - "database.filterStartsWith": "starts with", - "database.filtersCount": "{count} filters", - "database.firstPage": "First page", - "database.form": "Form", - "database.formMode": "Form mode", - "database.hideColumnTypes": "Hide column types", "database.host": "Host", - "database.indexes": "Indexes", - "database.indexesCount": "{count} indexes", - "database.info": "Info", - "database.insertSqlCompletion": "Insert SQL {detail} {label}", - "database.installProviderFromExtensions": "Install a database provider from Settings > Extensions to connect to databases.", - "database.keyPattern": "Key pattern", - "database.keyPatternPlaceholder": "Pattern (e.g. user:*)", - "database.lastPage": "Last page", - "database.lastRunMs": "Last run {ms}ms", - "database.leaveBlankForDefault": "Leave blank for default", - "database.loadingDatabases": "Loading databases", - "database.loadingKeysEllipsis": "Loading keys...", - "database.loadingMoreKeys": "Loading more keys", - "database.materializedViews": "Materialized Views", - "database.mongodbFilterQuery": "MongoDB filter query", - "database.mongodbSortQuery": "MongoDB sort query", - "database.moreKeysEllipsis": "More keys...", - "database.name": "Name", - "database.noData": "No data", - "database.noDocumentsFound": "No documents found", - "database.noMatchingDatabases": "No matching databases.", - "database.noProvidersInstalled": "No database providers installed.", - "database.noWorkspaceDatabases": "No databases in this workspace.", - "database.objects": "objects", - "database.objectsCount": "Objects ({count})", - "database.onOwner": "on {owner}", - "database.openDatabase": "Open Database", - "database.openExtensions": "Open Extensions", - "database.openQuery": "Open query: {preview}", - "database.openSqlEditor": "Open SQL editor", - "database.openWorkspaceBeforeAdding": "Open a workspace before adding databases.", - "database.openWorkspaceToAdd": "Open a workspace to add databases.", "database.optional": "Optional", - "database.pageOf": "Page {page} of {pages}", "database.password": "Password", - "database.perPage": "per page", "database.port": "Port", - "database.publications": "Publications", - "database.queryRows": "{count} query rows", - "database.queryRowsLabel": "query rows", - "database.queryRowsOnThisPage": "query rows on this page", - "database.recent": "Recent", - "database.recentQueries": "Recent queries", - "database.refresh": "Refresh", - "database.refreshKeys": "Refresh keys", - "database.refreshSubscription": "Refresh subscription", - "database.refreshingKeys": "Refreshing keys", - "database.removeColumn": "Remove {column}", - "database.removeFilter": "Remove filter", - "database.removeFromHistory": "Remove from history", - "database.removeQueryFromHistory": "Remove query from history: {preview}", - "database.required": "Required", - "database.reset": "Reset", - "database.resetQuery": "Reset query", - "database.resultCount": "{count} {label}", - "database.rows": "rows", - "database.rowsPerPage": "Rows per page", - "database.runQuery": "Run query", - "database.runQueryFromHistory": "Run query from history: {preview}", - "database.runSelection": "Run Selection", - "database.saveChanges": "Save Changes", - "database.savePasswordSecurely": "Save password securely", - "database.scanningKeys": "Scanning keys", - "database.schema": "Schema", - "database.searchDatabases": "Search databases", - "database.searchKeys": "Search keys", - "database.selectAllVisibleCells": "Select all visible cells", - "database.selectCollection": "Select collection {collection}", - "database.selectCollectionDescription": "Choose a collection from the sidebar to browse documents.", - "database.selectCollectionTitle": "Select a collection", - "database.selectDatabase": "Select database", - "database.selectDatabaseFile": "Select a database file", - "database.selectKey": "Select key {key}", - "database.selectKeyDescription": "Pick a Redis key from the sidebar to inspect its value.", - "database.selectKeyTitle": "Select a key", - "database.selectObject": "Select {kind} {name}", - "database.selectSqliteDatabaseFile": "Select a SQLite database file", - "database.selectionWillRun": "Selection will run", - "database.serverInfo": "Server Info", - "database.setColumnNotNull": "Set {column} as not null", - "database.showColumnTypes": "Show column types", - "database.slotName": "Slot Name", - "database.subscriptions": "Subscriptions", - "database.switchToView": "Switch to {view} view", - "database.tableName": "Table Name", - "database.tables": "Tables", - "database.tablesCount": "{count} tables", - "database.test": "Test", - "database.testConnection": "Test connection", - "database.testing": "Testing", - "database.to": "to", - "database.toggleColumnTypes": "Toggle column types", - "database.toggleServerInfo": "Toggle server info", "database.username": "Username", - "database.value": "value", - "database.views": "Views", - "database.visibleQueryRowsOnPage": "{count} visible query rows on page {page} of {pages}", - "database.visibleRows": "{count} visible rows", "debugger.adapterOutputAppearsHere": "Adapter output appears here.", "debugger.addExpression": "Add expression", "debugger.addWatch": "Add watch", @@ -1535,7 +1337,6 @@ "extensions.cannotInstallAutomatically": "{name} cannot be installed automatically", "extensions.category.agent": "Agent", "extensions.category.ai": "AI", - "extensions.category.database": "Database", "extensions.category.iconTheme": "Icon Theme", "extensions.category.integration": "Integration", "extensions.category.language": "Language", @@ -1545,7 +1346,6 @@ "extensions.crashed": "{name} crashed", "extensions.current": "Current", "extensions.currentName": "Current: {name}", - "extensions.databases": "Databases", "extensions.deactivate": "Deactivate", "extensions.deactivateFailed": "Failed to deactivate {name}: {message}", "extensions.deactivated": "{name} deactivated", @@ -3491,7 +3291,6 @@ "menu.commandPalette": "Command Palette", "menu.copy": "Copy", "menu.cut": "Cut", - "menu.databases": "Databases", "menu.deleteLine": "Delete Line", "menu.diagnostics": "Diagnostics", "menu.documentation": "Documentation", @@ -3784,7 +3583,6 @@ "native.avg": "avg", "native.backToAllReferences": "Back to all references", "native.backToTheConversation": "Back to the conversation", - "native.backUpDatabaseAsSQL": "Back Up Database as SQL…", "native.backgroundServices": "Background Services", "native.backgroundServices.variant2": "Background services", "native.backgroundServicesStartedInLitheAreStillRunning": "Background services started in Lithe are still running", @@ -3816,7 +3614,6 @@ "native.breakdownUnavailable": "Breakdown unavailable", "native.breakpointActions": "Breakpoint actions", "native.breakpoints": "Breakpoints", - "native.browseDatabase": "Browse Database", "native.bugReport": "Bug report", "native.build": "Build", "native.build.variant2": "Build {arg1} → {arg2}", @@ -3978,7 +3775,6 @@ "native.clearGitConsole": "Clear Git console", "native.clearLogSearch": "Clear log search", "native.clearLogs": "Clear Logs", - "native.clearOutput": "Clear output", "native.clearOverride": "Clear Override", "native.clearPathFilter": "Clear path filter", "native.clearRunOutput": "Clear run output", @@ -4092,7 +3888,6 @@ "native.committedPushPending": "Committed; push pending", "native.committerEmail": "Committer email", "native.committerName": "Committer name", - "native.commonActions": "Common Actions", "native.community": "Community", "native.communityDisk": "Community / Disk", "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified": "Community plugin installed from disk. Its publisher and repository have not been verified.", @@ -4142,7 +3937,6 @@ "native.connectedAgentsCanUseTheConfigurationBelowWhileThisProjectIsOpen": "Connected agents can use the configuration below while this project is open.", "native.connectedAs": "Connected as @{arg1}", "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying": "Connection recovery is taking longer. The Agent is still retrying.", - "native.connections": "Connections", "native.connectionsLld": "Connections: {arg1}", "native.console": "Console", "native.content": "Content", @@ -4290,16 +4084,12 @@ "native.cyrillicLetters": "Cyrillic letters", "native.dConversations": "{arg1} conversations", "native.dMessages": "{arg1} messages", - "native.dRowsRetrievedInDMs": "{arg1} rows retrieved in {arg2} ms", "native.dSelected": "{arg1} selected", "native.daemon": "Daemon", "native.dangerZone": "Danger Zone", "native.dark": "Dark", "native.dartSupport": "Dart Support", - "native.dataTools": "Data Tools", "native.database": "Database", - "native.databaseOverview": "Database Overview", - "native.databaseTypes": "Database types", "native.date": "Date", "native.debug": "Debug", "native.debugResume": "Debug: Resume", @@ -4440,7 +4230,6 @@ "native.dropThisShelf": "Drop this shelf?", "native.droppedLldWriteFailuresLld": "Dropped: {arg1}; write failures: {arg2}", "native.duplicate": "Duplicate", - "native.duplicateConnection": "Duplicate Connection", "native.duplicateOmitted": "duplicate omitted", "native.durationLldMs": "Duration: {arg1} ms", "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1": "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.", @@ -4458,7 +4247,6 @@ "native.editCommitMessage.variant2": "Edit Commit Message…", "native.editConfiguration": "Edit Configuration…", "native.editConfigurationText": "Edit configuration text", - "native.editConnection": "Edit Connection", "native.editExceptionBreakpoint": "Edit {arg1} exception breakpoint", "native.editExceptionBreakpoint.variant2": "Edit exception breakpoint", "native.editFieldBreakpoint": "Edit {arg1} field breakpoint", @@ -4572,8 +4360,6 @@ "native.exportDiagnosticsBundle": "Export Diagnostics Bundle", "native.exportDiagnosticsBundle.variant2": "Export Diagnostics Bundle…", "native.exportLogs": "Export logs", - "native.exportTableAsCSV": "Export Table as CSV…", - "native.exportTableAsJSON": "Export Table as JSON…", "native.exportingConversations": "Exporting conversations…", "native.exportsTheIndexAgainstHEAD": "Exports the index against HEAD.", "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles": "Exports the working tree's net changes against HEAD, including selected untracked files.", @@ -4585,7 +4371,6 @@ "native.fSupport": "F# Support", "native.failed": "{arg1} ({arg2} failed)", "native.failed.variant2": "Failed", - "native.failedLineD": "Failed, line {arg1}", "native.failedToStartCheckTheLanguageServerConfiguration": "Failed to start; check the language server configuration", "native.failures": "Failures", "native.fast": "Fast", @@ -4635,7 +4420,6 @@ "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically": "Files will stay unchanged. Nothing will be staged or committed automatically.", "native.filterByChangedPath": "Filter by changed path", "native.filterByTitleAuthorOrLabel": "Filter by title, author, or label", - "native.filterDatabaseType": "Filter database type", "native.filterLogMessages": "Filter log messages", "native.filterProblemsBySeverity": "Filter problems by severity", "native.filterResults": "Filter results", @@ -4812,11 +4596,8 @@ "native.ignoredEntriesLld": "Ignored entries: {arg1}", "native.imperativeSubject": "Imperative subject", "native.implementations": "Implementations", - "native.importCSV": "Import CSV…", - "native.importConnectionsFromDBX": "Import connections from DBX", "native.importFromClaude": "Import from Claude", "native.importFromCodex": "Import from Codex", - "native.importJSON": "Import JSON…", "native.inEffect": "In effect: ", "native.include": "Include", "native.includeAShortBodyWhenUseful": "Include a short body when useful", @@ -5049,7 +4830,6 @@ "native.lldhLldmLlds": "{arg1}h {arg2}m {arg3}s", "native.lldmLlds": "{arg1}m {arg2}s", "native.llds": "{arg1}s", - "native.lluRowsAffectedInDMs": "{arg1} rows affected in {arg2} ms", "native.load": "Load", "native.loadDiskVersion": "Load Disk Version", "native.loadMore": "Load More", @@ -5283,7 +5063,6 @@ "native.newFile": "New File…", "native.newFolder": "New Folder", "native.newProject": "New Project…", - "native.newQuery": "New Query", "native.newRunConfiguration": "New Run Configuration", "native.newTag": "New Tag", "native.newTag.variant2": "New Tag…", @@ -5329,7 +5108,6 @@ "native.noCredentialFoundInCodexConfiguration": "No credential found in Codex configuration", "native.noCurrentBranch": "No current branch", "native.noCustomProvidersYet": "No custom providers yet.", - "native.noDatabasesLoaded": "No databases loaded", "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain": "No default shell was detected. Choose a detected shell or run detection again.", "native.noDependencies": "No dependencies", "native.noDependencies.variant2": "No dependencies.", @@ -5394,7 +5172,6 @@ "native.noPublishedReleaseIsAvailableYet": "No published release is available yet.", "native.noPullRequests": "No pull requests", "native.noPullRequestsMatchTheSelectedState": "No pull requests match the selected state.", - "native.noQueryOutput": "No query output", "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive": "No recent progress has been received. The task is still active.", "native.noRecentProjects": "No recent projects", "native.noReleaseIsAvailableYet": "No release is available yet", @@ -5411,7 +5188,6 @@ "native.noSpringBootOrMavenModuleConfigurations": "No Spring Boot or Maven Module configurations", "native.noSubagentOutputYet": "No subagent output yet", "native.noSubagentsYet": "No subagents yet", - "native.noTableSelected": "No Table Selected", "native.noTasksYet": "No tasks yet", "native.noTextualDiffAvailable": "No textual diff available", "native.noTextualDifference": "No textual difference", @@ -5427,7 +5203,6 @@ "native.nodeJsLldOrLaterIsRequiredFound": "Node.js {arg1} or later is required; found {arg2}.", "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain": "Node.js {arg1} or later was not found. Install it, then check again.", "native.nodeJsSupport": "Node.js Support", - "native.nonIntrospected": "Non-Introspected", "native.nonStreaming": "Non-streaming", "native.nonStreamingResponses": "Non-streaming responses", "native.none": "None", @@ -5436,7 +5211,6 @@ "native.notAssigned": "Not Assigned", "native.notAvailableForThisLitheOrMac": "{arg1} (not available for this Lithe or Mac)", "native.notAvailableYetDependenciesAreNotIndexed": "Not available yet: dependencies are not indexed", - "native.notConnected": "Not connected", "native.notFetchedYet": "Not fetched yet.", "native.notFound": "Not found", "native.notIncludedInTheUpdatedPlan": "Not included in the updated plan", @@ -5571,7 +5345,6 @@ "native.passwordOrPassphrase": "Password or passphrase", "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790": "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root.", "native.pastePatch": "Paste Patch", - "native.pasteTSVFromClipboard": "Paste TSV from Clipboard", "native.pasteThisIntoYourMCPClientConfiguration": "Paste this into your MCP client configuration.", "native.pastedFiles": "Pasted files", "native.patchCanBeAppliedToTheSelectedDestination": "Patch can be applied to the selected destination.", @@ -5745,10 +5518,8 @@ "native.pushThisBranchToGitHub": "Push this branch to GitHub", "native.pushTo": "Push to {arg1}", "native.pythonSupport": "Python Support", - "native.queryHistory": "{arg1} Query History", "native.quickInformation": "Quick Information", "native.quickResponsesWithBasicReasoning": "Quick responses with basic reasoning", - "native.quickStart": "Quick Start", "native.quit": "Quit", "native.quitWithSelection": "Quit with Selection", "native.quittingLitheEndsThesePrograms": "Quitting Lithe ends these programs.", @@ -5785,7 +5556,6 @@ "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase": "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase.", "native.recent": "Recent", "native.recentProjects": "Recent Projects", - "native.recentSQL": "Recent SQL", "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78": "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.", "native.reconnect": "Reconnect", "native.reconnectThisAgentToRefreshItsModelList": "Reconnect this Agent to refresh its model list.", @@ -5797,7 +5567,6 @@ "native.refreshAll": "Refresh All", "native.refreshChanges": "Refresh changes", "native.refreshConnectionStructure": "Refresh connection structure", - "native.refreshDatabases": "Refresh databases", "native.refreshDependencies": "Refresh dependencies", "native.refreshDetectedRuntimes": "Refresh detected runtimes", "native.refreshFiles": "Refresh Files", @@ -5884,7 +5653,6 @@ "native.replace": "Replace", "native.replaceCurrentCommitMessage": "Replace current commit message?", "native.replaceExistingContent": "Replace existing content", - "native.replaceInCurrentPage": "Replace in Current Page…", "native.replaceInFile": "Replace in File", "native.replaceInFile.variant2": "Replace in File…", "native.replaceInFiles": "Replace in Files", @@ -5950,10 +5718,8 @@ "native.restoreDefaultTemplate": "Restore Default Template", "native.restoreDefaultUILayout": "Restore Default UI Layout", "native.restorePath": "Restore path", - "native.restoreSQLBackup": "Restore SQL Backup…", "native.restoringGitHubConnection": "Restoring GitHub connection", "native.result": "Result", - "native.resultD": "Result {arg1}", "native.results": "Results", "native.results.variant2": "results", "native.resume": "Resume", @@ -6261,7 +6027,6 @@ "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying": "Some MCP configuration files could not be restored: {arg1}. Refresh and check these files before retrying.", "native.sortByLastUpdated": "Sort by Last Updated", "native.sortByName": "Sort by Name", - "native.sortConnections": "Sort connections", "native.sortPlugins": "Sort plugins", "native.source": "Source", "native.sourceCode": "Source Code", @@ -6271,7 +6036,6 @@ "native.speed": "Speed", "native.springBoot": "Spring Boot", "native.springEndpoints": "Spring Endpoints", - "native.sqlExecutionMarkers": "SQL execution markers", "native.sqlSupport": "SQL Support", "native.squash": "Squash", "native.squash1": "Squash → 1", @@ -6371,14 +6135,12 @@ "native.subjectMaximumLength": "Subject maximum length", "native.submittingReview": "Submitting review…", "native.succeeded": "Succeeded", - "native.succeededLineD": "Succeeded, line {arg1}", "native.supplementaryMessageCancelled": "Supplementary message cancelled.", "native.supportedPlaceholdersSummaryChangesTestingRisks": "Supported placeholders: {summary}, {changes}, {testing}, {risks}.", "native.svelteSupport": "Svelte Support", "native.swapDirection": "Swap Direction", "native.swiftSupport": "Swift Support", "native.switchAgent": "Switch Agent", - "native.switchCurrentDatabase": "Switch current database", "native.switchProjectView": "Switch project view", "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain": "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again.", "native.symbols": "Symbols", @@ -7030,7 +6792,6 @@ "outline.search": "Search outline", "outline.showAll": "Show All", "outline.title": "Outline", - "panes.missingDatabaseConnection": "Missing database connection", "panes.resizePanes": "Resize panes", "panes.searchResults": "Search results", "panes.webViewerDisabled": "Web Viewer is disabled", @@ -8707,7 +8468,6 @@ "keybindings.commands.editor.cursorUndo.description": "Restore the previous cursor and selection without changing text", "keybindings.commands.editor.cursorUndo.title": "Cursor Undo", "native.aBasicProjectWithAFreeStructure": "A basic project with a free structure.", - "native.aFolderWithThisNameAlreadyExists": "A folder with this name already exists.", "native.aHookWithThisNameAlreadyExists": "A Hook with this name already exists.", "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit": "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply.", "native.aJavaProjectWithNoBuildToolMavenOrGradle": "A Java project with no build tool, Maven, or Gradle.", @@ -8723,7 +8483,6 @@ "native.afterAToolRuns": "After a tool runs", "native.agentDefault": "Agent default", "native.agentHooks": "Agent hooks", - "native.allSchemas": "All schemas", "native.allTools": "All tools", "native.alreadyExistsChooseAnotherNameOrLocation": "{arg1} already exists. Choose another name or location.", "native.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "An identical native Hook already exists. Lithe kept it unchanged.", @@ -8759,15 +8518,10 @@ "native.claudeSubscriptionQuota": "Claude subscription quota", "native.codexHookApproval": "Codex Hook approval", "native.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", - "native.collapseDatabase": "Collapse database", - "native.collapseTable": "Collapse table", "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported": "Connected, but Lithe cannot run on this platform yet. Linux on x86_64 or ARM64 is supported.", "native.connectedThisHostCanBeUsedForRemoteDevelopment": "Connected. This host can be used for remote development.", "native.connecting": "Connecting…", "native.connection": "Connection", - "native.connectionSuccessful": "Connection successful", - "native.connectionTestDurationLldMs": "Connection test duration: {arg1} ms", - "native.connectionTestResult": "Connection test result", "native.connectionTimeout": "Connection timeout", "native.conversationActions": "Conversation actions", "native.couldNotOpenTheRemoteFolder": "Could not open the remote folder: {arg1}", @@ -8778,13 +8532,11 @@ "native.creatingThisProjectDownloadsFilesFromTheInternet": "Creating this project downloads files from the internet.", "native.customArchetype": "Custom archetype…", "native.dMinutesAgo": "{arg1} minutes ago", - "native.dataSource": "Data Source", "native.deleteChatX93291d9c41": "Delete chat", "native.deleteHook": "Delete Hook?", "native.didNotFinishWithinLldSeconds": "{arg1} did not finish within {arg2} seconds.", "native.directories": "Directories", "native.disk": "Disk", - "native.doubleClickATableToViewData": "Double-click a table to view data", "native.earlier": "Earlier", "native.editHook": "Edit Hook", "native.emptyProject": "Empty Project", @@ -8794,18 +8546,13 @@ "native.enterAnAbsolutePathThatStartsWith": "Enter an absolute path that starts with /.", "native.enterTheHostWithoutAUserNameOrSpacesPutTheUserInUserName": "Enter the host without a user name or spaces; put the user in User name.", "native.exitedWithStatusD": "{arg1} exited with status {arg2}.\n{arg3}", - "native.expandDatabase": "Expand database", - "native.expandTable": "Expand table", "native.fileEdits": "File edits", "native.filePatterns": "File patterns", "native.findInMarketplace": "Find in Marketplace", "native.finishedWithoutCreating": "{arg1} finished without creating {arg2}.", "native.folder": "Folder", - "native.folderNameIsRequired": "Folder name is required.", "native.forExampleNotify": "For example, notify", "native.forExampleNotifyMeWhenATurnEnds": "For example, notify me when a turn ends", - "native.forceRefresh": "Force Refresh", - "native.forever": "Forever", "native.forkConversation": "Fork conversation", "native.freeOf": "{arg1} free of {arg2}", "native.generators": "Generators", @@ -8827,7 +8574,6 @@ "native.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", "native.hostName": "Host name", "native.httpsGithubComOwnerRepository": "https://github.com/owner/repository", - "native.importDataSources": "Import Data Sources…", "native.invalidHookFileGuards": "Invalid Hook file guards.", "native.isNeededForThisProjectButIsDisabled": "{arg1} is needed for this project but {arg2} is disabled.", "native.keepAliveInterval": "Keep-alive interval", @@ -8854,12 +8600,9 @@ "native.method": "Method", "native.mode": "Mode", "native.move": "Move", - "native.newFolderX633810e9b0": "New Folder…", "native.newHook": "New Hook", "native.newProjectX6f8d176462": "New Project", "native.newSSHConnection": "New SSH Connection", - "native.noAccessibleDatabases": "No accessible databases", - "native.noColumnsToDisplay": "No columns to display", "native.noHooksYet": "No Hooks yet.", "native.noHostYet": "No host yet", "native.noInstalledPluginSupportsYet": "No installed plugin supports {arg1} yet.", @@ -8867,10 +8610,8 @@ "native.noJDKWasFoundInstallAJDKThenReopenThisWindow": "No JDK was found. Install a JDK, then reopen this window.", "native.noMatchingHooks": "No matching Hooks.", "native.noSSHConnectionsYet": "No SSH connections yet.", - "native.noSchemasSelected": "No schemas selected", "native.notTested": "Not tested", "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd": "One entry per line. Directory names hide matching folders; file entries support * and ?.", - "native.openATableToInspectItsStructure": "Open a table to inspect its structure", "native.operatingSystem": "Operating system", "native.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "Optional description, up to 200 characters. Only the Hook library stores it.", "native.packageName": "Package name", @@ -8880,7 +8621,6 @@ "native.pin": "Pin", "native.pinConversation": "Pin conversation", "native.port": "Port", - "native.pressEnterOrClickOutsideTheListToApply": "Press Enter or click outside the list to apply", "native.previous30Days": "Previous 30 days", "native.previous7Days": "Previous 7 days", "native.privateKey": "Private Key", @@ -8891,35 +8631,25 @@ "native.providedByADisabledPlugin": "Provided by a disabled plugin", "native.publishCoreSelectsDefaultRemote": "Publish {arg1} (Core selects default remote)", "native.pushXc68c0bf1cd": "Push '{arg1}'?", - "native.queryConsole": "Query Console", "native.quotaX6c105cae1a": "Quota", - "native.refreshSelectedStructure": "Refresh selected structure", "native.rememberInKeychain": "Remember in Keychain", "native.remoteDevelopment": "Remote Development", "native.remoteFolder": "Remote folder", "native.remoteProject": "Remote Project", "native.removalPending": "Removal pending", - "native.renameFolder": "Rename Folder", "native.resources": "Resources", "native.retryHookSynchronization": "Retry Hook synchronization", "native.sSH": "SSH", "native.sSHAgent": "SSH Agent", "native.sSHAuthentication": "SSH Authentication", - "native.sSHTLS": "SSH / TLS", - "native.safetyAndLabels": "Safety and Labels", - "native.save": "Save:", "native.saveHook": "Save Hook", - "native.savePassword": "Save password", "native.savedInKeychain": "Saved in Keychain", "native.savesConnectsAndOpensTheRemoteFolderInThisWindow": "Saves, connects, and opens the remote folder in this window.", - "native.searchDatabases": "Search databases", "native.searchHooks": "Search Hooks", "native.searchJDKs": "Search JDKs", "native.secondsBetweenKeepAliveMessagesWhichDetectADroppedNetworkLeaveEmptyFor15Seconds": "Seconds between keep-alive messages, which detect a dropped network. Leave empty for 15 seconds.", "native.secondsToWaitForTheServerToAnswerLeaveEmptyFor15Seconds": "Seconds to wait for the server to answer. Leave empty for 15 seconds.", "native.security": "Security", - "native.selectAConnectionInTheDatabaseTree": "Select a connection in the database tree", - "native.selectDatabases": "Select databases", "native.serverDefault": "Server default", "native.serverURL": "Server URL", "native.shellCommands": "Shell commands", @@ -8929,13 +8659,11 @@ "native.testConnection": "Test Connection", "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress": "Test the connection to see the host's system, resources, and address.", "native.testing": "Testing", - "native.testingConnection": "Testing connection…", "native.theAddressIsInvalid": "The address is invalid.", "native.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", "native.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", "native.theConnectionFailed": "The connection failed.", "native.theConnectionToTheRemoteHostWasLostReconnectToContinue": "The connection to the remote host was lost. Reconnect to continue.", - "native.theDatabaseSelectionIsNoLongerAvailableRefreshAndTryAgain": "The database selection is no longer available. Refresh and try again.", "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit": "The field value does not match its declaration or exceeds its size limit.", "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes": "The field value is invalid or exceeds 1024 UTF-8 bytes.", "native.theFileSystemThatHoldsTheHomeDirectory": "The file system that holds the home directory.", @@ -8960,8 +8688,6 @@ "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi": "The project folder on the host. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh": "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", "native.theProjectWasCreatedButGitCouldNotBeSetUp": "The project was created, but Git could not be set up: {arg1}", - "native.theSelectedConnectionFolderNoLongerExists": "The selected connection folder no longer exists.", - "native.theSelectedFolderCannotBeMovedThere": "The selected folder cannot be moved there.", "native.theServerAskedAgainCheckThePreviousAnswer": "The server asked again. Check the previous answer.", "native.theServerRejectedTheCredentialsCheckTheUserNameAndTheAuthenticationMethod": "The server rejected the credentials. Check the user name and the authentication method.", "native.theSpringInitializrServerThatGeneratesTheProjectChangeItToUseAMirrorOrYourCompanySOwnServe": "The Spring Initializr server that generates the project. Change it to use a mirror or your company's own server.", @@ -8977,14 +8703,11 @@ "native.thisTemplateComesFromWhichIsDisabled": "This template comes from {arg1}, which is disabled.", "native.thisVersionIsNewerThanTheSelectedRuntimeSupports": "This version is newer than the selected runtime supports.", "native.timeoutSeconds": "Timeout (seconds)", - "native.toFolder": "To Folder…", - "native.toTopLevel": "To Top Level", "native.toolFilter": "Tool filter", "native.tracking": "Tracking {arg1}", "native.unknownDate": "Unknown date", "native.unpin": "Unpin", "native.unpinConversation": "Unpin conversation", - "native.untilRestart": "Until restart", "native.uptime": "Uptime", "native.usageAppearsAfterTheNextReply": "Usage appears after the next reply.", "native.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 856bbafaf..10a810826 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -401,7 +401,6 @@ "ai.copyTableMarkdown": "复制表格为 Markdown", "ai.couldNotOpenAgentTerminal": "无法打开 Agent 终端", "ai.couldNotRestartAgentSession": "无法重启 Agent 会话", - "ai.databaseContext": "{type} 数据库", "ai.deleteNamedSkill": "删除 {title}", "ai.deleteSkill": "删除技能", "ai.deny": "拒绝", @@ -885,7 +884,6 @@ "commandPalette.categories.AI": "AI", "commandPalette.categories.Advanced": "高级", "commandPalette.categories.CLI": "CLI", - "commandPalette.categories.Database": "数据库", "commandPalette.categories.Developer": "开发者", "commandPalette.categories.Editor": "编辑器", "commandPalette.categories.Features": "功能", @@ -950,208 +948,12 @@ "customTheme.saveJson": "保存 JSON", "customTheme.saveJsonFailed": "保存主题 JSON 失败", "customTheme.themeJson": "主题 JSON", - "database.add": "添加", - "database.addColumn": "添加列", - "database.addDatabase": "添加数据库", - "database.addFilter": "添加筛选器", - "database.addNewRow": "添加新行", - "database.addRow": "添加行", - "database.addRowToTable": "向 {table} 添加行", - "database.apply": "应用", - "database.applyQuery": "应用查询", - "database.backToCommands": "返回命令", - "database.chooseOrDropFile": "选择或拖入 {provider} 文件", - "database.clearAll": "全部清空", - "database.clearAllFilters": "清空所有筛选器", - "database.clearRecentQueries": "清空最近查询", - "database.cmdCtrlEnterToRun": "按 Cmd/Ctrl+Enter 运行", - "database.collections": "集合", - "database.collectionsCount": "{count} 个集合", - "database.columnName": "列名", - "database.columnNumber": "第 {number} 列", - "database.columns": "列", "database.connect": "连接", - "database.connectImmediately": "立即连接", - "database.connectToDatabase": "连接到数据库", - "database.connected": "已连接", - "database.connecting": "正在连接", - "database.connectionName": "连接名称", - "database.connectionString": "连接字符串", - "database.connectionStringMode": "连接字符串模式", - "database.connectionTestFailed": "连接测试失败", - "database.connectionTestSuccessful": "连接测试成功", - "database.copyAsJson": "复制为 JSON", - "database.copyExistingData": "复制现有数据", - "database.copyQuery": "复制查询", - "database.copyQueryFromHistory": "复制历史查询:{preview}", - "database.copySelection": "复制所选", - "database.copySelectionWithHeaders": "复制所选及表头", - "database.copyValue": "复制值", - "database.copyVisiblePageJson": "将当前页复制为 JSON", - "database.copyVisibleQueryPageJson": "将当前查询页复制为 JSON", - "database.create": "创建", - "database.createNewTable": "创建新表", - "database.createSlot": "创建 Slot", - "database.createSubscription": "创建订阅", - "database.createTable": "创建表", - "database.currentObject": "当前:{name}", - "database.currentPage": "当前页", - "database.data": "数据", - "database.database": "数据库", - "database.databaseFile": "数据库文件", - "database.databaseFilePathMissing": "数据库文件路径缺失。", - "database.databaseRows": "数据库行", - "database.databases": "数据库", - "database.deleteConnection": "删除 {name}", - "database.deleteDocument": "删除文档 {id}", - "database.deleteIndex": "删除索引", - "database.deleteKey": "删除键", - "database.deleteMaterializedView": "删除物化视图", - "database.deleteRow": "删除行", - "database.deleteTable": "删除表", - "database.deleteView": "删除视图", - "database.detected": "已检测", - "database.disableSubscription": "禁用订阅", - "database.documentNumber": "文档 {number}", - "database.documentsCount": "{count} 个文档", - "database.documentsPerPage": "每页文档数", - "database.dropDatabaseFile": "拖入数据库文件", - "database.dropSubscription": "删除订阅", - "database.dropSupportedDatabaseFile": "拖入 SQLite 或 DuckDB 数据库文件。", - "database.editRow": "编辑行", - "database.editRowInTable": "编辑 {table} 中的行", - "database.emptyFilterResult": "当前筛选条件返回了空结果集。", - "database.enableFailoverSlotSync": "启用故障转移 Slot 同步", - "database.enableSubscription": "启用订阅", - "database.enabled": "已启用", - "database.enterConnectionString": "输入连接字符串", - "database.enterDatabaseName": "输入数据库名称", - "database.enterHost": "输入主机", - "database.enterTableName": "输入表名", - "database.enterValidPort": "输入有效端口", - "database.execute": "执行", - "database.expandCell": "点击展开", - "database.expandJson": "点击展开 JSON", - "database.exportAsCsv": "导出为 CSV", - "database.exportVisiblePageCsv": "将当前页导出为 CSV", - "database.exportVisibleQueryPageCsv": "将当前查询页导出为 CSV", - "database.filterBetween": "介于", - "database.filterByColumn": "按 {column} 筛选", - "database.filterContains": "包含", - "database.filterEndsWith": "结尾为", - "database.filterEquals": "=", - "database.filterGreaterThan": ">", - "database.filterGreaterThanOrEqual": ">=", - "database.filterIsNotNull": "非 NULL", - "database.filterIsNull": "为 NULL", - "database.filterLessThan": "<", - "database.filterLessThanOrEqual": "<=", - "database.filterNotEquals": "!=", - "database.filterStartsWith": "开头为", - "database.filtersCount": "{count} 个筛选器", - "database.firstPage": "第一页", - "database.form": "表单", - "database.formMode": "表单模式", - "database.hideColumnTypes": "隐藏列类型", "database.host": "主机", - "database.indexes": "索引", - "database.indexesCount": "{count} 个索引", - "database.info": "信息", - "database.insertSqlCompletion": "插入 SQL {detail} {label}", - "database.installProviderFromExtensions": "请从“设置 > 扩展”安装数据库提供商后再连接数据库。", - "database.keyPattern": "键模式", - "database.keyPatternPlaceholder": "模式(例如 user:*)", - "database.lastPage": "最后一页", - "database.lastRunMs": "上次运行 {ms}ms", - "database.leaveBlankForDefault": "留空则使用默认值", - "database.loadingDatabases": "正在加载数据库", - "database.loadingKeysEllipsis": "正在加载键...", - "database.loadingMoreKeys": "正在加载更多键", - "database.materializedViews": "物化视图", - "database.mongodbFilterQuery": "MongoDB 筛选查询", - "database.mongodbSortQuery": "MongoDB 排序查询", - "database.moreKeysEllipsis": "更多键...", - "database.name": "名称", - "database.noData": "无数据", - "database.noDocumentsFound": "未找到文档", - "database.noMatchingDatabases": "没有匹配的数据库。", - "database.noProvidersInstalled": "未安装数据库提供商。", - "database.noWorkspaceDatabases": "此工作区中没有数据库。", - "database.objects": "对象", - "database.objectsCount": "对象({count})", - "database.onOwner": "位于 {owner}", - "database.openDatabase": "打开数据库", - "database.openExtensions": "打开扩展", - "database.openQuery": "打开查询:{preview}", - "database.openSqlEditor": "打开 SQL 编辑器", - "database.openWorkspaceBeforeAdding": "请先打开工作区再添加数据库。", - "database.openWorkspaceToAdd": "打开工作区后可添加数据库。", "database.optional": "可选", - "database.pageOf": "第 {page}/{pages} 页", "database.password": "密码", - "database.perPage": "每页", "database.port": "端口", - "database.publications": "发布", - "database.queryRows": "{count} 行查询结果", - "database.queryRowsLabel": "查询结果行", - "database.queryRowsOnThisPage": "当前页查询结果行", - "database.recent": "最近", - "database.recentQueries": "最近查询", - "database.refresh": "刷新", - "database.refreshKeys": "刷新键", - "database.refreshSubscription": "刷新订阅", - "database.refreshingKeys": "正在刷新键", - "database.removeColumn": "移除 {column}", - "database.removeFilter": "移除筛选器", - "database.removeFromHistory": "从历史中移除", - "database.removeQueryFromHistory": "从历史中移除查询:{preview}", - "database.required": "必填", - "database.reset": "重置", - "database.resetQuery": "重置查询", - "database.resultCount": "{count} {label}", - "database.rows": "行", - "database.rowsPerPage": "每页行数", - "database.runQuery": "运行查询", - "database.runQueryFromHistory": "运行历史查询:{preview}", - "database.runSelection": "运行所选", - "database.saveChanges": "保存更改", - "database.savePasswordSecurely": "安全保存密码", - "database.scanningKeys": "正在扫描键", - "database.schema": "结构", - "database.searchDatabases": "搜索数据库", - "database.searchKeys": "搜索键", - "database.selectAllVisibleCells": "选择所有可见单元格", - "database.selectCollection": "选择集合 {collection}", - "database.selectCollectionDescription": "从侧边栏选择集合以浏览文档。", - "database.selectCollectionTitle": "选择集合", - "database.selectDatabase": "选择数据库", - "database.selectDatabaseFile": "选择数据库文件", - "database.selectKey": "选择键 {key}", - "database.selectKeyDescription": "从侧边栏选择 Redis 键以查看其值。", - "database.selectKeyTitle": "选择键", - "database.selectObject": "选择 {kind} {name}", - "database.selectSqliteDatabaseFile": "选择 SQLite 数据库文件", - "database.selectionWillRun": "将运行所选内容", - "database.serverInfo": "服务器信息", - "database.setColumnNotNull": "将 {column} 设为非空", - "database.showColumnTypes": "显示列类型", - "database.slotName": "Slot 名称", - "database.subscriptions": "订阅", - "database.switchToView": "切换到{view}视图", - "database.tableName": "表名", - "database.tables": "表", - "database.tablesCount": "{count} 张表", - "database.test": "测试", - "database.testConnection": "测试连接", - "database.testing": "正在测试", - "database.to": "到", - "database.toggleColumnTypes": "切换列类型显示", - "database.toggleServerInfo": "切换服务器信息", "database.username": "用户名", - "database.value": "值", - "database.views": "视图", - "database.visibleQueryRowsOnPage": "第 {page}/{pages} 页,{count} 行可见查询结果", - "database.visibleRows": "{count} 行可见数据", "debugger.adapterOutputAppearsHere": "适配器输出会显示在这里。", "debugger.addExpression": "添加表达式", "debugger.addWatch": "添加监视", @@ -1535,7 +1337,6 @@ "extensions.cannotInstallAutomatically": "{name} 无法自动安装", "extensions.category.agent": "代理", "extensions.category.ai": "AI", - "extensions.category.database": "数据库", "extensions.category.iconTheme": "图标主题", "extensions.category.integration": "集成", "extensions.category.language": "语言", @@ -1545,7 +1346,6 @@ "extensions.crashed": "{name} 已崩溃", "extensions.current": "当前", "extensions.currentName": "当前:{name}", - "extensions.databases": "数据库", "extensions.deactivate": "停用", "extensions.deactivateFailed": "停用 {name} 失败:{message}", "extensions.deactivated": "{name} 已停用", @@ -3491,7 +3291,6 @@ "menu.commandPalette": "命令面板", "menu.copy": "复制", "menu.cut": "剪切", - "menu.databases": "数据库", "menu.deleteLine": "删除行", "menu.diagnostics": "诊断", "menu.documentation": "文档", @@ -3784,7 +3583,6 @@ "native.avg": "平均", "native.backToAllReferences": "返回所有引用", "native.backToTheConversation": "返回对话", - "native.backUpDatabaseAsSQL": "将数据库备份为 SQL…", "native.backgroundServices": "后台服务", "native.backgroundServices.variant2": "后台服务", "native.backgroundServicesStartedInLitheAreStillRunning": "在 Lithe 中启动的后台服务仍在运行", @@ -3816,7 +3614,6 @@ "native.breakdownUnavailable": "分项暂不可用", "native.breakpointActions": "断点操作", "native.breakpoints": "断点", - "native.browseDatabase": "浏览数据库", "native.bugReport": "错误报告", "native.build": "构建", "native.build.variant2": "构建 {arg1} → {arg2}", @@ -3978,7 +3775,6 @@ "native.clearGitConsole": "清空 Git 控制台", "native.clearLogSearch": "清除日志搜索", "native.clearLogs": "清空日志", - "native.clearOutput": "清除输出", "native.clearOverride": "清除覆盖", "native.clearPathFilter": "清除路径筛选", "native.clearRunOutput": "清除运行输出", @@ -4092,7 +3888,6 @@ "native.committedPushPending": "已提交,等待推送", "native.committerEmail": "提交者邮箱", "native.committerName": "提交者姓名", - "native.commonActions": "常用操作", "native.community": "社区", "native.communityDisk": "社区 / 磁盘", "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified": "从磁盘安装的社区插件,尚未验证发布者与源码仓库。", @@ -4142,7 +3937,6 @@ "native.connectedAgentsCanUseTheConfigurationBelowWhileThisProjectIsOpen": "项目打开期间,连接的 Agent 可以使用下方配置。", "native.connectedAs": "已连接为 @{arg1}", "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying": "连接恢复等待较久,Agent 仍在重试。", - "native.connections": "连接", "native.connectionsLld": "连接:{arg1}", "native.console": "控制台", "native.content": "内容", @@ -4290,16 +4084,12 @@ "native.cyrillicLetters": "西里尔字母", "native.dConversations": "共 {arg1} 个会话", "native.dMessages": "{arg1} 条消息", - "native.dRowsRetrievedInDMs": "取回 {arg1} 行,耗时 {arg2} 毫秒", "native.dSelected": "已选 {arg1} 项", "native.daemon": "守护进程", "native.dangerZone": "危险操作", "native.dark": "深色", "native.dartSupport": "Dart 支持", - "native.dataTools": "数据工具", "native.database": "数据库连接", - "native.databaseOverview": "数据库概览", - "native.databaseTypes": "数据库类型", "native.date": "日期", "native.debug": "调试", "native.debugResume": "调试:继续", @@ -4440,7 +4230,6 @@ "native.dropThisShelf": "丢弃此 Shelf 吗?", "native.droppedLldWriteFailuresLld": "已丢弃:{arg1};写入失败:{arg2}", "native.duplicate": "复制", - "native.duplicateConnection": "复制连接", "native.duplicateOmitted": "重复依赖已省略", "native.durationLldMs": "耗时:{arg1} 毫秒", "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1": "每个 Agent 使用各自的配置文件。关闭某个 Agent 只会移除 Lithe 写入的条目;在 Lithe 之外创建的条目必须先接管所有权才会被移除。", @@ -4458,7 +4247,6 @@ "native.editCommitMessage.variant2": "编辑提交消息…", "native.editConfiguration": "编辑运行配置…", "native.editConfigurationText": "编辑配置文本", - "native.editConnection": "编辑连接", "native.editExceptionBreakpoint": "编辑异常断点 {arg1}", "native.editExceptionBreakpoint.variant2": "编辑异常断点", "native.editFieldBreakpoint": "编辑字段断点 {arg1}", @@ -4572,8 +4360,6 @@ "native.exportDiagnosticsBundle": "导出诊断包", "native.exportDiagnosticsBundle.variant2": "导出诊断包…", "native.exportLogs": "导出日志", - "native.exportTableAsCSV": "将表导出为 CSV…", - "native.exportTableAsJSON": "将表导出为 JSON…", "native.exportingConversations": "正在导出会话…", "native.exportsTheIndexAgainstHEAD": "导出暂存区相对 HEAD 的更改。", "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles": "导出工作树相对 HEAD 的最终更改,包含所选的未跟踪文件。", @@ -4585,7 +4371,6 @@ "native.fSupport": "F# 支持", "native.failed": "{arg1}(失败 {arg2})", "native.failed.variant2": "失败", - "native.failedLineD": "失败,第 {arg1} 行", "native.failedToStartCheckTheLanguageServerConfiguration": "启动失败,请检查语言服务器配置", "native.failures": "失败详情", "native.fast": "快速", @@ -4635,7 +4420,6 @@ "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically": "文件内容保持不变,不会自动暂存或提交。", "native.filterByChangedPath": "按更改路径筛选", "native.filterByTitleAuthorOrLabel": "按标题、作者或标签筛选", - "native.filterDatabaseType": "筛选数据库类型", "native.filterLogMessages": "筛选日志内容", "native.filterProblemsBySeverity": "按严重级别筛选问题", "native.filterResults": "筛选结果", @@ -4812,11 +4596,8 @@ "native.ignoredEntriesLld": "已忽略条目:{arg1}", "native.imperativeSubject": "动作式标题", "native.implementations": "实现", - "native.importCSV": "导入 CSV…", - "native.importConnectionsFromDBX": "从 DBX 导入连接", "native.importFromClaude": "从 Claude 导入", "native.importFromCodex": "从 Codex 导入", - "native.importJSON": "导入 JSON…", "native.inEffect": "生效:", "native.include": "包含范围", "native.includeAShortBodyWhenUseful": "有必要时包含简短正文", @@ -5049,7 +4830,6 @@ "native.lldhLldmLlds": "{arg1}小时 {arg2}分 {arg3}秒", "native.lldmLlds": "{arg1}分 {arg2}秒", "native.llds": "{arg1}秒", - "native.lluRowsAffectedInDMs": "影响 {arg1} 行,耗时 {arg2} 毫秒", "native.load": "加载", "native.loadDiskVersion": "加载磁盘版本", "native.loadMore": "加载更多", @@ -5283,7 +5063,6 @@ "native.newFile": "新建文件…", "native.newFolder": "新建文件夹", "native.newProject": "新建项目…", - "native.newQuery": "新建查询", "native.newRunConfiguration": "新建运行配置", "native.newTag": "新建标签", "native.newTag.variant2": "新建标签…", @@ -5329,7 +5108,6 @@ "native.noCredentialFoundInCodexConfiguration": "Codex 配置中未找到凭据", "native.noCurrentBranch": "没有当前分支", "native.noCustomProvidersYet": "尚未添加自定义供应商。", - "native.noDatabasesLoaded": "尚未加载数据库", "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain": "未检测到系统默认 Shell。请选择已检测到的 Shell,或重新执行检测。", "native.noDependencies": "无依赖", "native.noDependencies.variant2": "没有依赖。", @@ -5394,7 +5172,6 @@ "native.noPublishedReleaseIsAvailableYet": "暂时没有已发布的版本。", "native.noPullRequests": "暂无拉取请求", "native.noPullRequestsMatchTheSelectedState": "当前状态下没有拉取请求。", - "native.noQueryOutput": "暂无查询输出", "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive": "暂未收到新的进度,任务仍在进行。", "native.noRecentProjects": "没有最近项目", "native.noReleaseIsAvailableYet": "暂时没有可用版本", @@ -5411,7 +5188,6 @@ "native.noSpringBootOrMavenModuleConfigurations": "没有 Spring Boot 或 Maven Module 配置", "native.noSubagentOutputYet": "尚未收到子代理输出", "native.noSubagentsYet": "暂无子代理", - "native.noTableSelected": "未选择表", "native.noTasksYet": "暂无任务", "native.noTextualDiffAvailable": "没有可用的文本 Diff", "native.noTextualDifference": "没有文本差异", @@ -5427,7 +5203,6 @@ "native.nodeJsLldOrLaterIsRequiredFound": "需要 Node.js {arg1} 或更高版本,当前为 {arg2}。", "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain": "未找到 Node.js {arg1} 或更高版本。请安装后重新检测。", "native.nodeJsSupport": "Node.js 支持", - "native.nonIntrospected": "未内省", "native.nonStreaming": "非流式", "native.nonStreamingResponses": "非流式响应", "native.none": "无", @@ -5436,7 +5211,6 @@ "native.notAssigned": "未分配", "native.notAvailableForThisLitheOrMac": "{arg1}(当前 Lithe 或这台 Mac 无法使用)", "native.notAvailableYetDependenciesAreNotIndexed": "尚不可用:依赖尚未建立索引", - "native.notConnected": "未连接", "native.notFetchedYet": "尚未获取。", "native.notFound": "未找到", "native.notIncludedInTheUpdatedPlan": "未包含在更新后的计划中", @@ -5571,7 +5345,6 @@ "native.passwordOrPassphrase": "密码或密钥口令", "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790": "粘贴 https 插件索引地址或 GitHub 仓库地址。GitHub 仓库的根目录需要包含 lithe-plugin-index.json。", "native.pastePatch": "粘贴补丁", - "native.pasteTSVFromClipboard": "从剪贴板粘贴 TSV", "native.pasteThisIntoYourMCPClientConfiguration": "将此内容粘贴到 MCP 客户端配置中。", "native.pastedFiles": "已粘贴文件", "native.patchCanBeAppliedToTheSelectedDestination": "补丁可应用到所选目标。", @@ -5745,10 +5518,8 @@ "native.pushThisBranchToGitHub": "将当前分支推送到 GitHub", "native.pushTo": "推送到 {arg1}", "native.pythonSupport": "Python 支持", - "native.queryHistory": "{arg1} 查询历史", "native.quickInformation": "快速信息", "native.quickResponsesWithBasicReasoning": "快速响应,基础推理", - "native.quickStart": "快速开始", "native.quit": "退出", "native.quitWithSelection": "按选择退出", "native.quittingLitheEndsThesePrograms": "退出 Lithe 会结束这些程序。", @@ -5785,7 +5556,6 @@ "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase": "变基需要干净的工作区。选择变基前,请先提交或暂存本地更改。", "native.recent": "最近", "native.recentProjects": "最近项目", - "native.recentSQL": "最近的 SQL", "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78": "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。", "native.reconnect": "重新连接", "native.reconnectThisAgentToRefreshItsModelList": "重新连接此 Agent 即可刷新模型列表。", @@ -5797,7 +5567,6 @@ "native.refreshAll": "全部刷新", "native.refreshChanges": "刷新更改", "native.refreshConnectionStructure": "刷新连接结构", - "native.refreshDatabases": "刷新数据库", "native.refreshDependencies": "刷新依赖", "native.refreshDetectedRuntimes": "刷新运行时检测结果", "native.refreshFiles": "刷新文件", @@ -5884,7 +5653,6 @@ "native.replace": "替换", "native.replaceCurrentCommitMessage": "替换当前提交信息?", "native.replaceExistingContent": "替换现有内容", - "native.replaceInCurrentPage": "在当前页替换…", "native.replaceInFile": "在文件中替换", "native.replaceInFile.variant2": "在文件中替换…", "native.replaceInFiles": "在项目中替换", @@ -5950,10 +5718,8 @@ "native.restoreDefaultTemplate": "恢复默认模板", "native.restoreDefaultUILayout": "恢复默认 UI 布局", "native.restorePath": "恢复路径", - "native.restoreSQLBackup": "恢复 SQL 备份…", "native.restoringGitHubConnection": "正在恢复 GitHub 连接", "native.result": "结果", - "native.resultD": "结果 {arg1}", "native.results": "测试结果", "native.results.variant2": "个结果", "native.resume": "恢复执行", @@ -6261,7 +6027,6 @@ "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying": "部分 MCP 配置文件未能恢复:{arg1}。请刷新并检查这些文件后重试。", "native.sortByLastUpdated": "按最近更新排序", "native.sortByName": "按名称排序", - "native.sortConnections": "连接排序", "native.sortPlugins": "插件排序", "native.source": "配置来源", "native.sourceCode": "源代码", @@ -6271,7 +6036,6 @@ "native.speed": "速度", "native.springBoot": "Spring Boot", "native.springEndpoints": "Spring 接口", - "native.sqlExecutionMarkers": "SQL 执行状态标记", "native.sqlSupport": "SQL 支持", "native.squash": "合并并保留消息", "native.squash1": "合并为 1 笔", @@ -6371,14 +6135,12 @@ "native.subjectMaximumLength": "标题最大长度", "native.submittingReview": "正在提交审查…", "native.succeeded": "成功", - "native.succeededLineD": "成功,第 {arg1} 行", "native.supplementaryMessageCancelled": "补充消息已取消。", "native.supportedPlaceholdersSummaryChangesTestingRisks": "支持的占位符:{summary}、{changes}、{testing}、{risks}。", "native.svelteSupport": "Svelte 支持", "native.swapDirection": "交换方向", "native.swiftSupport": "Swift 支持", "native.switchAgent": "切换 Agent", - "native.switchCurrentDatabase": "切换当前数据库", "native.switchProjectView": "切换项目视图", "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain": "请切换到安装此 CLI 的 Node.js/npm 环境,在该环境中更新后重新检测。", "native.symbols": "符号", @@ -7030,7 +6792,6 @@ "outline.search": "搜索大纲", "outline.showAll": "显示全部", "outline.title": "大纲", - "panes.missingDatabaseConnection": "缺少数据库连接", "panes.resizePanes": "调整窗格大小", "panes.searchResults": "搜索结果", "panes.webViewerDisabled": "Web 查看器已禁用", @@ -8707,7 +8468,6 @@ "keybindings.commands.editor.cursorUndo.description": "回到上一个光标位置和选区,保留代码修改", "keybindings.commands.editor.cursorUndo.title": "光标撤销", "native.aBasicProjectWithAFreeStructure": "具有自由结构的基本项目。", - "native.aFolderWithThisNameAlreadyExists": "已存在同名文件夹。", "native.aHookWithThisNameAlreadyExists": "已存在同名 Hook。", "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit": "主机名、IP 地址,或 ~/.ssh/config 中的 Host 别名。该文件里的 ProxyJump、IdentityFile 等设置仍然生效。", "native.aJavaProjectWithNoBuildToolMavenOrGradle": "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。", @@ -8723,7 +8483,6 @@ "native.afterAToolRuns": "工具运行后", "native.agentDefault": "沿用 Agent 默认值", "native.agentHooks": "Agent 钩子", - "native.allSchemas": "所有架构", "native.allTools": "所有工具", "native.alreadyExistsChooseAnotherNameOrLocation": "{arg1} 已存在,请更换名称或位置。", "native.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "已存在相同的原生 Hook,Lithe 保留了该条目。", @@ -8759,15 +8518,10 @@ "native.claudeSubscriptionQuota": "Claude 订阅额度", "native.codexHookApproval": "Codex Hook 授权", "native.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex 需要启用 Hooks,并在原生 /hooks 页面中信任命令。Lithe 只保存配置,不授予信任。编辑命令后需要重新批准。配置已写入不代表已执行。", - "native.collapseDatabase": "折叠数据库", - "native.collapseTable": "折叠表", "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported": "已连接,但 Lithe 暂不支持这台主机的平台。目前支持 x86_64 或 ARM64 上的 Linux。", "native.connectedThisHostCanBeUsedForRemoteDevelopment": "连接成功,这台主机可以用于远程开发。", "native.connecting": "正在连接…", "native.connection": "连接", - "native.connectionSuccessful": "连接成功", - "native.connectionTestDurationLldMs": "连接检测耗时:{arg1} 毫秒", - "native.connectionTestResult": "连接测试结果", "native.connectionTimeout": "连接超时", "native.conversationActions": "会话操作", "native.couldNotOpenTheRemoteFolder": "无法打开远程文件夹:{arg1}", @@ -8778,13 +8532,11 @@ "native.creatingThisProjectDownloadsFilesFromTheInternet": "创建此项目需要从网络下载文件。", "native.customArchetype": "自定义原型…", "native.dMinutesAgo": "{arg1}分钟前", - "native.dataSource": "数据源", "native.deleteChatX93291d9c41": "删除会话", "native.deleteHook": "删除 Hook?", "native.didNotFinishWithinLldSeconds": "{arg1} 未在 {arg2} 秒内完成。", "native.directories": "目录", "native.disk": "磁盘", - "native.doubleClickATableToViewData": "在数据库树中双击表以查看数据", "native.earlier": "更早", "native.editHook": "编辑 Hook", "native.emptyProject": "空项目", @@ -8794,18 +8546,13 @@ "native.enterAnAbsolutePathThatStartsWith": "请输入以 / 开头的绝对路径。", "native.enterTheHostWithoutAUserNameOrSpacesPutTheUserInUserName": "主机里不要包含用户名或空格,用户名请填在“用户名”里。", "native.exitedWithStatusD": "{arg1} 以状态 {arg2} 退出。\n{arg3}", - "native.expandDatabase": "展开数据库", - "native.expandTable": "展开表", "native.fileEdits": "文件编辑", "native.filePatterns": "文件模式", "native.findInMarketplace": "在插件市场中查找", "native.finishedWithoutCreating": "{arg1} 已结束,但没有生成 {arg2}。", "native.folder": "分组", - "native.folderNameIsRequired": "请输入文件夹名称。", "native.forExampleNotify": "例如 notify", "native.forExampleNotifyMeWhenATurnEnds": "例如:一轮结束时提醒我", - "native.forceRefresh": "强制刷新", - "native.forever": "永久", "native.forkConversation": "分叉会话", "native.freeOf": "可用 {arg1},共 {arg2}", "native.generators": "生成器", @@ -8827,7 +8574,6 @@ "native.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hook 会在 Agent 的生命周期事件中运行 Shell 命令。Agent 通过标准输入传入事件 JSON,并在会话目录中执行。更改对新会话生效,现有会话继续使用已加载的配置。", "native.hostName": "主机名", "native.httpsGithubComOwnerRepository": "https://github.com/owner/repository", - "native.importDataSources": "导入数据源…", "native.invalidHookFileGuards": "Hook 文件校验信息无效。", "native.isNeededForThisProjectButIsDisabled": "此项目需要 {arg1} 支持,但 {arg2} 已停用。", "native.keepAliveInterval": "保活间隔", @@ -8854,12 +8600,9 @@ "native.method": "认证方式", "native.mode": "模式", "native.move": "移动", - "native.newFolderX633810e9b0": "新建文件夹…", "native.newHook": "新 Hook", "native.newProjectX6f8d176462": "新建项目", "native.newSSHConnection": "新建 SSH 连接", - "native.noAccessibleDatabases": "没有可访问的数据库", - "native.noColumnsToDisplay": "没有可显示的列", "native.noHooksYet": "还没有 Hook。", "native.noHostYet": "尚未填写主机", "native.noInstalledPluginSupportsYet": "尚未安装支持 {arg1} 的插件。", @@ -8867,10 +8610,8 @@ "native.noJDKWasFoundInstallAJDKThenReopenThisWindow": "未找到 JDK。请安装 JDK 后重新打开此窗口。", "native.noMatchingHooks": "没有匹配的 Hook。", "native.noSSHConnectionsYet": "还没有 SSH 连接。", - "native.noSchemasSelected": "未选择架构", "native.notTested": "未测试", "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd": "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。", - "native.openATableToInspectItsStructure": "打开表以查看其结构", "native.operatingSystem": "系统", "native.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "可选描述,最多 200 个字符,仅保存在 Hook 资源库中。", "native.packageName": "软件包名称", @@ -8880,7 +8621,6 @@ "native.pin": "置顶", "native.pinConversation": "置顶会话", "native.port": "端口", - "native.pressEnterOrClickOutsideTheListToApply": "按 Enter 或点击列表外应用", "native.previous30Days": "过去 30 天", "native.previous7Days": "过去 7 天", "native.privateKey": "私钥", @@ -8891,35 +8631,25 @@ "native.providedByADisabledPlugin": "由已停用的插件提供", "native.publishCoreSelectsDefaultRemote": "发布 {arg1}(自动选择默认远程仓库)", "native.pushXc68c0bf1cd": "推送“{arg1}”?", - "native.queryConsole": "查询控制台", "native.quotaX6c105cae1a": "额度", - "native.refreshSelectedStructure": "刷新选中对象的结构", "native.rememberInKeychain": "记住到钥匙串", "native.remoteDevelopment": "远程开发", "native.remoteFolder": "远程文件夹", "native.remoteProject": "远程项目", "native.removalPending": "等待移除", - "native.renameFolder": "重命名文件夹", "native.resources": "资源", "native.retryHookSynchronization": "重试 Hook 同步", "native.sSH": "SSH", "native.sSHAgent": "SSH Agent", "native.sSHAuthentication": "SSH 认证", - "native.sSHTLS": "SSH / TLS", - "native.safetyAndLabels": "安全与标记", - "native.save": "保存:", "native.saveHook": "保存 Hook", - "native.savePassword": "保存密码", "native.savedInKeychain": "已保存在钥匙串中", "native.savesConnectsAndOpensTheRemoteFolderInThisWindow": "保存、连接,并在当前窗口打开远程文件夹。", - "native.searchDatabases": "搜索数据库", "native.searchHooks": "搜索 Hook", "native.searchJDKs": "搜索 JDK", "native.secondsBetweenKeepAliveMessagesWhichDetectADroppedNetworkLeaveEmptyFor15Seconds": "发送保活消息的间隔秒数,用于发现网络中断。留空为 15 秒。", "native.secondsToWaitForTheServerToAnswerLeaveEmptyFor15Seconds": "等待服务器响应的秒数。留空为 15 秒。", "native.security": "安全", - "native.selectAConnectionInTheDatabaseTree": "在数据库树中选择连接", - "native.selectDatabases": "选择数据库", "native.serverDefault": "服务器默认", "native.serverURL": "服务器 URL", "native.shellCommands": "Shell 命令", @@ -8929,13 +8659,11 @@ "native.testConnection": "测试连接", "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress": "测试连接后,这里会显示主机的系统、资源和地址。", "native.testing": "测试", - "native.testingConnection": "正在测试连接…", "native.theAddressIsInvalid": "地址无效。", "native.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "Agent 使用自身 Shell 执行命令,并通过标准输入传入事件 JSON。命令以你的用户权限运行,请只添加你信任的命令。", "native.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "Agent 配置目录已改变,请恢复之前的目录后再同步其 Hook。", "native.theConnectionFailed": "连接失败。", "native.theConnectionToTheRemoteHostWasLostReconnectToContinue": "与远程主机的连接已断开,请重新连接后继续。", - "native.theDatabaseSelectionIsNoLongerAvailableRefreshAndTryAgain": "数据库选择已失效,请刷新后重试。", "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit": "字段值不符合声明或超过大小限制。", "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes": "字段值无效或超过 1024 个 UTF-8 字节。", "native.theFileSystemThatHoldsTheHomeDirectory": "主目录所在的文件系统。", @@ -8960,8 +8688,6 @@ "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi": "主机上的项目文件夹。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。", "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh": "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。", "native.theProjectWasCreatedButGitCouldNotBeSetUp": "项目已创建,但无法初始化 Git 仓库:{arg1}", - "native.theSelectedConnectionFolderNoLongerExists": "所选连接文件夹已不存在。", - "native.theSelectedFolderCannotBeMovedThere": "无法将所选文件夹移动到该位置。", "native.theServerAskedAgainCheckThePreviousAnswer": "服务器再次提问,请检查上一次的回答。", "native.theServerRejectedTheCredentialsCheckTheUserNameAndTheAuthenticationMethod": "服务器拒绝了凭据,请检查用户名和认证方式。", "native.theSpringInitializrServerThatGeneratesTheProjectChangeItToUseAMirrorOrYourCompanySOwnServe": "生成项目的 Spring Initializr 服务器。可改为镜像或公司内部服务器。", @@ -8977,14 +8703,11 @@ "native.thisTemplateComesFromWhichIsDisabled": "此模板由已停用的插件 {arg1} 提供。", "native.thisVersionIsNewerThanTheSelectedRuntimeSupports": "该版本高于所选运行时支持的版本。", "native.timeoutSeconds": "超时(秒)", - "native.toFolder": "移至文件夹…", - "native.toTopLevel": "移至顶层", "native.toolFilter": "工具筛选", "native.tracking": "跟踪 {arg1}", "native.unknownDate": "时间未知", "native.unpin": "取消置顶", "native.unpinConversation": "取消置顶", - "native.untilRestart": "直到重启", "native.uptime": "运行时长", "native.usageAppearsAfterTheNextReply": "下一次回复后显示用量。", "native.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "使用 1–80 个字母、数字、连字符或下划线,名称不能重复。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index aec8d80dd..50ea786af 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -3073,16 +3073,6 @@ "windows" ] }, - { - "id": "ai.databaseContext", - "description": "Windows UI: ai.databaseContext", - "platforms": [ - "windows" - ], - "parameters": { - "type": "string" - } - }, { "id": "ai.deleteNamedSkill", "description": "Windows UI: ai.deleteNamedSkill", @@ -6937,2220 +6927,736 @@ "platforms": [ "windows" ], - "windows": { - "key": "commandPalette.actions.toggle-terminal.enableLabel" - } - }, - { - "id": "commandPalette.actions.toggleTerminalFeature.disableLabel", - "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.disableLabel", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.toggle-terminal-feature.disableLabel" - } - }, - { - "id": "commandPalette.actions.toggleTerminalFeature.enableLabel", - "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.enableLabel", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.toggle-terminal-feature.enableLabel" - } - }, - { - "id": "commandPalette.actions.toggleVimMode.label", - "description": "Windows UI: commandPalette.actions.toggle-vim-mode.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.toggle-vim-mode.label" - } - }, - { - "id": "commandPalette.actions.toggleWordWrap.disableLabel", - "description": "Windows UI: commandPalette.actions.toggle-word-wrap.disableLabel", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.toggle-word-wrap.disableLabel" - } - }, - { - "id": "commandPalette.actions.toggleWordWrap.enableLabel", - "description": "Windows UI: commandPalette.actions.toggle-word-wrap.enableLabel", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.toggle-word-wrap.enableLabel" - } - }, - { - "id": "commandPalette.actions.viewResetZoom.label", - "description": "Windows UI: commandPalette.actions.view-reset-zoom.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-reset-zoom.label" - } - }, - { - "id": "commandPalette.actions.viewShowFiles.label", - "description": "Windows UI: commandPalette.actions.view-show-files.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-show-files.label" - } - }, - { - "id": "commandPalette.actions.viewShowGit.label", - "description": "Windows UI: commandPalette.actions.view-show-git.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-show-git.label" - } - }, - { - "id": "commandPalette.actions.viewShowOutline.label", - "description": "Windows UI: commandPalette.actions.view-show-outline.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-show-outline.label" - } - }, - { - "id": "commandPalette.actions.viewZoomIn.label", - "description": "Windows UI: commandPalette.actions.view-zoom-in.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-zoom-in.label" - } - }, - { - "id": "commandPalette.actions.viewZoomOut.label", - "description": "Windows UI: commandPalette.actions.view-zoom-out.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.view-zoom-out.label" - } - }, - { - "id": "commandPalette.actions.vimInsertMode.label", - "description": "Windows UI: commandPalette.actions.vim-insert-mode.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.vim-insert-mode.label" - } - }, - { - "id": "commandPalette.actions.vimNormalMode.label", - "description": "Windows UI: commandPalette.actions.vim-normal-mode.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.vim-normal-mode.label" - } - }, - { - "id": "commandPalette.actions.vimVisualMode.label", - "description": "Windows UI: commandPalette.actions.vim-visual-mode.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.vim-visual-mode.label" - } - }, - { - "id": "commandPalette.actions.windowEnterFullscreen.label", - "description": "Windows UI: commandPalette.actions.window-enter-fullscreen.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.window-enter-fullscreen.label" - } - }, - { - "id": "commandPalette.actions.windowExitFullscreen.label", - "description": "Windows UI: commandPalette.actions.window-exit-fullscreen.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.window-exit-fullscreen.label" - } - }, - { - "id": "commandPalette.actions.windowMaximize.label", - "description": "Windows UI: commandPalette.actions.window-maximize.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.window-maximize.label" - } - }, - { - "id": "commandPalette.actions.windowMinimize.label", - "description": "Windows UI: commandPalette.actions.window-minimize.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.window-minimize.label" - } - }, - { - "id": "commandPalette.actions.windowRestore.label", - "description": "Windows UI: commandPalette.actions.window-restore.label", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.actions.window-restore.label" - } - }, - { - "id": "commandPalette.backToCommands", - "description": "Windows UI: commandPalette.backToCommands", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.AI", - "description": "Windows UI: commandPalette.categories.AI", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Advanced", - "description": "Windows UI: commandPalette.categories.Advanced", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.CLI", - "description": "Windows UI: commandPalette.categories.CLI", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Database", - "description": "Windows UI: commandPalette.categories.Database", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Developer", - "description": "Windows UI: commandPalette.categories.Developer", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Editor", - "description": "Windows UI: commandPalette.categories.Editor", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Features", - "description": "Windows UI: commandPalette.categories.Features", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.File", - "description": "Windows UI: commandPalette.categories.File", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Generate", - "description": "Windows UI: commandPalette.categories.Generate", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Git", - "description": "Windows UI: commandPalette.categories.Git", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.GitHub", - "description": "Windows UI: commandPalette.categories.GitHub", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.LSP", - "description": "Windows UI: commandPalette.categories.LSP", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Language", - "description": "Windows UI: commandPalette.categories.Language", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Markdown", - "description": "Windows UI: commandPalette.categories.Markdown", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Navigation", - "description": "Windows UI: commandPalette.categories.Navigation", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Settings", - "description": "Windows UI: commandPalette.categories.Settings", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Terminal", - "description": "Windows UI: commandPalette.categories.Terminal", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Theme", - "description": "Windows UI: commandPalette.categories.Theme", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.View", - "description": "Windows UI: commandPalette.categories.View", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Vim", - "description": "Windows UI: commandPalette.categories.Vim", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.Window", - "description": "Windows UI: commandPalette.categories.Window", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.categories.languageServer", - "description": "Windows UI: commandPalette.categories.Language Server", - "platforms": [ - "windows" - ], - "windows": { - "key": "commandPalette.categories.Language Server" - } - }, - { - "id": "commandPalette.clearPersistedActions", - "description": "Windows UI: commandPalette.clearPersistedActions", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.close", - "description": "Windows UI: commandPalette.close", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.current", - "description": "Windows UI: commandPalette.current", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.failedToImportTheme", - "description": "Windows UI: commandPalette.failedToImportTheme", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.importedTheme", - "description": "Windows UI: commandPalette.importedTheme", - "platforms": [ - "windows" - ], - "parameters": { - "name": "string" - } - }, - { - "id": "commandPalette.importedThemeVariants", - "description": "Windows UI: commandPalette.importedThemeVariants", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "commandPalette.noCommands", - "description": "Windows UI: commandPalette.noCommands", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.noIconThemes", - "description": "Windows UI: commandPalette.noIconThemes", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.noThemes", - "description": "Windows UI: commandPalette.noThemes", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.openAppearanceSettings", - "description": "Windows UI: commandPalette.openAppearanceSettings", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.placeholder", - "description": "Windows UI: commandPalette.placeholder", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.searchIconThemes", - "description": "Windows UI: commandPalette.searchIconThemes", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.searchThemes", - "description": "Windows UI: commandPalette.searchThemes", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.title", - "description": "Windows UI: commandPalette.title", - "platforms": [ - "windows" - ] - }, - { - "id": "commandPalette.uploadTheme", - "description": "Windows UI: commandPalette.uploadTheme", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.auto", - "description": "Windows UI: csv.auto", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.changeDelimiter", - "description": "Windows UI: csv.changeDelimiter", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.comma", - "description": "Windows UI: csv.comma", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.copyAsCsv", - "description": "Windows UI: csv.copyAsCsv", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.copyAsJson", - "description": "Windows UI: csv.copyAsJson", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.delimiter", - "description": "Windows UI: csv.delimiter", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.headerOff", - "description": "Windows UI: csv.headerOff", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.headerOn", - "description": "Windows UI: csv.headerOn", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.pipe", - "description": "Windows UI: csv.pipe", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.rowsColumns", - "description": "Windows UI: csv.rowsColumns", - "platforms": [ - "windows" - ], - "parameters": { - "columns": "string", - "rows": "string" - } - }, - { - "id": "csv.semicolon", - "description": "Windows UI: csv.semicolon", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.tab", - "description": "Windows UI: csv.tab", - "platforms": [ - "windows" - ] - }, - { - "id": "csv.toggleHeaderRow", - "description": "Windows UI: csv.toggleHeaderRow", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.baseTheme", - "description": "Windows UI: customTheme.baseTheme", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.createTitle", - "description": "Windows UI: customTheme.createTitle", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.defaultName", - "description": "Windows UI: customTheme.defaultName", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.description", - "description": "Windows UI: customTheme.description", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.filterAllFiles", - "description": "Windows UI: customTheme.filterAllFiles", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.filterLitheTheme", - "description": "Windows UI: customTheme.filterLitheTheme", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.generateFailed", - "description": "Windows UI: customTheme.generateFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.id", - "description": "Windows UI: customTheme.id", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.install", - "description": "Windows UI: customTheme.install", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.installFailed", - "description": "Windows UI: customTheme.installFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.installed", - "description": "Windows UI: customTheme.installed", - "platforms": [ - "windows" - ], - "parameters": { - "theme": "string" - } - }, - { - "id": "customTheme.installedVariants", - "description": "Windows UI: customTheme.installedVariants", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "customTheme.installing", - "description": "Windows UI: customTheme.installing", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.jsonSaved", - "description": "Windows UI: customTheme.jsonSaved", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.name", - "description": "Windows UI: customTheme.name", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.saveJson", - "description": "Windows UI: customTheme.saveJson", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.saveJsonFailed", - "description": "Windows UI: customTheme.saveJsonFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "customTheme.themeJson", - "description": "Windows UI: customTheme.themeJson", - "platforms": [ - "windows" - ] - }, - { - "id": "database.add", - "description": "Windows UI: database.add", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addColumn", - "description": "Windows UI: database.addColumn", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addDatabase", - "description": "Windows UI: database.addDatabase", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addFilter", - "description": "Windows UI: database.addFilter", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addNewRow", - "description": "Windows UI: database.addNewRow", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addRow", - "description": "Windows UI: database.addRow", - "platforms": [ - "windows" - ] - }, - { - "id": "database.addRowToTable", - "description": "Windows UI: database.addRowToTable", - "platforms": [ - "windows" - ], - "parameters": { - "table": "string" - } - }, - { - "id": "database.apply", - "description": "Windows UI: database.apply", - "platforms": [ - "windows" - ] - }, - { - "id": "database.applyQuery", - "description": "Windows UI: database.applyQuery", - "platforms": [ - "windows" - ] - }, - { - "id": "database.backToCommands", - "description": "Windows UI: database.backToCommands", - "platforms": [ - "windows" - ] - }, - { - "id": "database.chooseOrDropFile", - "description": "Windows UI: database.chooseOrDropFile", - "platforms": [ - "windows" - ], - "parameters": { - "provider": "string" - } - }, - { - "id": "database.clearAll", - "description": "Windows UI: database.clearAll", - "platforms": [ - "windows" - ] - }, - { - "id": "database.clearAllFilters", - "description": "Windows UI: database.clearAllFilters", - "platforms": [ - "windows" - ] - }, - { - "id": "database.clearRecentQueries", - "description": "Windows UI: database.clearRecentQueries", - "platforms": [ - "windows" - ] - }, - { - "id": "database.cmdCtrlEnterToRun", - "description": "Windows UI: database.cmdCtrlEnterToRun", - "platforms": [ - "windows" - ] - }, - { - "id": "database.collections", - "description": "Windows UI: database.collections", - "platforms": [ - "windows" - ] - }, - { - "id": "database.collectionsCount", - "description": "Windows UI: database.collectionsCount", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "database.columnName", - "description": "Windows UI: database.columnName", - "platforms": [ - "windows" - ] - }, - { - "id": "database.columnNumber", - "description": "Windows UI: database.columnNumber", - "platforms": [ - "windows" - ], - "parameters": { - "number": "string" - } - }, - { - "id": "database.columns", - "description": "Windows UI: database.columns", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connect", - "description": "Windows UI: database.connect", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectImmediately", - "description": "Windows UI: database.connectImmediately", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectToDatabase", - "description": "Windows UI: database.connectToDatabase", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connected", - "description": "Windows UI: database.connected", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connecting", - "description": "Windows UI: database.connecting", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectionName", - "description": "Windows UI: database.connectionName", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectionString", - "description": "Windows UI: database.connectionString", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectionStringMode", - "description": "Windows UI: database.connectionStringMode", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectionTestFailed", - "description": "Windows UI: database.connectionTestFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "database.connectionTestSuccessful", - "description": "Windows UI: database.connectionTestSuccessful", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyAsJson", - "description": "Windows UI: database.copyAsJson", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyExistingData", - "description": "Windows UI: database.copyExistingData", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyQuery", - "description": "Windows UI: database.copyQuery", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyQueryFromHistory", - "description": "Windows UI: database.copyQueryFromHistory", - "platforms": [ - "windows" - ], - "parameters": { - "preview": "string" - } - }, - { - "id": "database.copySelection", - "description": "Windows UI: database.copySelection", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copySelectionWithHeaders", - "description": "Windows UI: database.copySelectionWithHeaders", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyValue", - "description": "Windows UI: database.copyValue", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyVisiblePageJson", - "description": "Windows UI: database.copyVisiblePageJson", - "platforms": [ - "windows" - ] - }, - { - "id": "database.copyVisibleQueryPageJson", - "description": "Windows UI: database.copyVisibleQueryPageJson", - "platforms": [ - "windows" - ] - }, - { - "id": "database.create", - "description": "Windows UI: database.create", - "platforms": [ - "windows" - ] - }, - { - "id": "database.createNewTable", - "description": "Windows UI: database.createNewTable", - "platforms": [ - "windows" - ] - }, - { - "id": "database.createSlot", - "description": "Windows UI: database.createSlot", - "platforms": [ - "windows" - ] - }, - { - "id": "database.createSubscription", - "description": "Windows UI: database.createSubscription", - "platforms": [ - "windows" - ] - }, - { - "id": "database.createTable", - "description": "Windows UI: database.createTable", - "platforms": [ - "windows" - ] - }, - { - "id": "database.currentObject", - "description": "Windows UI: database.currentObject", - "platforms": [ - "windows" - ], - "parameters": { - "name": "string" - } - }, - { - "id": "database.currentPage", - "description": "Windows UI: database.currentPage", - "platforms": [ - "windows" - ] - }, - { - "id": "database.data", - "description": "Windows UI: database.data", - "platforms": [ - "windows" - ] - }, - { - "id": "database.database", - "description": "Windows UI: database.database", - "platforms": [ - "windows" - ] - }, - { - "id": "database.databaseFile", - "description": "Windows UI: database.databaseFile", - "platforms": [ - "windows" - ] - }, - { - "id": "database.databaseFilePathMissing", - "description": "Windows UI: database.databaseFilePathMissing", - "platforms": [ - "windows" - ] - }, - { - "id": "database.databaseRows", - "description": "Windows UI: database.databaseRows", - "platforms": [ - "windows" - ] - }, - { - "id": "database.databases", - "description": "Windows UI: database.databases", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteConnection", - "description": "Windows UI: database.deleteConnection", - "platforms": [ - "windows" - ], - "parameters": { - "name": "string" - } - }, - { - "id": "database.deleteDocument", - "description": "Windows UI: database.deleteDocument", - "platforms": [ - "windows" - ], - "parameters": { - "id": "string" - } - }, - { - "id": "database.deleteIndex", - "description": "Windows UI: database.deleteIndex", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteKey", - "description": "Windows UI: database.deleteKey", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteMaterializedView", - "description": "Windows UI: database.deleteMaterializedView", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteRow", - "description": "Windows UI: database.deleteRow", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteTable", - "description": "Windows UI: database.deleteTable", - "platforms": [ - "windows" - ] - }, - { - "id": "database.deleteView", - "description": "Windows UI: database.deleteView", - "platforms": [ - "windows" - ] - }, - { - "id": "database.detected", - "description": "Windows UI: database.detected", - "platforms": [ - "windows" - ] - }, - { - "id": "database.disableSubscription", - "description": "Windows UI: database.disableSubscription", - "platforms": [ - "windows" - ] - }, - { - "id": "database.documentNumber", - "description": "Windows UI: database.documentNumber", - "platforms": [ - "windows" - ], - "parameters": { - "number": "string" - } - }, - { - "id": "database.documentsCount", - "description": "Windows UI: database.documentsCount", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "database.documentsPerPage", - "description": "Windows UI: database.documentsPerPage", - "platforms": [ - "windows" - ] - }, - { - "id": "database.dropDatabaseFile", - "description": "Windows UI: database.dropDatabaseFile", - "platforms": [ - "windows" - ] - }, - { - "id": "database.dropSubscription", - "description": "Windows UI: database.dropSubscription", - "platforms": [ - "windows" - ] - }, - { - "id": "database.dropSupportedDatabaseFile", - "description": "Windows UI: database.dropSupportedDatabaseFile", - "platforms": [ - "windows" - ] - }, - { - "id": "database.editRow", - "description": "Windows UI: database.editRow", - "platforms": [ - "windows" - ] - }, - { - "id": "database.editRowInTable", - "description": "Windows UI: database.editRowInTable", - "platforms": [ - "windows" - ], - "parameters": { - "table": "string" - } - }, - { - "id": "database.emptyFilterResult", - "description": "Windows UI: database.emptyFilterResult", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enableFailoverSlotSync", - "description": "Windows UI: database.enableFailoverSlotSync", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enableSubscription", - "description": "Windows UI: database.enableSubscription", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enabled", - "description": "Windows UI: database.enabled", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enterConnectionString", - "description": "Windows UI: database.enterConnectionString", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enterDatabaseName", - "description": "Windows UI: database.enterDatabaseName", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enterHost", - "description": "Windows UI: database.enterHost", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enterTableName", - "description": "Windows UI: database.enterTableName", - "platforms": [ - "windows" - ] - }, - { - "id": "database.enterValidPort", - "description": "Windows UI: database.enterValidPort", - "platforms": [ - "windows" - ] - }, - { - "id": "database.execute", - "description": "Windows UI: database.execute", - "platforms": [ - "windows" - ] - }, - { - "id": "database.expandCell", - "description": "Windows UI: database.expandCell", - "platforms": [ - "windows" - ] - }, - { - "id": "database.expandJson", - "description": "Windows UI: database.expandJson", - "platforms": [ - "windows" - ] - }, - { - "id": "database.exportAsCsv", - "description": "Windows UI: database.exportAsCsv", - "platforms": [ - "windows" - ] - }, - { - "id": "database.exportVisiblePageCsv", - "description": "Windows UI: database.exportVisiblePageCsv", - "platforms": [ - "windows" - ] - }, - { - "id": "database.exportVisibleQueryPageCsv", - "description": "Windows UI: database.exportVisibleQueryPageCsv", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterBetween", - "description": "Windows UI: database.filterBetween", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterByColumn", - "description": "Windows UI: database.filterByColumn", - "platforms": [ - "windows" - ], - "parameters": { - "column": "string" - } - }, - { - "id": "database.filterContains", - "description": "Windows UI: database.filterContains", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterEndsWith", - "description": "Windows UI: database.filterEndsWith", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterEquals", - "description": "Windows UI: database.filterEquals", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterGreaterThan", - "description": "Windows UI: database.filterGreaterThan", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterGreaterThanOrEqual", - "description": "Windows UI: database.filterGreaterThanOrEqual", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterIsNotNull", - "description": "Windows UI: database.filterIsNotNull", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterIsNull", - "description": "Windows UI: database.filterIsNull", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterLessThan", - "description": "Windows UI: database.filterLessThan", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterLessThanOrEqual", - "description": "Windows UI: database.filterLessThanOrEqual", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterNotEquals", - "description": "Windows UI: database.filterNotEquals", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filterStartsWith", - "description": "Windows UI: database.filterStartsWith", - "platforms": [ - "windows" - ] - }, - { - "id": "database.filtersCount", - "description": "Windows UI: database.filtersCount", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "database.firstPage", - "description": "Windows UI: database.firstPage", - "platforms": [ - "windows" - ] - }, - { - "id": "database.form", - "description": "Windows UI: database.form", - "platforms": [ - "windows" - ] - }, - { - "id": "database.formMode", - "description": "Windows UI: database.formMode", - "platforms": [ - "windows" - ] - }, - { - "id": "database.hideColumnTypes", - "description": "Windows UI: database.hideColumnTypes", - "platforms": [ - "windows" - ] - }, - { - "id": "database.host", - "description": "Windows UI: database.host", - "platforms": [ - "windows" - ] - }, - { - "id": "database.indexes", - "description": "Windows UI: database.indexes", - "platforms": [ - "windows" - ] - }, - { - "id": "database.indexesCount", - "description": "Windows UI: database.indexesCount", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" + "windows": { + "key": "commandPalette.actions.toggle-terminal.enableLabel" } }, { - "id": "database.info", - "description": "Windows UI: database.info", + "id": "commandPalette.actions.toggleTerminalFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.disableLabel", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.toggle-terminal-feature.disableLabel" + } }, { - "id": "database.insertSqlCompletion", - "description": "Windows UI: database.insertSqlCompletion", + "id": "commandPalette.actions.toggleTerminalFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.enableLabel", "platforms": [ "windows" ], - "parameters": { - "detail": "string", - "label": "string" + "windows": { + "key": "commandPalette.actions.toggle-terminal-feature.enableLabel" } }, { - "id": "database.installProviderFromExtensions", - "description": "Windows UI: database.installProviderFromExtensions", + "id": "commandPalette.actions.toggleVimMode.label", + "description": "Windows UI: commandPalette.actions.toggle-vim-mode.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.toggle-vim-mode.label" + } }, { - "id": "database.keyPattern", - "description": "Windows UI: database.keyPattern", + "id": "commandPalette.actions.toggleWordWrap.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-word-wrap.disableLabel", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.toggle-word-wrap.disableLabel" + } }, { - "id": "database.keyPatternPlaceholder", - "description": "Windows UI: database.keyPatternPlaceholder", + "id": "commandPalette.actions.toggleWordWrap.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-word-wrap.enableLabel", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.toggle-word-wrap.enableLabel" + } }, { - "id": "database.lastPage", - "description": "Windows UI: database.lastPage", + "id": "commandPalette.actions.viewResetZoom.label", + "description": "Windows UI: commandPalette.actions.view-reset-zoom.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.view-reset-zoom.label" + } }, { - "id": "database.lastRunMs", - "description": "Windows UI: database.lastRunMs", + "id": "commandPalette.actions.viewShowFiles.label", + "description": "Windows UI: commandPalette.actions.view-show-files.label", "platforms": [ "windows" ], - "parameters": { - "ms": "string" + "windows": { + "key": "commandPalette.actions.view-show-files.label" } }, { - "id": "database.leaveBlankForDefault", - "description": "Windows UI: database.leaveBlankForDefault", + "id": "commandPalette.actions.viewShowGit.label", + "description": "Windows UI: commandPalette.actions.view-show-git.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.view-show-git.label" + } }, { - "id": "database.loadingDatabases", - "description": "Windows UI: database.loadingDatabases", + "id": "commandPalette.actions.viewShowOutline.label", + "description": "Windows UI: commandPalette.actions.view-show-outline.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.view-show-outline.label" + } }, { - "id": "database.loadingKeysEllipsis", - "description": "Windows UI: database.loadingKeysEllipsis", + "id": "commandPalette.actions.viewZoomIn.label", + "description": "Windows UI: commandPalette.actions.view-zoom-in.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.view-zoom-in.label" + } }, { - "id": "database.loadingMoreKeys", - "description": "Windows UI: database.loadingMoreKeys", + "id": "commandPalette.actions.viewZoomOut.label", + "description": "Windows UI: commandPalette.actions.view-zoom-out.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.view-zoom-out.label" + } }, { - "id": "database.materializedViews", - "description": "Windows UI: database.materializedViews", + "id": "commandPalette.actions.vimInsertMode.label", + "description": "Windows UI: commandPalette.actions.vim-insert-mode.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.vim-insert-mode.label" + } }, { - "id": "database.mongodbFilterQuery", - "description": "Windows UI: database.mongodbFilterQuery", + "id": "commandPalette.actions.vimNormalMode.label", + "description": "Windows UI: commandPalette.actions.vim-normal-mode.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.vim-normal-mode.label" + } }, { - "id": "database.mongodbSortQuery", - "description": "Windows UI: database.mongodbSortQuery", + "id": "commandPalette.actions.vimVisualMode.label", + "description": "Windows UI: commandPalette.actions.vim-visual-mode.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.vim-visual-mode.label" + } }, { - "id": "database.moreKeysEllipsis", - "description": "Windows UI: database.moreKeysEllipsis", + "id": "commandPalette.actions.windowEnterFullscreen.label", + "description": "Windows UI: commandPalette.actions.window-enter-fullscreen.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.window-enter-fullscreen.label" + } }, { - "id": "database.name", - "description": "Windows UI: database.name", + "id": "commandPalette.actions.windowExitFullscreen.label", + "description": "Windows UI: commandPalette.actions.window-exit-fullscreen.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.window-exit-fullscreen.label" + } }, { - "id": "database.noData", - "description": "Windows UI: database.noData", + "id": "commandPalette.actions.windowMaximize.label", + "description": "Windows UI: commandPalette.actions.window-maximize.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.window-maximize.label" + } }, { - "id": "database.noDocumentsFound", - "description": "Windows UI: database.noDocumentsFound", + "id": "commandPalette.actions.windowMinimize.label", + "description": "Windows UI: commandPalette.actions.window-minimize.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.window-minimize.label" + } }, { - "id": "database.noMatchingDatabases", - "description": "Windows UI: database.noMatchingDatabases", + "id": "commandPalette.actions.windowRestore.label", + "description": "Windows UI: commandPalette.actions.window-restore.label", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.actions.window-restore.label" + } }, { - "id": "database.noProvidersInstalled", - "description": "Windows UI: database.noProvidersInstalled", + "id": "commandPalette.backToCommands", + "description": "Windows UI: commandPalette.backToCommands", "platforms": [ "windows" ] }, { - "id": "database.noWorkspaceDatabases", - "description": "Windows UI: database.noWorkspaceDatabases", + "id": "commandPalette.categories.AI", + "description": "Windows UI: commandPalette.categories.AI", "platforms": [ "windows" ] }, { - "id": "database.objects", - "description": "Windows UI: database.objects", + "id": "commandPalette.categories.Advanced", + "description": "Windows UI: commandPalette.categories.Advanced", "platforms": [ "windows" ] }, { - "id": "database.objectsCount", - "description": "Windows UI: database.objectsCount", + "id": "commandPalette.categories.CLI", + "description": "Windows UI: commandPalette.categories.CLI", "platforms": [ "windows" - ], - "parameters": { - "count": "string" - } + ] }, { - "id": "database.onOwner", - "description": "Windows UI: database.onOwner", + "id": "commandPalette.categories.Developer", + "description": "Windows UI: commandPalette.categories.Developer", "platforms": [ "windows" - ], - "parameters": { - "owner": "string" - } + ] }, { - "id": "database.openDatabase", - "description": "Windows UI: database.openDatabase", + "id": "commandPalette.categories.Editor", + "description": "Windows UI: commandPalette.categories.Editor", "platforms": [ "windows" ] }, { - "id": "database.openExtensions", - "description": "Windows UI: database.openExtensions", + "id": "commandPalette.categories.Features", + "description": "Windows UI: commandPalette.categories.Features", "platforms": [ "windows" ] }, { - "id": "database.openQuery", - "description": "Windows UI: database.openQuery", + "id": "commandPalette.categories.File", + "description": "Windows UI: commandPalette.categories.File", "platforms": [ "windows" - ], - "parameters": { - "preview": "string" - } + ] }, { - "id": "database.openSqlEditor", - "description": "Windows UI: database.openSqlEditor", + "id": "commandPalette.categories.Generate", + "description": "Windows UI: commandPalette.categories.Generate", "platforms": [ "windows" ] }, { - "id": "database.openWorkspaceBeforeAdding", - "description": "Windows UI: database.openWorkspaceBeforeAdding", + "id": "commandPalette.categories.Git", + "description": "Windows UI: commandPalette.categories.Git", "platforms": [ "windows" ] }, { - "id": "database.openWorkspaceToAdd", - "description": "Windows UI: database.openWorkspaceToAdd", + "id": "commandPalette.categories.GitHub", + "description": "Windows UI: commandPalette.categories.GitHub", "platforms": [ "windows" ] }, { - "id": "database.optional", - "description": "Windows UI: database.optional", + "id": "commandPalette.categories.LSP", + "description": "Windows UI: commandPalette.categories.LSP", "platforms": [ "windows" ] }, { - "id": "database.pageOf", - "description": "Windows UI: database.pageOf", + "id": "commandPalette.categories.Language", + "description": "Windows UI: commandPalette.categories.Language", "platforms": [ "windows" - ], - "parameters": { - "page": "string", - "pages": "string" - } + ] }, { - "id": "database.password", - "description": "Windows UI: database.password", + "id": "commandPalette.categories.Markdown", + "description": "Windows UI: commandPalette.categories.Markdown", "platforms": [ "windows" ] }, { - "id": "database.perPage", - "description": "Windows UI: database.perPage", + "id": "commandPalette.categories.Navigation", + "description": "Windows UI: commandPalette.categories.Navigation", "platforms": [ "windows" ] }, { - "id": "database.port", - "description": "Windows UI: database.port", + "id": "commandPalette.categories.Settings", + "description": "Windows UI: commandPalette.categories.Settings", "platforms": [ "windows" ] }, { - "id": "database.publications", - "description": "Windows UI: database.publications", + "id": "commandPalette.categories.Terminal", + "description": "Windows UI: commandPalette.categories.Terminal", "platforms": [ "windows" ] }, { - "id": "database.queryRows", - "description": "Windows UI: database.queryRows", + "id": "commandPalette.categories.Theme", + "description": "Windows UI: commandPalette.categories.Theme", "platforms": [ "windows" - ], - "parameters": { - "count": "string" - } + ] }, { - "id": "database.queryRowsLabel", - "description": "Windows UI: database.queryRowsLabel", + "id": "commandPalette.categories.View", + "description": "Windows UI: commandPalette.categories.View", "platforms": [ "windows" ] }, { - "id": "database.queryRowsOnThisPage", - "description": "Windows UI: database.queryRowsOnThisPage", + "id": "commandPalette.categories.Vim", + "description": "Windows UI: commandPalette.categories.Vim", "platforms": [ "windows" ] }, { - "id": "database.recent", - "description": "Windows UI: database.recent", + "id": "commandPalette.categories.Window", + "description": "Windows UI: commandPalette.categories.Window", "platforms": [ "windows" ] }, { - "id": "database.recentQueries", - "description": "Windows UI: database.recentQueries", + "id": "commandPalette.categories.languageServer", + "description": "Windows UI: commandPalette.categories.Language Server", "platforms": [ "windows" - ] + ], + "windows": { + "key": "commandPalette.categories.Language Server" + } }, { - "id": "database.refresh", - "description": "Windows UI: database.refresh", + "id": "commandPalette.clearPersistedActions", + "description": "Windows UI: commandPalette.clearPersistedActions", "platforms": [ "windows" ] }, { - "id": "database.refreshKeys", - "description": "Windows UI: database.refreshKeys", + "id": "commandPalette.close", + "description": "Windows UI: commandPalette.close", "platforms": [ "windows" ] }, { - "id": "database.refreshSubscription", - "description": "Windows UI: database.refreshSubscription", + "id": "commandPalette.current", + "description": "Windows UI: commandPalette.current", "platforms": [ "windows" ] }, { - "id": "database.refreshingKeys", - "description": "Windows UI: database.refreshingKeys", + "id": "commandPalette.failedToImportTheme", + "description": "Windows UI: commandPalette.failedToImportTheme", "platforms": [ "windows" ] }, { - "id": "database.removeColumn", - "description": "Windows UI: database.removeColumn", + "id": "commandPalette.importedTheme", + "description": "Windows UI: commandPalette.importedTheme", "platforms": [ "windows" ], "parameters": { - "column": "string" + "name": "string" } }, { - "id": "database.removeFilter", - "description": "Windows UI: database.removeFilter", + "id": "commandPalette.importedThemeVariants", + "description": "Windows UI: commandPalette.importedThemeVariants", "platforms": [ "windows" - ] + ], + "parameters": { + "count": "string" + } }, { - "id": "database.removeFromHistory", - "description": "Windows UI: database.removeFromHistory", + "id": "commandPalette.noCommands", + "description": "Windows UI: commandPalette.noCommands", "platforms": [ "windows" ] }, { - "id": "database.removeQueryFromHistory", - "description": "Windows UI: database.removeQueryFromHistory", + "id": "commandPalette.noIconThemes", + "description": "Windows UI: commandPalette.noIconThemes", "platforms": [ "windows" - ], - "parameters": { - "preview": "string" - } + ] }, { - "id": "database.required", - "description": "Windows UI: database.required", + "id": "commandPalette.noThemes", + "description": "Windows UI: commandPalette.noThemes", "platforms": [ "windows" ] }, { - "id": "database.reset", - "description": "Windows UI: database.reset", + "id": "commandPalette.openAppearanceSettings", + "description": "Windows UI: commandPalette.openAppearanceSettings", "platforms": [ "windows" ] }, { - "id": "database.resetQuery", - "description": "Windows UI: database.resetQuery", + "id": "commandPalette.placeholder", + "description": "Windows UI: commandPalette.placeholder", "platforms": [ "windows" ] }, { - "id": "database.resultCount", - "description": "Windows UI: database.resultCount", + "id": "commandPalette.searchIconThemes", + "description": "Windows UI: commandPalette.searchIconThemes", "platforms": [ "windows" - ], - "parameters": { - "count": "string", - "label": "string" - } + ] }, { - "id": "database.rows", - "description": "Windows UI: database.rows", + "id": "commandPalette.searchThemes", + "description": "Windows UI: commandPalette.searchThemes", "platforms": [ "windows" ] }, { - "id": "database.rowsPerPage", - "description": "Windows UI: database.rowsPerPage", + "id": "commandPalette.title", + "description": "Windows UI: commandPalette.title", "platforms": [ "windows" ] }, { - "id": "database.runQuery", - "description": "Windows UI: database.runQuery", + "id": "commandPalette.uploadTheme", + "description": "Windows UI: commandPalette.uploadTheme", "platforms": [ "windows" ] }, { - "id": "database.runQueryFromHistory", - "description": "Windows UI: database.runQueryFromHistory", + "id": "csv.auto", + "description": "Windows UI: csv.auto", "platforms": [ "windows" - ], - "parameters": { - "preview": "string" - } + ] }, { - "id": "database.runSelection", - "description": "Windows UI: database.runSelection", + "id": "csv.changeDelimiter", + "description": "Windows UI: csv.changeDelimiter", "platforms": [ "windows" ] }, { - "id": "database.saveChanges", - "description": "Windows UI: database.saveChanges", + "id": "csv.comma", + "description": "Windows UI: csv.comma", "platforms": [ "windows" ] }, { - "id": "database.savePasswordSecurely", - "description": "Windows UI: database.savePasswordSecurely", + "id": "csv.copyAsCsv", + "description": "Windows UI: csv.copyAsCsv", "platforms": [ "windows" ] }, { - "id": "database.scanningKeys", - "description": "Windows UI: database.scanningKeys", + "id": "csv.copyAsJson", + "description": "Windows UI: csv.copyAsJson", "platforms": [ "windows" ] }, { - "id": "database.schema", - "description": "Windows UI: database.schema", + "id": "csv.delimiter", + "description": "Windows UI: csv.delimiter", "platforms": [ "windows" ] }, { - "id": "database.searchDatabases", - "description": "Windows UI: database.searchDatabases", + "id": "csv.headerOff", + "description": "Windows UI: csv.headerOff", "platforms": [ "windows" ] }, { - "id": "database.searchKeys", - "description": "Windows UI: database.searchKeys", + "id": "csv.headerOn", + "description": "Windows UI: csv.headerOn", "platforms": [ "windows" ] }, { - "id": "database.selectAllVisibleCells", - "description": "Windows UI: database.selectAllVisibleCells", + "id": "csv.pipe", + "description": "Windows UI: csv.pipe", "platforms": [ "windows" ] }, { - "id": "database.selectCollection", - "description": "Windows UI: database.selectCollection", + "id": "csv.rowsColumns", + "description": "Windows UI: csv.rowsColumns", "platforms": [ "windows" ], "parameters": { - "collection": "string" + "columns": "string", + "rows": "string" } }, { - "id": "database.selectCollectionDescription", - "description": "Windows UI: database.selectCollectionDescription", + "id": "csv.semicolon", + "description": "Windows UI: csv.semicolon", "platforms": [ "windows" ] }, { - "id": "database.selectCollectionTitle", - "description": "Windows UI: database.selectCollectionTitle", + "id": "csv.tab", + "description": "Windows UI: csv.tab", "platforms": [ "windows" ] }, { - "id": "database.selectDatabase", - "description": "Windows UI: database.selectDatabase", + "id": "csv.toggleHeaderRow", + "description": "Windows UI: csv.toggleHeaderRow", "platforms": [ "windows" ] }, { - "id": "database.selectDatabaseFile", - "description": "Windows UI: database.selectDatabaseFile", + "id": "customTheme.baseTheme", + "description": "Windows UI: customTheme.baseTheme", "platforms": [ "windows" ] }, { - "id": "database.selectKey", - "description": "Windows UI: database.selectKey", - "platforms": [ - "windows" - ], - "parameters": { - "key": "string" - } - }, - { - "id": "database.selectKeyDescription", - "description": "Windows UI: database.selectKeyDescription", + "id": "customTheme.createTitle", + "description": "Windows UI: customTheme.createTitle", "platforms": [ "windows" ] }, { - "id": "database.selectKeyTitle", - "description": "Windows UI: database.selectKeyTitle", + "id": "customTheme.defaultName", + "description": "Windows UI: customTheme.defaultName", "platforms": [ "windows" ] }, { - "id": "database.selectObject", - "description": "Windows UI: database.selectObject", - "platforms": [ - "windows" - ], - "parameters": { - "kind": "string", - "name": "string" - } - }, - { - "id": "database.selectSqliteDatabaseFile", - "description": "Windows UI: database.selectSqliteDatabaseFile", + "id": "customTheme.description", + "description": "Windows UI: customTheme.description", "platforms": [ "windows" ] }, { - "id": "database.selectionWillRun", - "description": "Windows UI: database.selectionWillRun", + "id": "customTheme.filterAllFiles", + "description": "Windows UI: customTheme.filterAllFiles", "platforms": [ "windows" ] }, { - "id": "database.serverInfo", - "description": "Windows UI: database.serverInfo", + "id": "customTheme.filterLitheTheme", + "description": "Windows UI: customTheme.filterLitheTheme", "platforms": [ "windows" ] }, { - "id": "database.setColumnNotNull", - "description": "Windows UI: database.setColumnNotNull", + "id": "customTheme.generateFailed", + "description": "Windows UI: customTheme.generateFailed", "platforms": [ "windows" - ], - "parameters": { - "column": "string" - } + ] }, { - "id": "database.showColumnTypes", - "description": "Windows UI: database.showColumnTypes", + "id": "customTheme.id", + "description": "Windows UI: customTheme.id", "platforms": [ "windows" ] }, { - "id": "database.slotName", - "description": "Windows UI: database.slotName", + "id": "customTheme.install", + "description": "Windows UI: customTheme.install", "platforms": [ "windows" ] }, { - "id": "database.subscriptions", - "description": "Windows UI: database.subscriptions", + "id": "customTheme.installFailed", + "description": "Windows UI: customTheme.installFailed", "platforms": [ "windows" ] }, { - "id": "database.switchToView", - "description": "Windows UI: database.switchToView", + "id": "customTheme.installed", + "description": "Windows UI: customTheme.installed", "platforms": [ "windows" ], "parameters": { - "view": "string" + "theme": "string" } }, { - "id": "database.tableName", - "description": "Windows UI: database.tableName", + "id": "customTheme.installedVariants", + "description": "Windows UI: customTheme.installedVariants", "platforms": [ "windows" - ] + ], + "parameters": { + "count": "string" + } }, { - "id": "database.tables", - "description": "Windows UI: database.tables", + "id": "customTheme.installing", + "description": "Windows UI: customTheme.installing", "platforms": [ "windows" ] }, { - "id": "database.tablesCount", - "description": "Windows UI: database.tablesCount", - "platforms": [ - "windows" - ], - "parameters": { - "count": "string" - } - }, - { - "id": "database.test", - "description": "Windows UI: database.test", + "id": "customTheme.jsonSaved", + "description": "Windows UI: customTheme.jsonSaved", "platforms": [ "windows" ] }, { - "id": "database.testConnection", - "description": "Windows UI: database.testConnection", + "id": "customTheme.name", + "description": "Windows UI: customTheme.name", "platforms": [ "windows" ] }, { - "id": "database.testing", - "description": "Windows UI: database.testing", + "id": "customTheme.saveJson", + "description": "Windows UI: customTheme.saveJson", "platforms": [ "windows" ] }, { - "id": "database.to", - "description": "Windows UI: database.to", + "id": "customTheme.saveJsonFailed", + "description": "Windows UI: customTheme.saveJsonFailed", "platforms": [ "windows" ] }, { - "id": "database.toggleColumnTypes", - "description": "Windows UI: database.toggleColumnTypes", + "id": "customTheme.themeJson", + "description": "Windows UI: customTheme.themeJson", "platforms": [ "windows" ] }, { - "id": "database.toggleServerInfo", - "description": "Windows UI: database.toggleServerInfo", + "id": "database.connect", + "description": "Windows UI: database.connect", "platforms": [ "windows" ] }, { - "id": "database.username", - "description": "Windows UI: database.username", + "id": "database.host", + "description": "Windows UI: database.host", "platforms": [ "windows" ] }, { - "id": "database.value", - "description": "Windows UI: database.value", + "id": "database.optional", + "description": "Windows UI: database.optional", "platforms": [ "windows" ] }, { - "id": "database.views", - "description": "Windows UI: database.views", + "id": "database.password", + "description": "Windows UI: database.password", "platforms": [ "windows" ] }, { - "id": "database.visibleQueryRowsOnPage", - "description": "Windows UI: database.visibleQueryRowsOnPage", + "id": "database.port", + "description": "Windows UI: database.port", "platforms": [ "windows" - ], - "parameters": { - "count": "string", - "page": "string", - "pages": "string" - } + ] }, { - "id": "database.visibleRows", - "description": "Windows UI: database.visibleRows", + "id": "database.username", + "description": "Windows UI: database.username", "platforms": [ "windows" - ], - "parameters": { - "count": "string" - } + ] }, { "id": "debugger.adapterOutputAppearsHere", @@ -11955,13 +10461,6 @@ "windows" ] }, - { - "id": "extensions.category.database", - "description": "Windows UI: extensions.category.database", - "platforms": [ - "windows" - ] - }, { "id": "extensions.category.iconTheme", "description": "Windows UI: extensions.category.icon-theme", @@ -12034,13 +10533,6 @@ "name": "string" } }, - { - "id": "extensions.databases", - "description": "Windows UI: extensions.databases", - "platforms": [ - "windows" - ] - }, { "id": "extensions.deactivate", "description": "Windows UI: extensions.deactivate", @@ -26763,13 +25255,6 @@ "windows" ] }, - { - "id": "menu.databases", - "description": "Windows UI: menu.databases", - "platforms": [ - "windows" - ] - }, { "id": "menu.deleteLine", "description": "Windows UI: menu.deleteLine", @@ -29734,17 +28219,6 @@ "arguments": [] } }, - { - "id": "native.backUpDatabaseAsSQL", - "description": "Native UI: Back Up Database as SQL…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Back Up Database as SQL…", - "arguments": [] - } - }, { "id": "native.backgroundServices", "description": "Native UI: Background Services", @@ -30110,17 +28584,6 @@ "arguments": [] } }, - { - "id": "native.browseDatabase", - "description": "Native UI: Browse Database", - "platforms": [ - "macos" - ], - "macos": { - "key": "Browse Database", - "arguments": [] - } - }, { "id": "native.bugReport", "description": "Native UI: Bug report", @@ -31959,17 +30422,6 @@ "arguments": [] } }, - { - "id": "native.clearOutput", - "description": "Native UI: Clear output", - "platforms": [ - "macos" - ], - "macos": { - "key": "Clear output", - "arguments": [] - } - }, { "id": "native.clearOverride", "description": "Native UI: Clear Override", @@ -33233,17 +31685,6 @@ "arguments": [] } }, - { - "id": "native.commonActions", - "description": "Native UI: Common Actions", - "platforms": [ - "macos" - ], - "macos": { - "key": "Common Actions", - "arguments": [] - } - }, { "id": "native.community", "description": "Native UI: Community", @@ -33805,17 +32246,6 @@ "arguments": [] } }, - { - "id": "native.connections", - "description": "Native UI: Connections", - "platforms": [ - "macos" - ], - "macos": { - "key": "Connections", - "arguments": [] - } - }, { "id": "native.connectionsLld", "description": "Native UI: Connections: %lld", @@ -35564,28 +33994,6 @@ "arg1": "integer" } }, - { - "id": "native.dRowsRetrievedInDMs", - "description": "Native UI: %d rows retrieved in %d ms", - "platforms": [ - "macos" - ], - "macos": { - "key": "%d rows retrieved in %d ms", - "arguments": [ - "arg1", - "arg2" - ], - "formats": { - "arg1": "d", - "arg2": "d" - } - }, - "parameters": { - "arg1": "integer", - "arg2": "integer" - } - }, { "id": "native.dSelected", "description": "Native UI: %d selected", @@ -35649,17 +34057,6 @@ "arguments": [] } }, - { - "id": "native.dataTools", - "description": "Native UI: Data Tools", - "platforms": [ - "macos" - ], - "macos": { - "key": "Data Tools", - "arguments": [] - } - }, { "id": "native.database", "description": "Native UI: Database", @@ -35671,28 +34068,6 @@ "arguments": [] } }, - { - "id": "native.databaseOverview", - "description": "Native UI: Database Overview", - "platforms": [ - "macos" - ], - "macos": { - "key": "Database Overview", - "arguments": [] - } - }, - { - "id": "native.databaseTypes", - "description": "Native UI: Database types", - "platforms": [ - "macos" - ], - "macos": { - "key": "Database types", - "arguments": [] - } - }, { "id": "native.date", "description": "Native UI: Date", @@ -37352,17 +35727,6 @@ "arguments": [] } }, - { - "id": "native.duplicateConnection", - "description": "Native UI: Duplicate Connection", - "platforms": [ - "macos" - ], - "macos": { - "key": "Duplicate Connection", - "arguments": [] - } - }, { "id": "native.duplicateOmitted", "description": "Native UI: duplicate omitted", @@ -37555,17 +35919,6 @@ "arguments": [] } }, - { - "id": "native.editConnection", - "description": "Native UI: Edit Connection", - "platforms": [ - "macos" - ], - "macos": { - "key": "Edit Connection", - "arguments": [] - } - }, { "id": "native.editExceptionBreakpoint", "description": "Native UI: Edit %@ exception breakpoint", @@ -38902,28 +37255,6 @@ "arguments": [] } }, - { - "id": "native.exportTableAsCSV", - "description": "Native UI: Export Table as CSV…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Export Table as CSV…", - "arguments": [] - } - }, - { - "id": "native.exportTableAsJSON", - "description": "Native UI: Export Table as JSON…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Export Table as JSON…", - "arguments": [] - } - }, { "id": "native.exportingConversations", "description": "Native UI: Exporting conversations…", @@ -39052,25 +37383,6 @@ "arguments": [] } }, - { - "id": "native.failedLineD", - "description": "Native UI: Failed, line %d", - "platforms": [ - "macos" - ], - "macos": { - "key": "Failed, line %d", - "arguments": [ - "arg1" - ], - "formats": { - "arg1": "d" - } - }, - "parameters": { - "arg1": "integer" - } - }, { "id": "native.failedToStartCheckTheLanguageServerConfiguration", "description": "Native UI: Failed to start; check the language server configuration", @@ -39627,17 +37939,6 @@ "arguments": [] } }, - { - "id": "native.filterDatabaseType", - "description": "Native UI: Filter database type", - "platforms": [ - "macos" - ], - "macos": { - "key": "Filter database type", - "arguments": [] - } - }, { "id": "native.filterLogMessages", "description": "Native UI: Filter log messages", @@ -41637,28 +39938,6 @@ "arguments": [] } }, - { - "id": "native.importCSV", - "description": "Native UI: Import CSV…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Import CSV…", - "arguments": [] - } - }, - { - "id": "native.importConnectionsFromDBX", - "description": "Native UI: Import connections from DBX", - "platforms": [ - "macos" - ], - "macos": { - "key": "Import connections from DBX", - "arguments": [] - } - }, { "id": "native.importFromClaude", "description": "Native UI: Import from Claude", @@ -41681,17 +39960,6 @@ "arguments": [] } }, - { - "id": "native.importJSON", - "description": "Native UI: Import JSON…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Import JSON…", - "arguments": [] - } - }, { "id": "native.inEffect", "description": "Native UI: In effect: ", @@ -45381,28 +43649,6 @@ "arg1": "integer" } }, - { - "id": "native.lluRowsAffectedInDMs", - "description": "Native UI: %llu rows affected in %d ms", - "platforms": [ - "macos" - ], - "macos": { - "key": "%llu rows affected in %d ms", - "arguments": [ - "arg1", - "arg2" - ], - "formats": { - "arg1": "llu", - "arg2": "d" - } - }, - "parameters": { - "arg1": "unsignedInteger", - "arg2": "integer" - } - }, { "id": "native.load", "description": "Native UI: Load", @@ -48086,17 +46332,6 @@ "arguments": [] } }, - { - "id": "native.newQuery", - "description": "Native UI: New Query", - "platforms": [ - "macos" - ], - "macos": { - "key": "New Query", - "arguments": [] - } - }, { "id": "native.newRunConfiguration", "description": "Native UI: New Run Configuration", @@ -48597,17 +46832,6 @@ "arguments": [] } }, - { - "id": "native.noDatabasesLoaded", - "description": "Native UI: No databases loaded", - "platforms": [ - "macos" - ], - "macos": { - "key": "No databases loaded", - "arguments": [] - } - }, { "id": "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain", "description": "Native UI: No default shell was detected. Choose a detected shell or run detection again.", @@ -49327,17 +47551,6 @@ "arguments": [] } }, - { - "id": "native.noQueryOutput", - "description": "Native UI: No query output", - "platforms": [ - "macos" - ], - "macos": { - "key": "No query output", - "arguments": [] - } - }, { "id": "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive", "description": "Native UI: No recent progress has been received. The task is still active.", @@ -49519,17 +47732,6 @@ "arguments": [] } }, - { - "id": "native.noTableSelected", - "description": "Native UI: No Table Selected", - "platforms": [ - "macos" - ], - "macos": { - "key": "No Table Selected", - "arguments": [] - } - }, { "id": "native.noTasksYet", "description": "Native UI: No tasks yet", @@ -49712,17 +47914,6 @@ "arguments": [] } }, - { - "id": "native.nonIntrospected", - "description": "Native UI: Non-Introspected", - "platforms": [ - "macos" - ], - "macos": { - "key": "Non-Introspected", - "arguments": [] - } - }, { "id": "native.nonStreaming", "description": "Native UI: Non-streaming", @@ -49816,17 +48007,6 @@ "arguments": [] } }, - { - "id": "native.notConnected", - "description": "Native UI: Not connected", - "platforms": [ - "macos" - ], - "macos": { - "key": "Not connected", - "arguments": [] - } - }, { "id": "native.notFetchedYet", "description": "Native UI: Not fetched yet.", @@ -51412,17 +49592,6 @@ "arguments": [] } }, - { - "id": "native.pasteTSVFromClipboard", - "description": "Native UI: Paste TSV from Clipboard", - "platforms": [ - "macos" - ], - "macos": { - "key": "Paste TSV from Clipboard", - "arguments": [] - } - }, { "id": "native.pasteThisIntoYourMCPClientConfiguration", "description": "Native UI: Paste this into your MCP client configuration.", @@ -53382,22 +51551,6 @@ "arguments": [] } }, - { - "id": "native.queryHistory", - "description": "Native UI: %@ Query History", - "platforms": [ - "macos" - ], - "macos": { - "key": "%@ Query History", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } - }, { "id": "native.quickInformation", "description": "Native UI: Quick Information", @@ -53420,17 +51573,6 @@ "arguments": [] } }, - { - "id": "native.quickStart", - "description": "Native UI: Quick Start", - "platforms": [ - "macos" - ], - "macos": { - "key": "Quick Start", - "arguments": [] - } - }, { "id": "native.quit", "description": "Native UI: Quit", @@ -53852,17 +51994,6 @@ "arguments": [] } }, - { - "id": "native.recentSQL", - "description": "Native UI: Recent SQL", - "platforms": [ - "macos" - ], - "macos": { - "key": "Recent SQL", - "arguments": [] - } - }, { "id": "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78", "description": "Native UI: Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.", @@ -54007,17 +52138,6 @@ "arguments": [] } }, - { - "id": "native.refreshDatabases", - "description": "Native UI: Refresh databases", - "platforms": [ - "macos" - ], - "macos": { - "key": "Refresh databases", - "arguments": [] - } - }, { "id": "native.refreshDependencies", "description": "Native UI: Refresh dependencies", @@ -55027,17 +53147,6 @@ "arguments": [] } }, - { - "id": "native.replaceInCurrentPage", - "description": "Native UI: Replace in Current Page…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Replace in Current Page…", - "arguments": [] - } - }, { "id": "native.replaceInFile", "description": "Native UI: Replace in File", @@ -55785,17 +53894,6 @@ "arguments": [] } }, - { - "id": "native.restoreSQLBackup", - "description": "Native UI: Restore SQL Backup…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Restore SQL Backup…", - "arguments": [] - } - }, { "id": "native.restoringGitHubConnection", "description": "Native UI: Restoring GitHub connection", @@ -55818,25 +53916,6 @@ "arguments": [] } }, - { - "id": "native.resultD", - "description": "Native UI: Result %d", - "platforms": [ - "macos" - ], - "macos": { - "key": "Result %d", - "arguments": [ - "arg1" - ], - "formats": { - "arg1": "d" - } - }, - "parameters": { - "arg1": "integer" - } - }, { "id": "native.results", "description": "Native UI: Results", @@ -59333,17 +57412,6 @@ "arguments": [] } }, - { - "id": "native.sortConnections", - "description": "Native UI: Sort connections", - "platforms": [ - "macos" - ], - "macos": { - "key": "Sort connections", - "arguments": [] - } - }, { "id": "native.sortPlugins", "description": "Native UI: Sort plugins", @@ -59443,17 +57511,6 @@ "arguments": [] } }, - { - "id": "native.sqlExecutionMarkers", - "description": "Native UI: SQL execution markers", - "platforms": [ - "macos" - ], - "macos": { - "key": "SQL execution markers", - "arguments": [] - } - }, { "id": "native.sqlSupport", "description": "Native UI: SQL Support", @@ -60565,25 +58622,6 @@ "arguments": [] } }, - { - "id": "native.succeededLineD", - "description": "Native UI: Succeeded, line %d", - "platforms": [ - "macos" - ], - "macos": { - "key": "Succeeded, line %d", - "arguments": [ - "arg1" - ], - "formats": { - "arg1": "d" - } - }, - "parameters": { - "arg1": "integer" - } - }, { "id": "native.supplementaryMessageCancelled", "description": "Native UI: Supplementary message cancelled.", @@ -60651,17 +58689,6 @@ "arguments": [] } }, - { - "id": "native.switchCurrentDatabase", - "description": "Native UI: Switch current database", - "platforms": [ - "macos" - ], - "macos": { - "key": "Switch current database", - "arguments": [] - } - }, { "id": "native.switchProjectView", "description": "Native UI: Switch project view", @@ -67670,13 +65697,6 @@ "windows" ] }, - { - "id": "panes.missingDatabaseConnection", - "description": "Windows UI: panes.missingDatabaseConnection", - "platforms": [ - "windows" - ] - }, { "id": "panes.resizePanes", "description": "Windows UI: panes.resizePanes", @@ -80315,17 +78335,6 @@ "arguments": [] } }, - { - "id": "native.aFolderWithThisNameAlreadyExists", - "description": "Native UI: A folder with this name already exists.", - "platforms": [ - "macos" - ], - "macos": { - "key": "A folder with this name already exists.", - "arguments": [] - } - }, { "id": "native.aHookWithThisNameAlreadyExists", "description": "Native UI: A Hook with this name already exists.", @@ -80491,17 +78500,6 @@ "arguments": [] } }, - { - "id": "native.allSchemas", - "description": "Native UI: All schemas", - "platforms": [ - "macos" - ], - "macos": { - "key": "All schemas", - "arguments": [] - } - }, { "id": "native.allTools", "description": "Native UI: All tools", @@ -80925,28 +78923,6 @@ "arguments": [] } }, - { - "id": "native.collapseDatabase", - "description": "Native UI: Collapse database", - "platforms": [ - "macos" - ], - "macos": { - "key": "Collapse database", - "arguments": [] - } - }, - { - "id": "native.collapseTable", - "description": "Native UI: Collapse table", - "platforms": [ - "macos" - ], - "macos": { - "key": "Collapse table", - "arguments": [] - } - }, { "id": "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported", "description": "Native UI: Connected, but Lithe cannot run on this platform yet. Linux on x86_64 or ARM64 is supported.", @@ -80991,47 +78967,6 @@ "arguments": [] } }, - { - "id": "native.connectionSuccessful", - "description": "Native UI: Connection successful", - "platforms": [ - "macos" - ], - "macos": { - "key": "Connection successful", - "arguments": [] - } - }, - { - "id": "native.connectionTestDurationLldMs", - "description": "Native UI: Connection test duration: %lld ms", - "platforms": [ - "macos" - ], - "macos": { - "key": "Connection test duration: %lld ms", - "arguments": [ - "arg1" - ], - "formats": { - "arg1": "lld" - } - }, - "parameters": { - "arg1": "integer" - } - }, - { - "id": "native.connectionTestResult", - "description": "Native UI: Connection test result", - "platforms": [ - "macos" - ], - "macos": { - "key": "Connection test result", - "arguments": [] - } - }, { "id": "native.connectionTimeout", "description": "Native UI: Connection timeout", @@ -81174,17 +79109,6 @@ "arg1": "integer" } }, - { - "id": "native.dataSource", - "description": "Native UI: Data Source", - "platforms": [ - "macos" - ], - "macos": { - "key": "Data Source", - "arguments": [] - } - }, { "id": "native.deleteChatX93291d9c41", "description": "Native UI: Delete chat", @@ -81251,17 +79175,6 @@ "arguments": [] } }, - { - "id": "native.doubleClickATableToViewData", - "description": "Native UI: Double-click a table to view data", - "platforms": [ - "macos" - ], - "macos": { - "key": "Double-click a table to view data", - "arguments": [] - } - }, { "id": "native.earlier", "description": "Native UI: Earlier", @@ -81375,28 +79288,6 @@ "arg3": "string" } }, - { - "id": "native.expandDatabase", - "description": "Native UI: Expand database", - "platforms": [ - "macos" - ], - "macos": { - "key": "Expand database", - "arguments": [] - } - }, - { - "id": "native.expandTable", - "description": "Native UI: Expand table", - "platforms": [ - "macos" - ], - "macos": { - "key": "Expand table", - "arguments": [] - } - }, { "id": "native.fileEdits", "description": "Native UI: File edits", @@ -81463,17 +79354,6 @@ "arguments": [] } }, - { - "id": "native.folderNameIsRequired", - "description": "Native UI: Folder name is required.", - "platforms": [ - "macos" - ], - "macos": { - "key": "Folder name is required.", - "arguments": [] - } - }, { "id": "native.forExampleNotify", "description": "Native UI: For example, notify", @@ -81496,28 +79376,6 @@ "arguments": [] } }, - { - "id": "native.forceRefresh", - "description": "Native UI: Force Refresh", - "platforms": [ - "macos" - ], - "macos": { - "key": "Force Refresh", - "arguments": [] - } - }, - { - "id": "native.forever", - "description": "Native UI: Forever", - "platforms": [ - "macos" - ], - "macos": { - "key": "Forever", - "arguments": [] - } - }, { "id": "native.forkConversation", "description": "Native UI: Fork conversation", @@ -81760,17 +79618,6 @@ "arguments": [] } }, - { - "id": "native.importDataSources", - "description": "Native UI: Import Data Sources…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Import Data Sources…", - "arguments": [] - } - }, { "id": "native.invalidHookFileGuards", "description": "Native UI: Invalid Hook file guards.", @@ -82100,17 +79947,6 @@ "arguments": [] } }, - { - "id": "native.newFolderX633810e9b0", - "description": "Native UI: New Folder…", - "platforms": [ - "macos" - ], - "macos": { - "key": "New Folder…", - "arguments": [] - } - }, { "id": "native.newHook", "description": "Native UI: New Hook", @@ -82144,28 +79980,6 @@ "arguments": [] } }, - { - "id": "native.noAccessibleDatabases", - "description": "Native UI: No accessible databases", - "platforms": [ - "macos" - ], - "macos": { - "key": "No accessible databases", - "arguments": [] - } - }, - { - "id": "native.noColumnsToDisplay", - "description": "Native UI: No columns to display", - "platforms": [ - "macos" - ], - "macos": { - "key": "No columns to display", - "arguments": [] - } - }, { "id": "native.noHooksYet", "description": "Native UI: No Hooks yet.", @@ -82251,17 +80065,6 @@ "arguments": [] } }, - { - "id": "native.noSchemasSelected", - "description": "Native UI: No schemas selected", - "platforms": [ - "macos" - ], - "macos": { - "key": "No schemas selected", - "arguments": [] - } - }, { "id": "native.notTested", "description": "Native UI: Not tested", @@ -82284,17 +80087,6 @@ "arguments": [] } }, - { - "id": "native.openATableToInspectItsStructure", - "description": "Native UI: Open a table to inspect its structure", - "platforms": [ - "macos" - ], - "macos": { - "key": "Open a table to inspect its structure", - "arguments": [] - } - }, { "id": "native.operatingSystem", "description": "Native UI: Operating system", @@ -82394,17 +80186,6 @@ "arguments": [] } }, - { - "id": "native.pressEnterOrClickOutsideTheListToApply", - "description": "Native UI: Press Enter or click outside the list to apply", - "platforms": [ - "macos" - ], - "macos": { - "key": "Press Enter or click outside the list to apply", - "arguments": [] - } - }, { "id": "native.previous30Days", "description": "Native UI: Previous 30 days", @@ -82539,17 +80320,6 @@ "arg1": "string" } }, - { - "id": "native.queryConsole", - "description": "Native UI: Query Console", - "platforms": [ - "macos" - ], - "macos": { - "key": "Query Console", - "arguments": [] - } - }, { "id": "native.quotaX6c105cae1a", "description": "Native UI: Quota", @@ -82561,17 +80331,6 @@ "arguments": [] } }, - { - "id": "native.refreshSelectedStructure", - "description": "Native UI: Refresh selected structure", - "platforms": [ - "macos" - ], - "macos": { - "key": "Refresh selected structure", - "arguments": [] - } - }, { "id": "native.rememberInKeychain", "description": "Native UI: Remember in Keychain", @@ -82627,17 +80386,6 @@ "arguments": [] } }, - { - "id": "native.renameFolder", - "description": "Native UI: Rename Folder", - "platforms": [ - "macos" - ], - "macos": { - "key": "Rename Folder", - "arguments": [] - } - }, { "id": "native.resources", "description": "Native UI: Resources", @@ -82693,39 +80441,6 @@ "arguments": [] } }, - { - "id": "native.sSHTLS", - "description": "Native UI: SSH / TLS", - "platforms": [ - "macos" - ], - "macos": { - "key": "SSH / TLS", - "arguments": [] - } - }, - { - "id": "native.safetyAndLabels", - "description": "Native UI: Safety and Labels", - "platforms": [ - "macos" - ], - "macos": { - "key": "Safety and Labels", - "arguments": [] - } - }, - { - "id": "native.save", - "description": "Native UI: Save:", - "platforms": [ - "macos" - ], - "macos": { - "key": "Save:", - "arguments": [] - } - }, { "id": "native.saveHook", "description": "Native UI: Save Hook", @@ -82737,17 +80452,6 @@ "arguments": [] } }, - { - "id": "native.savePassword", - "description": "Native UI: Save password", - "platforms": [ - "macos" - ], - "macos": { - "key": "Save password", - "arguments": [] - } - }, { "id": "native.savedInKeychain", "description": "Native UI: Saved in Keychain", @@ -82770,17 +80474,6 @@ "arguments": [] } }, - { - "id": "native.searchDatabases", - "description": "Native UI: Search databases", - "platforms": [ - "macos" - ], - "macos": { - "key": "Search databases", - "arguments": [] - } - }, { "id": "native.searchHooks", "description": "Native UI: Search Hooks", @@ -82836,28 +80529,6 @@ "arguments": [] } }, - { - "id": "native.selectAConnectionInTheDatabaseTree", - "description": "Native UI: Select a connection in the database tree", - "platforms": [ - "macos" - ], - "macos": { - "key": "Select a connection in the database tree", - "arguments": [] - } - }, - { - "id": "native.selectDatabases", - "description": "Native UI: Select databases", - "platforms": [ - "macos" - ], - "macos": { - "key": "Select databases", - "arguments": [] - } - }, { "id": "native.serverDefault", "description": "Native UI: Server default", @@ -82965,17 +80636,6 @@ "arguments": [] } }, - { - "id": "native.testingConnection", - "description": "Native UI: Testing connection…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Testing connection…", - "arguments": [] - } - }, { "id": "native.theAddressIsInvalid", "description": "Native UI: The address is invalid.", @@ -83031,17 +80691,6 @@ "arguments": [] } }, - { - "id": "native.theDatabaseSelectionIsNoLongerAvailableRefreshAndTryAgain", - "description": "Native UI: The database selection is no longer available. Refresh and try again.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The database selection is no longer available. Refresh and try again.", - "arguments": [] - } - }, { "id": "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit", "description": "Native UI: The field value does not match its declaration or exceeds its size limit.", @@ -83322,28 +80971,6 @@ "arg1": "string" } }, - { - "id": "native.theSelectedConnectionFolderNoLongerExists", - "description": "Native UI: The selected connection folder no longer exists.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The selected connection folder no longer exists.", - "arguments": [] - } - }, - { - "id": "native.theSelectedFolderCannotBeMovedThere", - "description": "Native UI: The selected folder cannot be moved there.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The selected folder cannot be moved there.", - "arguments": [] - } - }, { "id": "native.theServerAskedAgainCheckThePreviousAnswer", "description": "Native UI: The server asked again. Check the previous answer.", @@ -83517,28 +81144,6 @@ "arguments": [] } }, - { - "id": "native.toFolder", - "description": "Native UI: To Folder…", - "platforms": [ - "macos" - ], - "macos": { - "key": "To Folder…", - "arguments": [] - } - }, - { - "id": "native.toTopLevel", - "description": "Native UI: To Top Level", - "platforms": [ - "macos" - ], - "macos": { - "key": "To Top Level", - "arguments": [] - } - }, { "id": "native.toolFilter", "description": "Native UI: Tool filter", @@ -83602,17 +81207,6 @@ "arguments": [] } }, - { - "id": "native.untilRestart", - "description": "Native UI: Until restart", - "platforms": [ - "macos" - ], - "macos": { - "key": "Until restart", - "arguments": [] - } - }, { "id": "native.uptime", "description": "Native UI: Uptime", diff --git a/shared/platform-feature-matrix/features/database-connections.json b/shared/platform-feature-matrix/features/database-connections.json index a4e298af2..85ee5e4e7 100644 --- a/shared/platform-feature-matrix/features/database-connections.json +++ b/shared/platform-feature-matrix/features/database-connections.json @@ -2,34 +2,27 @@ "id": "database-connections", "area": "数据库", "group": "数据库工作区", - "capability": "连接配置与 Provider", + "capability": "连接配置与 Provider(由官方插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database", - "macos/Sources/Lithe/Services", - "macos/Sources/Lithe/Theme/LitheTheme.swift", - "macos/Tests/LitheTests/LitheSearchFieldStyleTests.swift", - "macos/Sources/Lithe/Models/AppModel/AppModel.swift", - "macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift", - "macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift", - "macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift", - "macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift", - "macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift", - "macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift", - ".agents/notes/implemented/feature/2026-10-22-database-multi-schema-tree.md", - "macos/Sources/Lithe/Views/Components/LitheNamePopup.swift", - "macos/Tests/LitheTests/LitheCenteredPopupTests.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/config/backend-capabilities.ts" + "shared/contracts/plugins/v1/README.md" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。 Search 查询与文件掩码、Database 连接搜索、Redis Key pattern 和 Nacos 配置/服务搜索均直接使用 Git Log 同源的全局唯一 LitheSearchFieldStyle;在深浅主题核对 36pt 外框、30pt 可见高度、4pt 圆角、1pt 常态边框、2pt 聚焦边框、3pt 外留白及 13pt 常规文字和提示色,确认无局部背景或原生边框叠加,并保留搜索、清除与键盘提交行为。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 共享搜索字段未聚焦时悬停立即显示文本编辑光标,离开或禁用时恢复箭头;保留中文组词及连接过滤语义。 macOS:分别在数据库首页、SQL/数据/结构/历史页、Redis/Nacos/MongoDB 工作区及连接/结构比较弹窗打开时退出应用或关闭项目,确认无 SIGTRAP;休眠后重新打开数据库,确认新实例正常工作,旧视图的延迟刷新不会访问已清空的能力缓存。 macOS 数据库入口位于右侧停靠栏,采用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 20pt 描边圆柱 SVG(database/expui/dbms@20x20.svg 及 dark 变体,DatabaseIconMappings 对应数据库工具窗口),沿用共享停靠栏尺寸及普通/悬停/选中配色,打开时保留项目树、代码编辑器和底部工具;无连接时只有右侧空状态,不显示中央统计首页。打开表、查询或历史时复用可关闭的 Database 编辑器标签;隐藏右侧面板不关闭标签,关闭标签保留连接和 SQL 草稿。切换代码、终端、媒体、Diff 后可重新选择数据库;休眠和关闭项目移除数据库标签。Windows 本次保持现有布局。 数据库工具栏和筛选/排序统一为原始 16pt IDEA 明暗 SVG、共享 22pt 按钮,检查无额外边框、正常/悬停/禁用/筛选选中状态和默认箭头;各图标按钮复用 workbenchHoverHelp 即时显示本地化用途,禁用按钮也可显示提示,移开和点击后关闭,提示在数据库面板局部 scope 内绘制;窄面板工具栏可横向滚动,空状态只保留常规灰色文字,在内容区横向居中、纵向约三分之一处显示;移除图标、说明段落和重复 New Connection 按钮,添加连接仍由工具栏加号进入。 数据库连接新建/编辑、文件夹及 DBX 导入复用全局居中弹窗、输入焦点边框与确认/取消按钮;核对重复打开、取消/Escape/标题关闭、引擎选择及语言环境,导入关闭后清空数据。 连接/文件夹表单不再绘制 grouped 卡片背景,展开高级选项仍可滚动;普通/密码输入框复用共享 28pt 居中布局和启用状态下的 I-beam 悬停,深浅主题核对文字、焦点和禁用状态。 数据库面板仅标题下保留共享 LitheToolWindowHeaderDivider(主题边框色、1pt 高度、两侧 1pt 留白),工具栏按钮下方不再绘制额外横线;深浅主题对照项目和其他工具窗口核对。 连接搜索默认折叠为工具栏搜索图标,点击仅展开共享输入框且不自动聚焦,点击输入框后才显示焦点边框,再次点击或 Escape 清空并关闭搜索;筛选和排序固定在同一工具栏最右侧,窄宽度下左侧动作可横向滚动且右侧菜单仍可操作。 新建/编辑连接采用原生 Grid 标签列和输入列,Host/Port 同行、TLS/安全开关在输入列;提示文字跟随输入列,保留现有字段及逻辑。核对 MySQL、SQLite、Redis、Nacos、长标签以及展开安全/网络选项时的对齐和滚动;弹窗按简要/展开内容调整高度,不添加 IDEA 专有控件。 共享搜索输入在点击同窗口输入区域以外的位置时释放原生字段编辑器,焦点边框恢复常态;输入内部点击保留焦点,其他搜索框的观察器不得取消当前编辑,查询文字保持不变。 数据库概览按钮位于搜索左侧,打开独立可关闭且去重的 Database Overview 编辑器标签;用共享标题/分隔线/行样式保留连接数、已连接数、类型数、Quick Start、Common Actions、Recent SQL 六区。核对与 Database 查询标签同时打开、分别关闭和重复打开,草稿/连接不丢失;关闭项目或休眠移除全部数据库标签;明暗主题、窄窗口纵向排版及最近 SQL 恢复到所属连接。 数据库概览和内容标签统一使用侧栏圆柱图标;连接标签保持数据库名(空值用连接名),页面切换不改变名称;无连接全局历史显示 SQL History。 概览常用动作悬停仅覆盖内容和内边距,使用共享圆角,禁用动作无悬停;概览 New Connection 删除 SwiftUI sheet,复用侧栏 LitheCenteredPopup,核对无打开动画/遮罩、原生标题与外框及关闭/取消/Escape。 连接弹窗 Safety 和 Advanced network 标题整行支持点击展开/收起;检查文字、空白、原生箭头均只切换一次,展开后的输入和开关操作不触发收起。 数据库分组文件夹复用项目树 LitheIcon(.folder) 和共享 treeIconSize,删除黄色 folder.fill;核对深浅主题、展开/收起及嵌套分组。 数据库树分组、连接、数据库、表节点复用共享 litheTreeRow/Tree 字体、行高、缩进与选择悬停色;移除连接颜色竖线、Tables 数量胶囊和空表独立卡片,保留状态、只读/保护提示及原动作。 数据工作区四个导航图标复用共享工具栏按钮和选中状态,空状态使用共享 LitheUnavailableView;数据库操作错误由协调器交给全局通知,核对隐藏侧栏仍通知、重复激活不重复通知、休眠取消观察和重新激活恢复;连接树悬停保持默认箭头。 四个数据库工作区内容统一共享页签、工具栏、标题分隔线、滚动条与字体,SQL 编辑器使用主题字体和颜色;Overview Quick Start/Recent SQL 悬停与 Common Actions 共用圆角与内外留白,背景不覆盖行外留白,状态和时间保持右对齐。核对有数据和空状态、查询草稿切换/关闭、结果网格、数据编辑、结构动作、历史恢复以及明暗主题。 数据库树采用共享左右 12pt、上下 4pt 外留白;连接与表的选中行接入共享活动区域观察器,仅树区域获得焦点且应用窗口为 key 时显示主题蓝色,点击编辑区、搜索框或切换窗口后显示非活动灰色。核对重新点击树恢复蓝色,选中对象保持不变。 连接树移除行尾 Provider 类型及状态符号,成功/失败改为品牌图标右下角绿色/红色圆点,未连接不显示点,连接中保留进度提示;MySQL 原有海豚图形使用 IDEA 深色图标蓝色 #548AF7 提升暗背景对比,工具栏与树节点复用同一资源。核对不同连接状态、只读提示及明暗背景可辨性。 Provider 图标替换为 Community c7f91397 的原始 16×16 providers SVG 及可用 dark 变体(Nacos 保持已有资源),避免 DBX 1024px 画布内留白导致图形过小;主题切换使用独立缓存。状态点依据 modifiers/dot.svg 的 5pt 直径,在 Lithe 主题语义色上按用户要求轻微提高饱和度,不改变全局颜色。验证原生资源尺寸、实际像素占用、主题切换及红绿点位置。 Redis Key Browser 标题/DB 标识复用共享工具窗口标题栏,Estimate size 使用原生常规复选框;刷新与 SCAN 搜索使用 IDEA 16pt SVG 和共享 22pt 工具栏按钮及悬停说明。删除局部红色图标底板、DB 胶囊、迷你开关和内联错误条,错误沿用协调器全局通知,空状态复用 LitheUnavailableView 并填充浏览区,确保搜索固定顶部。保留 SCAN 分页、大小估计绑定、重试、详情及写保护逻辑。核对错误/空状态、搜索焦点、键盘提交与切换连接;原生生命周期检查覆盖 SQLite/Redis 工作区关闭项目后的保留视图。 Data 表格按 Community grid/impl 的 TableResultView、CellRenderingUtils、GridColorSchemeUtil 和 SelectionColorLayer 组织:数据工具栏置于 WHERE/ORDER BY 分栏上方,删除无动作 All rows 胶囊;表头 25pt 下限、4pt 左内边距、原始列 SVG、自适应数字宽度行号列,点击行号仍选择行、点击 # 仍全选,保留排序/过滤菜单。数字单元格右对齐、文本左对齐;单元格选择接入原生焦点和共享活动区域,有焦编辑器选择蓝、失焦编辑器选择灰,切换表清理焦点;表头使用背景 RGB +16/-16 的 softHighlightOf、网格线取 Islands 编辑器 INDENT_GUIDE,单元格左右 3pt,行高按字体行高乘编辑器行距 +5pt,顶部工具栏使用共享 30pt 紧凑高度。数据库树 schema/Tables 分组/表使用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 schema/objectGroup/table 16pt 明暗 SVG,Nacos/Redis/MongoDB 节点保持既有语义。验证有数据时焦点/失焦、过滤/排序、行选择/全选、分页、只读及窄宽度。 筛选/排序弹出区域复用共享弹窗按钮、输入框和分段控件,删除原生按钮背景。数据列宽参考 DefaultGridColumnLayout,按表头与前 100 行内容测量,40pt 下限和可用宽度黄金分割上限,按内容比例分配面板剩余空间;不再等分视口。可写连接单击只选择、双击进入单元格编辑,只读只选择不进入编辑;保留上下文菜单和行操作。 数据库树首层文件夹和连接不叠加局部 7pt/4pt 左内边距,只使用共享容器留白和 19pt 层级缩进;核对首层起点、文件夹子连接以及未分组连接。 数据表头依据 primaryKeyColumns 显示原始 16pt columnGoldKey 明暗资源;核对单字段及复合主键、普通列,字段名 id 不作为主键判据。 共享 sidecar 行解码读取实际值类型,SQLite PRAGMA 的 pk/cid/notnull 保持整数而非 unsupported;回归覆盖单字段和复合主键及普通列。 数据网格列宽在内容测量基础上按比例分配面板剩余宽度、移除 full×1.2 上限;拉宽/收窄面板核对铺满与横向滚动。双击可写单元格进入编辑,普通选中保留整格选中色;复用共享 litheFocusEffectHidden 隐藏 macOS 14+ 系统文字焦点框,核对键盘及双击编辑,Git Log 既有行为不变。 数据表删除独立路径/属性工具栏,保留单行共享 30pt 工具栏:左侧原始 16pt/22pt 刷新及编辑动作,右侧 TSV/分页固定,低频原操作合并到 Data Tools,窄宽度左侧横向滚动;连接标签显示数据库/连接名,移除内层表标签。原普通 Text 的单/双击 SwiftUI 手势改为原生 mouseDown 点击计数,第一下同步选择,第二下在可写连接进入编辑,不等待双击判定;已编辑字段不覆盖,右键/Control-click 和滚轮仍交由原控件,核对默认光标、只读、焦点失焦和键盘导航。 每个数据库连接对应一个顶层标签,重复打开去重;切换表仍复用所属连接标签,内层表标签与文字 Data/SQL/Structure/History 栏删除。四个页面使用原始 16pt table/shell/structure/history SVG、共享 22pt 图标按钮及悬停/选中/辅助功能,与表格操作合并为一行;分组分隔项 7pt 槽位,ToolBar.separatorColor 深浅主题色,非 Data 或无表禁用表格操作。验证 Data/Structure/History 页面及独立 SQL 控制台切换、数据库名稳定、多连接标签去重/关闭/恢复、各连接 SQL 草稿和导航位置隔离、表选择及即时单击、全部关闭/休眠清理和窄宽度布局;明暗资源和原动作保留。 工具栏新增/提交/撤销/更多、TSV 和分页复用同一共享按钮状态;核对可用时原色及 hover,禁用时灰色且无 hover,父组禁用与显式参数禁用只降低一次亮度;TSV 保留文字固有宽度。组件检查覆盖深浅主题,实际预览检查只读/可写草稿及撤销后的状态恢复。 行号列使用与数据相同的 13pt 等宽 Regular 字体,普通与当前行文字使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;背景透明,当前行按 SelectionColorLayer 高亮;核对单元格选择、行选择、# 全选、失焦、右键、深浅主题,以及右侧 1pt 网格线。 macOS Data 新增草稿行须立即可见、延续行号并滚动选中,双击编辑后可提交;连续新增与撤销通过原生预览检查,只读连接保持禁用,失败保留草稿。 macOS 连接树只显示保存的连接名和品牌/连接状态图标,移除只读锁;演示名称不含人为追加的类型后缀。 macOS Data 全选后点击单元格清除整行选择,行号仍可逐个取消;可写演示连接单击只选择、双击可编辑,Tables 分类使用普通树文字色,数量维持次要色。 macOS 可写单元格单击立即整格选择、双击进入编辑,编辑保留原背景及内侧 2pt Islands #3871E1 焦点边框;已有行与新增行一致,失焦移除边框,只读不编辑,行号与全选保留草稿。 macOS 异步标签导航:点击连接先同步选中标签,数据刷新完成不重新激活页面;侧栏、Overview、历史恢复和表动作复用同一入口。运行 DatabaseWorkspaceLifecycleTests.delayedConnectionLoadPreservesLaterNavigation,以 TestGate 控制成功/失败加载,核对切到文件或 Overview、关闭单个/全部标签和请求开始前关闭后,标签集合和选择不被迟到结果修改,loading 状态正常结束。 macOS Data 变更背景按 Community MutationsColorLayer 解析:修改蓝色、新增绿色、删除使用 DIFF_DELETED,提交失败覆盖为 Islands Notification.errorBackground 红色;当前行深/浅 RGB 各加/减 0x10,取消整行选择后保留当前行高亮,实际选中单元格覆盖选中色。核对编辑、连续新增、删除标记、失败保留草稿、撤销及深浅主题;每条网格线使用 INDENT_GUIDE_COLOR 深/浅 #323438/#E6E6E6 与 1pt 单边绘制,行列无额外空隙,修改/失败/删除不改变边线或文字透明度。DatabaseGridAppearanceTests 保护动态主题解析与透明度。 macOS Data 透明行号栏使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;普通行号无表头底板;GridRowHeader 横线与 TableResultRowHeader 右侧竖线保留同一 1pt 网格边线,当前行号变亮,选中单元格所在行的其余普通单元格及当前列标题取 GRID_CURRENT_ROW。核对深浅主题、跨行/列点击、键盘移动、全选取消、失焦、双击编辑和新增草稿,变更色及蓝/灰单元格选择保持原优先级。 macOS SQL 控制台:New Query 为每个查询打开独立顶层 console 标签;Data/SQL 切换复用已打开控制台,历史恢复与表 SQL 动作走相同导航入口。无查询结果时编辑器铺满面板,结果出现后使用共享分隔容器,原生行号栏复用普通编辑器;保留补全、格式化、执行选区、风险确认与历史。MySQL/MariaDB 数据库菜单位于控制台右侧,侧栏不显示额外品牌按钮,切库保留查询 ID 和草稿并清理旧结果。DatabaseWorkspaceLifecycleTests.connectionAndConsoleTabsRestoreDraftsAndCloseIndependently 覆盖多查询、跨连接、后台关闭、切库、关闭当前控制台与全部关闭;延迟加载测试增加切到查询后不得抢回标签的场景。 macOS Console 默认 JetBrains Mono 13pt Regular 与 1.2 行距,字号随编辑器设置;浅色注释真实 Italic,其余 token 保持常规,引号内注释标记不误着色。原生测试核对明暗主题、空白编辑视口扩大后仍覆盖、I-beam、当前行号、草稿回传及字体/段落属性。SQL 只读结果复用 Data 自适应列宽、透明行号、1pt 网格和立即选择/键盘/复制;筛选排序、结构编辑及批量/替换/行详情复用共享按钮、输入、选择器和居中弹窗,核对 Esc/取消/标题关闭不提交。Redis/Nacos 输入与工具栏保留原动作;系统确认及文件选择保留原生。远端 Provider 的实际读写仍须有服务验证,不以组件测试代替。 Console 及 Structure 工具栏统一复用 Database 的 30pt 行高;Console 与共享行号使用 TextKit 1 初始化,保护执行结果分栏及标签重建后无需点击即可首次显示草稿。补全候选的原生 delegate 回归确认 SELECT 前缀,不把系统抢占的 Control-Space 快捷键认定为已完成实机验收。 Mac 本地回归增加重名查询列的布局、原生 Data/Console 重建后的表格草稿及后台刷新快照、行号区与编辑区背景连续性、数据库菜单初始选择状态;数据库切换保留现有 MySQL/MariaDB 后端边界。 Mac Console:无焦点时进入/移动空白编辑区显示 I-beam,离开恢复箭头;首次普通点击末行下方任意位置直接聚焦 EOF,清除旧文字选择,不插入虚拟空行,立即输入追加到最后一行;窗口缩放与查询结果分栏后仍保持整块视口命中。数据库下拉的辅助功能报告当前库名,切库保留查询 ID 和草稿。 Mac Console 文字从内容区原点开始,不叠加容器与行片段的水平留白;明暗主题原生图像回归核对空查询和末尾空行输入前后行号基线不变,当前行背景持续覆盖行号栏;Console 行号按 New UI 的 4pt 空注释区、4pt 前置间距及 14pt 最小占位右对齐,核对单个数字的原生绘制位置,测量使用实际 Console 字体。 Mac Console 点击切行时,原生插入光标与当前行背景同步;stillSelecting 为 true 的单个/多个选区入口回归在选择结束通知之前检查行号及背景,保留原生拖选、键盘、输入法和执行选区行为,只重绘旧、新行及行号区域。 Mac Console 按 Community New UI 将分隔线置于行号栏右边缘前 3pt,1pt 线后保留 2pt 编辑背景,文字容器不重复加 padding;明暗主题原生像素检查验证边线及两点背景。 macOS 审查回归:运行 DatabaseWorkspaceLifecycleTests 的 delayedApplyLocksDraftsAndPreservesNavigation、backgroundConnectionManagementPreservesEditorSelection、removingConnectionClosesOwnedTabsAndFallsBack;以有界延迟控制成功/失败提交,核对在途编辑及重复提交被阻止、A→B 和 A→B→A 不被旧提交刷新、后台保存/刷新不改变编辑器选择,删除连接显式移除数据/查询标签并合法回退。原生交互和实际服务验证单独记录。 Mac Data、只读结果、Console 和行号栏结构背景复用全局 editor 调色板,与项目区保持一致;测试遍历全部配色及明暗外观,Console 与普通编辑器一样关闭文本、滚动视口、容器及行号栏的独立底色;在同一原生窗口逐像素比较最终合成背景与侧栏一致,覆盖深浅外观及编辑、缩放、重绘后的当前行状态。WHERE/ORDER BY 空条件沿 INLINE_PARAMETER_HINT 取深/浅 #858A94/#7A7A7A,有条件使用 SQL keyword 色;实际点击筛选/排序与切换面板后颜色不漂移。 Mac Console 数据库选择器在 SQLite 等单库连接下也可展开,使用共享菜单/hover/22pt 按钮和 16pt schema/箭头;当前库初始选中,MySQL/MariaDB 列出当前连接的其他数据库并复用切库,保留查询 ID 和草稿。SQLite 只显示当前文件库,未开放跨连接切换;核对关闭/Esc/禁用、真实名称和 2pt 内容间隔及左右 4pt 留白。 Mac 异步草稿隔离:运行 delayedPageOnlyClearsItsOwnCurrentDrafts,核对成功/失败分页在切表、切连接及后续分页替代后不清空新页面草稿;运行 delayedApplyLocksDraftsAndPreservesNavigation 的 otherConnection 场景,核对后台提交不阻止新连接加载和编辑,旧提交结束不清理新连接草稿,模块仍追踪后台在途写入。 Mac 查询呈现:运行 queryOutputTracksStatementsSelectionAndFailures 与 WorkbenchModuleUIRegistryTests,核对底部 Services/Output、逐语句真实时间/行数/错误、Unicode 与重复语句选区、错误历史、清空日志保留结果,以及执行前按 SQL 子句格式化(保留词大小写,SELECT 内容另起一行并缩进 4 格,FROM 跟随投影)、有原生范围时只改选区并保留重复 SQL、引用及运算符;深浅主题实际打开连接标题的历史窗口,检查 Close/Paste/Esc、只读预览、错误条关闭和 Services 页签切换。SQL 编辑区、历史预览和 Output 共用语法高亮器;querySyntaxColorsProtectQuotedIdentifiersAndLiterals 在明暗主题核对关键词与运算符、指数数字、转义字符串、美元引用、引用标识符和注释,SQL 本文保持不变。原生像素回归核对首列语句左侧 1pt 绿框可见,绘制遵循 BOXED 零外扩并避开 AppKit 裁剪边缘;同窗原生色块校准 HDR 映射,保留确切色值及严格色差断言。格式化后的末尾光标仍位于 EOF。executionMarkersFollowUnchangedStatementsAndExposeStripePositions 核对成功对号、失败图标、右侧 14×2pt 状态条、短文本不拉伸和长文本压缩、Unicode 前置编辑移动范围与语句编辑清除状态;queryCompletionKeepsOnlyUnchangedExecutionMarkers 用 TestGate 控制查询完成,核对在途追加保留状态及修改已执行语句不显示旧标记。右侧标记点击定位,普通滚动和滑块拖动保留共享 CompactScroller;原生明暗主题另验收。共享输出 Copy 复用统一按钮,常态无背景和边框、hover 使用共享圆角底色,禁用只衰减一次,复制内容仍为无 ANSI 的原始输出。Copy 作为原生滚动视图内的真实 NSHostingView 子视图优先命中,仍用共享 SwiftUI 按钮样式;正文排除按钮的 cursor rect,按钮及下层移动事件均保持箭头;copyExcludesNativeTextHitsAndCursorRectsAcrossResize 在深浅主题检查图标、文字、内边距、缩放后命中、文本矩形完整覆盖正文,以及下层 cursorUpdate 事件恢复箭头。回归检查整棵 NSHostingView 视图树命中到真实按钮宿主,覆盖窗口缩放和图标/文字/留白;完整应用 hover 单独检查。multilineStatementOutlineEndsAtFinalLineInsteadOfWidestLine 核对四行 SQL 的绿框在最后一行收窄到实际终点,原生像素验证收窄边线和原最大 X 处背景;沿 Community EditorPainter.paintBorderEffect,保留绿色 token 和 1pt 描边。 审查修复回归:databaseSQLFormatterLeavesSessionDependentEscapesVerbatim 核对 SQLite 普通字符串、E 转义字符串及写入语句含反斜杠时完全保留原文;formattedMiddleSelectionKeepsTheWholeStatement 核对 Unicode 前缀后的中间选区、全选和 EOF 格式化映射;delayedApplyLocksDraftsAndPreservesNavigation 增加 returnToConnection 的成功/失败场景,核对提交结束后补刷并结束 loading、不影响其他连接。真实窗口核对从旧 Console 切到新 Console 后 History Paste 不沿用旧范围。 SQL History 列表与预览之间的分隔条使用现有 highlightsOnHover=false,深浅主题核对 hover、按下及拖动颜色保持常态,同时保留调整光标和宽度拖动。 databaseSQLScannersPreserveCombiningMarksInsideLiterals 覆盖 U+0301/U+FE0F 开头、双引号转义及字面量内分号/危险关键词;格式化保持原值、拆分仍为一条查询。formattedMiddleSelectionKeepsTheWholeStatement 同时覆盖单条替换和整段多语句格式化,选区不包含其他语句,EOF/全选仍正确。databaseTextAndCopyIgnoreLateEventsOverDivider 验证正文、可编辑 Console、只读 History 及 Copy 的延迟 tracking 事件不覆盖真实分隔条光标,移出窗口后不修改光标;正常正文和按钮仍提供各自光标。Copy 的深浅主题实际宿主测试覆盖翻转坐标与缩放。 后台连接保存回归:backgroundSavePreservesCurrentLoading 使用 TestGate 覆盖当前连接空闲、刷新表、打开表及分页,与后台保存成功/失败的两种完成顺序;后台保存不能设置或清除当前页面 Loading,连接弹窗独立显示连接中并防止重复提交。 P1 并发回归:sidebarTableSelectionDuringApplyLoadsLastSelection 经真实 AppModel 入口覆盖提交期间 A→B、A→B→A 与成功/失败,断言旧页清空、最终表数据正确及 Loading 结束;sqlExecutionLockSurvivesConnectionNavigation 覆盖 INSERT/查询、成功/失败及完成前/后返回原连接,验证跨连接 SQL 执行锁、模块忙闲、禁止重复 Run 与完成后释放。 草稿快照与持久日志回归:reselectingFilteredTableDiscardsDraftsBeforeReplacingKeys 覆盖过滤/排序后编辑、重选同表成功/失败,保证旧行索引草稿不再向新快照主键提交;sqlExecutionLockSurvivesConnectionNavigation 重读原连接的持久 History、Execution Events、Audit,覆盖后台成功/失败及部分批次中断,保留实际影响行数且不回填其他连接 UI。 连接目录的多库独立展开与树单选,见 database-multi-schema-tree 能力记录。 macOS 连接和文件夹右键 Move/To Folder:无分组直接输入,已有分组选择目的地、新建后自动移入或移回根层;QA/Quick 创建嵌套路径,验证循环/重名/过期目标不会产生部分变更。名称弹窗与项目新建文件共用 LitheNamePopup,检查 Enter、Esc、错误提示和深浅主题。 macOS 密码保存:新建用户名为空,仅提供直到重启和永久两种范围;验证临时密码不落盘、跨模块实例可读、范围转换先成功更新存储再发布值、写入/删除失败保留旧密码、旧配置缺少范围字段默认永久。测试结果 Copy 点击后关闭、悬停手形。Windows 此保存范围选项未同步,保持 pending。 新连接 Name 默认采用数据库@主机、未填数据库时为@主机,SQLite 使用文件名;手动改名和已有连接名称不被联动覆盖,URI 密码和查询参数不进入名称。 Windows:数据库后端从未接入(backend-capabilities 的 database 为 false,前端调用的数据库命令没有原生实现),工具菜单入口禁用;.db、.sqlite、.duckdb 文件按普通二进制或文本打开,不进入数据库查看器。" + "owner": "Database(官方插件 Lithe-IDEA/lithe-plugin-database)", + "verification": "macOS 从插件市场安装官方插件 dev.lithe.plugin.database(发布者签名),打开 Database 工具窗口:新建、编辑、复制、测试、删除 MySQL、MariaDB、PostgreSQL、SQLite、SQL Server、MongoDB、Redis、Nacos 连接,含 SSH 隧道、TLS、只读和生产保护;永久保存的密码进入 Keychain(服务 <前缀>.plugin-credentials.dev.lithe.plugin.database),“直到重启”的密码不落盘;文件夹的新建、嵌套、移动、重命名和删除,以及从 DBX 导入连接。停用后入口消失、后端进程退出,重新启用或更新后连接和密码仍在;卸载后凭据和 ${stateDir} 一并删除。确认安装包目录没有任何写入。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-crud-export.json b/shared/platform-feature-matrix/features/database-crud-export.json index 1393d6fba..a35f12087 100644 --- a/shared/platform-feature-matrix/features/database-crud-export.json +++ b/shared/platform-feature-matrix/features/database-crud-export.json @@ -2,24 +2,27 @@ "id": "database-crud-export", "area": "数据库", "group": "数据库工作区", - "capability": "表数据编辑、CRUD 与导出", + "capability": "表数据编辑、CRUD 与导出(由官方插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database", - "macos/Sources/Lithe/Views/Database/DatabaseTableView.swift", - "macos/Sources/Lithe/Views/Components/LitheToolWindowStatusText.swift", - "macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/config/backend-capabilities.ts" + "shared/contracts/plugins/v1/README.md" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "验证新增、编辑、删除、事务失败、复制和结果导出。 macOS 中文界面使用同时包含 Name、名称、Type 列的表,检查筛选、排序、批量更新和替换的列名保持原样;替换的 All columns 固定选项保留本地化,选择列后只修改对应列。 macOS 未选表/未选连接时,数据区使用共享 StatusText 操作提示,无大图标或重复标题;结构页同样使用共享提示,检查深浅主题。" + "owner": "Database(官方插件 Lithe-IDEA/lithe-plugin-database)", + "verification": "macOS 从插件市场安装官方插件 dev.lithe.plugin.database(发布者签名),打开 Database 工具窗口:打开表数据,筛选、排序、分页,编辑单元格、新增和删除行后提交与撤销;只读连接和脱敏列不可编辑;生产保护连接上的危险写入弹出确认;通过宿主文件对话框导出 CSV/JSON/SQL、导入 CSV/JSON、备份与恢复 SQL,大结果超过 1 MiB 时提示截断。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-multi-schema-tree.json b/shared/platform-feature-matrix/features/database-multi-schema-tree.json index 139efd7c9..996b51969 100644 --- a/shared/platform-feature-matrix/features/database-multi-schema-tree.json +++ b/shared/platform-feature-matrix/features/database-multi-schema-tree.json @@ -2,26 +2,27 @@ "id": "database-multi-schema-tree", "area": "数据库", "group": "数据库工作区", - "capability": "连接下多数据库独立展开、显示范围选择与树单选", + "capability": "连接下多数据库独立展开与显示范围选择(由官方插件提供)", "macos": { "evidence": [ - "macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift", - "macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift", - "macos/Sources/Lithe/Views/Components/LitheTreeCheckbox.swift", - "macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift", - ".agents/notes/implemented/feature/2026-10-22-database-multi-schema-tree.md", - "macos/Sources/Lithe/Theme/DatabaseBrandIcon.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/database/components/table-sidebar.tsx" + "shared/contracts/plugins/v1/README.md" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "macOS:在分组中放置 MySQL 与 SQLite 连接,展开 MySQL/MariaDB 后同时展开两个账号可见的数据库,文件夹/连接/MySQL 数据库/SQLite 默认库/Tables 分组名称单击只选择、反复双击收缩后再次双击展开,独立箭头仍单击切换;表名双击打开数据而不展开结构,未分组连接直接显示在根层而无额外分区标题,保留空白落点和移回根目录菜单;检查同名表上下文、独立折叠/刷新/失败重试;依次点击连接、数据库、表,每次只能高亮被点击的一行,展开箭头不增加选择。执行 DatabaseWorkspaceLifecycleTests/databaseTree、LitheTreeRowStyleTests,并完成真实数据库完整工作区深浅主题验收。刷新按选中连接/库/表限定元数据范围,连接 Diagnostics/Force Refresh 清缓存重载,刷新不查询记录或移动编辑器;检查数据/结构空状态使用共享状态文字。Windows 尚未接入此多库树。 表节点:单击只选择,箭头立即展开并显示 loading;用受控慢 describeTable 验证展开不发 pageTable、不切换编辑器、折叠后迟到结果不重开,双击/打开数据编辑器与树共享结构缓存和请求,失败可重试。 点击 MySQL/MariaDB 连接旁 N of M,在共享搜索/复选框弹层中选择库后 Enter 或点击外部应用,仅显示已选库;Esc 不保存草稿;弹层不显示标题和 Apply/Cancel 按钮,输入时出现搜索,连接徽标在中文界面仍为紧凑 N of M 而非序号句,深浅主题检查 24pt 行内徽标边框与悬停、名称间距,悬停徽标显示小手但无文字提示;范围弹框内部使用普通箭头,复用 Git 提升的 IDEA 原版树复选框与共享树行/缩进/背景/悬停色,保留高列表空白区及底部说明,长库名单行省略,行内空白可点击;设置页复选框保持原行为;全选包含以后新增的库,空选保持 0 of M,展开后显示小号灰色“未选择架构”且无省略号按钮;连接名称与徽标右侧空白均可反复双击展开/折叠,零选库也一样,徽标点击仍只打开范围列表;重启后保留选择,连接级刷新不扫描已隐藏库,范围变更不读密码或切换编辑器。右键侧栏空白区及无连接空状态可新建连接/分组、导入 DBX、刷新所选结构和全部折叠,节点右键菜单仍可独立使用。 点击工具栏带右下角下拉标记的+,检查统一菜单中的查询控制台、数据源子菜单、分组、导入数据源;数据源列出全部已支持引擎及品牌图标,选择 SQLite 直接打开文件路径表单,取消不保存,选择其他引擎采用相应端口与账号默认值;空白区 New 复用同一菜单,尚未实现的云数据源/模板/驱动管理不显示。 新建菜单数据源按用户指定复用右侧数据库工具窗口的 dbms 圆柱图标,其他菜单图标核对 Community 原图:七种已有数据库使用 providers 图标,Nacos 使用通用 settings 深浅图标,不使用第三方 PNG 或 SF Symbols 备用图标;连接编辑器采用固定名称区、常规/SSH TLS/安全与标记分页及固定操作栏,保留原有引擎字段。 新建菜单前 3 项连续使用共享 24pt 行高,仅导入数据源前保留分隔组,避免每项叠加 11pt 分组留白。 连接草稿测试复用已有 sidecar testConnection,不保存配置或密码、不切换当前连接、不改变连接状态;成功、失败、取消及模块释放均由受控端口回归。测试连接为主题色纯文字,hover 下划线同色,原生深浅主题核对分页与按钮。 成功检测自动显示共享上浮层,绿色成功标题、复制入口、引擎和真实检测耗时;底栏绿色对号和引擎可重新展开/收起,修改参数或关闭编辑器清理浮层。接口不提供的版本/驱动/实际 TLS 信息不展示,耗时不冒称 Ping。 成功气泡纠正菜单背景:Community c7f91397 的 locale 字体分支(Inter 13 regular;CJK 的 macOS Helvetica Neue 13)、4pt 圆角和14×10pt底部指针,ManyIslands dialog surface/popup border独立色值;标题按截图加粗,复制跟随主题强调色。正文保留普通字重,不虚构 Database Tools 私有源码可得性。 连接表单复用共享 4pt 圆角输入框,用户名/密码与 Host 等宽;检测耗时跟随界面语言;结果浮层关闭原生阴影外沿,普通菜单不变。 Copy 悬停手形、点击复制并关闭结果;新建用户名为空。密码保存仅提供直到重启(进程内存)和永久(现有安全存储),范围转换删除旧存储、保存失败恢复旧值与范围,旧配置兼容永久。 成功结果浮层使用 500ms 透明度淡入,减少动态效果时立即展示;关闭不等待动画。 Commit 对话框共享输入样式也复用 4pt 圆角 chrome,覆盖既有 Git 与数据库调用点。" + "owner": "Database(官方插件 Lithe-IDEA/lithe-plugin-database)", + "verification": "macOS 从插件市场安装官方插件 dev.lithe.plugin.database(发布者签名),打开 Database 工具窗口:展开 MySQL/MariaDB 连接后同时展开多个数据库,各自折叠、刷新和失败重试;用“Databases Shown…”只显示选中的库,选择随连接保存;表、视图、例程、触发器、序列分组按需加载。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-plugin-migration.json b/shared/platform-feature-matrix/features/database-plugin-migration.json new file mode 100644 index 000000000..aa7cca745 --- /dev/null +++ b/shared/platform-feature-matrix/features/database-plugin-migration.json @@ -0,0 +1,27 @@ +{ + "id": "database-plugin-migration", + "area": "数据库", + "group": "数据库工作区", + "capability": "内置数据库模块的连接迁入插件与安装提示", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift", + "macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift", + "macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift", + "macos/Tests/LitheTests/WorkbenchNotificationTests.swift", + "macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "owner": "Database", + "verification": "macOS:用保存过数据库连接(含文件夹、旧 group 字段、SQL 历史、永久和“直到重启”密码)的旧版本升级。首次启动出现一次“数据库已改为插件”通知,点击“打开插件市场”进入搜索 Database 的市场页,通知在点击前不自动消失,之后不再出现;已安装插件或从未保存连接的用户不出现。安装插件并首次打开后,连接、文件夹(旧 group 变为顶层文件夹)、属于现存连接的最近 100 条历史和永久密码全部可用,“直到重启”的密码需要重新输入;Keychain 锁定时迁移失败的密码只记录日志、连接改为询问密码。插件已有 connections.json 时不覆盖;迁移只执行一次,旧数据保留供旧版本读取。Windows 没有内置数据库模块,无需迁移。" +} diff --git a/shared/platform-feature-matrix/features/database-query-history.json b/shared/platform-feature-matrix/features/database-query-history.json index b47f9693c..485de68cf 100644 --- a/shared/platform-feature-matrix/features/database-query-history.json +++ b/shared/platform-feature-matrix/features/database-query-history.json @@ -2,24 +2,27 @@ "id": "database-query-history", "area": "数据库", "group": "数据库工作区", - "capability": "SQL 查询、历史与分页", + "capability": "SQL 查询、历史与分页(由官方插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database", - "macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift", - "rust/lithe-db-sidecar/src/main.rs", - "macos/Tests/LitheTests/LitheCoreLogicTests.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/config/backend-capabilities.ts" + "shared/contracts/plugins/v1/README.md" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。 macOS 生产保护连接:带 WHERE 的 UPDATE/DELETE(WHERE 换行或缩进)直接执行不弹窗;无 WHERE、WHERE 只在子查询/注释/字符串里、WITH … DELETE、DROP/TRUNCATE(含 ALTER … DROP)在第一条语句执行前弹出确认框,确认后整批执行一次;SELECT … FOR UPDATE 不弹窗。危险判定以数据库 helper(lithe-db-sidecar)为准,执行 cargo test -p lithe-db-sidecar write_protection 与 LitheCoreLogicTests/databaseSQLConfirmation。" + "owner": "Database(官方插件 Lithe-IDEA/lithe-plugin-database)", + "verification": "macOS 从插件市场安装官方插件 dev.lithe.plugin.database(发布者签名),打开 Database 工具窗口:在查询控制台执行单条和多条语句,取消正在执行的语句,查看逐语句结果、耗时、行数和错误;只读连接拒绝写入语句(包括以查询或导出形式发送的写入);不带 WHERE 的 UPDATE/DELETE 等危险语句在生产保护连接上先确认;SQL 历史保留最近 100 条并可恢复到所属连接。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-schema.json b/shared/platform-feature-matrix/features/database-schema.json index 0f09c024e..cb2659028 100644 --- a/shared/platform-feature-matrix/features/database-schema.json +++ b/shared/platform-feature-matrix/features/database-schema.json @@ -2,21 +2,27 @@ "id": "database-schema", "area": "数据库", "group": "数据库工作区", - "capability": "Schema、表与列浏览", + "capability": "Schema、表与列浏览及结构编辑(由官方插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/config/backend-capabilities.ts" + "shared/contracts/plugins/v1/README.md" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "展开数据库、Schema、表和列,验证刷新、筛选和空结果展示。" + "owner": "Database(官方插件 Lithe-IDEA/lithe-plugin-database)", + "verification": "macOS 从插件市场安装官方插件 dev.lithe.plugin.database(发布者签名),打开 Database 工具窗口:浏览 schema、表、列、索引、外键和 DDL;新增、修改、删除列与索引,生产保护连接上的结构变更先确认;MongoDB 集合、Redis Key、Nacos 配置与服务各自的浏览和编辑页面可用。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json index 73233b922..c6369b915 100644 --- a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json +++ b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json @@ -15,7 +15,6 @@ "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift", "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", - "macos/Sources/Lithe/Views/Database/DatabaseTableView.swift", "macos/Sources/Lithe/Views/Debug/GenericDebugView.swift", "macos/Tests/LitheTests/ContextMenuCoverageTests.swift", "macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift", diff --git a/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json b/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json index 9b8e9c8ab..b636000fe 100644 --- a/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json +++ b/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json @@ -12,8 +12,7 @@ "macos/Tests/LitheTests/LitheTreeRowStyleTests.swift", "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", "macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift", - ".agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md", - "macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift" + ".agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md" ] }, "windows": { diff --git a/third_party/dbx/manifest.json b/third_party/dbx/manifest.json deleted file mode 100644 index 312b6d06f..000000000 --- a/third_party/dbx/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "schemaVersion": 1, - "name": "DBX", - "upstreamRepository": "https://github.com/t8y2/dbx", - "revision": "996ce42e80387bba4b33a2bf1713f590ef79d476", - "sourceArchiveURL": "https://github.com/t8y2/dbx/archive/996ce42e80387bba4b33a2bf1713f590ef79d476.tar.gz", - "usage": "reference-only", - "buildDependency": false, - "runtimeDependency": false, - "packaged": false, - "retainedArtifacts": [ - { - "path": "macos/Resources/DatabaseIcons", - "notice": "macos/Resources/DatabaseIcons/NOTICE.txt", - "license": "macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt" - } - ] -} diff --git a/windows/tauri/src/config/backend-capabilities.ts b/windows/tauri/src/config/backend-capabilities.ts index 5f3f351a9..0434190b9 100644 --- a/windows/tauri/src/config/backend-capabilities.ts +++ b/windows/tauri/src/config/backend-capabilities.ts @@ -6,7 +6,6 @@ export const backendCapabilities = { // that never had a Windows backend and is removed by #957. agent: true, aiChat: false, - database: false, debugger: true, docker: false, extensions: true, diff --git a/windows/tauri/src/extensions/languages/language-packager.ts b/windows/tauri/src/extensions/languages/language-packager.ts index 3b53c8fc4..8bff7ccbd 100644 --- a/windows/tauri/src/extensions/languages/language-packager.ts +++ b/windows/tauri/src/extensions/languages/language-packager.ts @@ -75,7 +75,6 @@ function toExtensionCategories(rawCategories: string[] | undefined): ExtensionCa return rawCategories.map((category) => { const normalized = category.trim().toLowerCase(); if (normalized === "language") return "Language"; - if (normalized === "database") return "Database"; if (normalized === "icon theme" || normalized === "icon-theme" || normalized === "icontheme") { return "Icon Theme"; } diff --git a/windows/tauri/src/extensions/marketplace/marketplace-extensions.ts b/windows/tauri/src/extensions/marketplace/marketplace-extensions.ts index 8b942f9ca..bcffb755f 100644 --- a/windows/tauri/src/extensions/marketplace/marketplace-extensions.ts +++ b/windows/tauri/src/extensions/marketplace/marketplace-extensions.ts @@ -2,7 +2,6 @@ import type { ExtensionCategory, ExtensionManifest } from "../types/extension-ma import { filterRetiredExtensions } from "../registry/retired-extensions"; import { getManifestAIProviderContributions, - getManifestDatabaseContributions, getManifestIconContributions, getManifestIntegrationContributions, } from "../types/extension-contributions"; @@ -37,7 +36,6 @@ function toExtensionCategories(rawCategories: string[] | undefined): ExtensionCa return rawCategories.map((category) => { const normalized = category.trim().toLowerCase(); - if (normalized === "database") return "Database"; if (normalized === "ai") return "AI"; if (normalized === "integration") return "Integration"; if (normalized === "agent") return "Agent"; @@ -57,7 +55,6 @@ function toExtensionCategories(rawCategories: string[] | undefined): ExtensionCa function isContributionExtension(manifest: ExtensionManifest): boolean { return Boolean( - getManifestDatabaseContributions(manifest).length || manifest.agents?.length || manifest.contributes?.agents?.length || getManifestAIProviderContributions(manifest).length || diff --git a/windows/tauri/src/extensions/tooling/build-extensions-index.ts b/windows/tauri/src/extensions/tooling/build-extensions-index.ts index 3091e7bba..808d36839 100644 --- a/windows/tauri/src/extensions/tooling/build-extensions-index.ts +++ b/windows/tauri/src/extensions/tooling/build-extensions-index.ts @@ -53,7 +53,7 @@ type IndexEntry = { description: string; version: string; author: string; - category: "Languages" | "Themes" | "Icon Themes" | "Databases" | "Agents" | "Integrations"; + category: "Languages" | "Themes" | "Icon Themes" | "Agents" | "Integrations"; icon: string; manifestUrl: string; downloads: number; @@ -70,7 +70,6 @@ function normalizeIndexCategory(raw?: string): IndexEntry["category"] { const value = (raw ?? "").toLowerCase().replace(/[_-]+/g, " ").trim(); if (value === "icon" || value === "icon theme" || value === "icon themes") return "Icon Themes"; - if (value === "database" || value === "databases") return "Databases"; if (value === "agent" || value === "agents") return "Agents"; if (value === "integration" || value === "integrations") return "Integrations"; if (value === "theme" || value === "themes") return "Themes"; @@ -80,7 +79,6 @@ function normalizeIndexCategory(raw?: string): IndexEntry["category"] { function normalizeRegistryCategory(raw?: string): string { const normalized = (raw ?? "").toLowerCase(); if (normalized.includes("icon")) return "icon-theme"; - if (normalized.includes("database")) return "database"; if (normalized.includes("agent")) return "agent"; if (normalized.includes("integration")) return "integration"; if (normalized.includes("theme")) return "theme"; @@ -118,7 +116,6 @@ async function buildCatalog() { } const languages = getContributionArray(manifest, "languages"); - const databases = getContributionArray(manifest, "databases"); const agents = getContributionArray(manifest, "agents"); const themes = getContributionArray(manifest, "themes"); const icons = getContributionArray(manifest, "icons"); @@ -133,7 +130,6 @@ async function buildCatalog() { if ( languages.length === 0 && - databases.length === 0 && agents.length === 0 && themes.length === 0 && icons.length === 0 && diff --git a/windows/tauri/src/extensions/tooling/extension-workspace.ts b/windows/tauri/src/extensions/tooling/extension-workspace.ts index f58b60a5c..000593f6b 100644 --- a/windows/tauri/src/extensions/tooling/extension-workspace.ts +++ b/windows/tauri/src/extensions/tooling/extension-workspace.ts @@ -10,8 +10,6 @@ export const GENERATED_CDN_DIR = join(EXTENSIONS_ROOT, "generated", "cdn"); export const CATALOG_DIR = join(EXTENSION_DOMAIN_ROOT, "catalog"); const CONTRIBUTION_ALIASES: Record = { - databases: ["databases", "databaseProviders"], - databaseProviders: ["databases", "databaseProviders"], icons: ["icons", "iconThemes"], iconThemes: ["icons", "iconThemes"], }; @@ -97,7 +95,6 @@ export function getExtensionSourceDir(folder: string): string { export function getExtensionCdnPath(folder: string, manifest: ExtensionManifestRecord): string { const slug = basename(folder); - const databases = getContributionArray(manifest, "databases"); const agents = getContributionArray(manifest, "agents"); const themes = getContributionArray(manifest, "themes"); const icons = getContributionArray(manifest, "icons"); @@ -107,10 +104,6 @@ export function getExtensionCdnPath(folder: string, manifest: ExtensionManifestR return `integration/${integrations[0].id}`; } - if (databases.length > 0 && typeof databases[0].id === "string") { - return `database/${databases[0].id}`; - } - if (agents.length > 0 && typeof agents[0].id === "string") { return `agents/${agents[0].id}`; } diff --git a/windows/tauri/src/extensions/tooling/package-database-sidecars.ts b/windows/tauri/src/extensions/tooling/package-database-sidecars.ts deleted file mode 100644 index 6fba3796b..000000000 --- a/windows/tauri/src/extensions/tooling/package-database-sidecars.ts +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env bun - -import { $ } from "bun"; -import { createHash } from "node:crypto"; -import { chmod, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { requireExtensionsCdnBaseUrl } from "./cdn-config"; -import { - LITHE_ROOT, - getContributionArray, - getExtensionCdnPath, - getExtensionSourceDir, - getGeneratedCdnPath, - listExtensionFolders, - writeExtensionManifest, - writeStableTarGz, -} from "./extension-workspace"; - -const cdnBaseUrl = requireExtensionsCdnBaseUrl(); - -function argValue(name: string) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -function currentPlatformArch() { - const os = - process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux"; - const arch = process.arch === "arm64" ? "arm64" : "x64"; - return `${os}-${arch}`; -} - -async function sha256(path: string) { - const bytes = await readFile(path); - return createHash("sha256").update(bytes).digest("hex"); -} - -function hasCompletePackageInfo(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const entry = value as Record; - return ( - typeof entry.downloadUrl === "string" && - entry.downloadUrl.length > 0 && - typeof entry.size === "number" && - entry.size > 0 && - typeof entry.checksum === "string" && - entry.checksum.length > 0 - ); -} - -async function createPackage(params: { - extensionDir: string; - manifest: Record; - sidecarPath: string; - binaryPath: string; - packagePath: string; -}) { - const tempDir = await mkdtemp(join(tmpdir(), "lithe-db-extension-")); - - try { - await $`rsync -az --exclude='.DS_Store' ${params.extensionDir}/ ${tempDir}/`; - - const packagedManifest = { ...params.manifest }; - delete packagedManifest.installation; - await writeFile( - join(tempDir, "extension.json"), - `${JSON.stringify(packagedManifest, null, 2)}\n`, - ); - - const targetBinary = join(tempDir, params.sidecarPath); - await mkdir(dirname(targetBinary), { recursive: true }); - await cp(params.binaryPath, targetBinary); - await chmod(targetBinary, 0o755); - - await mkdir(dirname(params.packagePath), { recursive: true }); - await writeStableTarGz(tempDir, params.packagePath); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -} - -async function findDatabaseExtensionFolders(providerFilter?: string) { - const databaseFolders: Array<{ - folder: string; - manifest: Record; - provider: Record; - }> = []; - - for (const folder of await listExtensionFolders()) { - const manifestPath = join(getExtensionSourceDir(folder), "extension.json"); - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record; - const provider = getContributionArray(manifest, "databases")[0]; - if (!provider) continue; - if (providerFilter && provider.id !== providerFilter) continue; - databaseFolders.push({ folder, manifest, provider }); - } - - if (providerFilter && databaseFolders.length === 0) { - throw new Error(`Unknown database provider: ${providerFilter}`); - } - - return databaseFolders; -} - -const platformArch = argValue("--platform") || process.env.PLATFORM_ARCH || currentPlatformArch(); -const shouldBuild = process.argv.includes("--build") || process.env.BUILD_DATABASE_SIDECARS === "1"; -const requestedBinDir = argValue("--bin-dir") || process.env.LITHE_DATABASE_SIDECAR_BIN_DIR; -const requestedBuildTargetDir = - argValue("--target-dir") || process.env.LITHE_DATABASE_SIDECAR_TARGET_DIR; -const providerFilter = argValue("--provider"); -let packagedCount = 0; - -if (shouldBuild && requestedBinDir) { - throw new Error("--bin-dir cannot be used with --build. Use --target-dir instead."); -} - -const temporaryBuildTargetDir = - shouldBuild && !requestedBuildTargetDir - ? await mkdtemp(join(tmpdir(), "lithe-db-sidecars-")) - : undefined; -const buildTargetDir = shouldBuild - ? requestedBuildTargetDir - ? resolve(LITHE_ROOT, requestedBuildTargetDir) - : temporaryBuildTargetDir - : undefined; -const binDir = buildTargetDir - ? join(buildTargetDir, "release") - : resolve(LITHE_ROOT, requestedBinDir || "target/release"); - -async function buildSidecar(providerId: string, binaryName: string) { - if (!buildTargetDir) { - throw new Error("Database sidecar build target is not configured."); - } - - await $`cargo build -p lithe-database --release --no-default-features --features ${providerId} --bin ${binaryName} --target-dir ${buildTargetDir}`.cwd( - LITHE_ROOT, - ); -} - -try { - for (const { folder, manifest, provider } of await findDatabaseExtensionFolders(providerFilter)) { - const extensionDir = getExtensionSourceDir(folder); - const manifestPath = join(extensionDir, "extension.json"); - const sidecar = provider.sidecar as Record | undefined; - const sidecarPath = sidecar?.[platformArch]; - const providerId = String(provider.id); - - if (!sidecarPath) { - throw new Error(`Database extension ${providerId} has no sidecar for ${platformArch}`); - } - - const binaryPath = join(binDir, basename(sidecarPath)); - if (shouldBuild) { - await buildSidecar(providerId, basename(sidecarPath)); - } - - if ( - !(await stat(binaryPath) - .then((value) => value.isFile()) - .catch(() => false)) - ) { - throw new Error( - `Missing database sidecar binary for ${providerId}: ${binaryPath}. Run this script with --build, or build it from the Lithe repo with: cargo build -p lithe-database --release --no-default-features --features ${providerId} --bin ${basename(sidecarPath)}`, - ); - } - - const cdnPath = getExtensionCdnPath(folder, manifest); - const packagePath = getGeneratedCdnPath(join(cdnPath, `${platformArch}.tar.gz`)); - await createPackage({ extensionDir, manifest, sidecarPath, binaryPath, packagePath }); - - const packageStats = await stat(packagePath); - const packageInfo = { - downloadUrl: `${cdnBaseUrl}/${cdnPath}/${platformArch}.tar.gz`, - size: packageStats.size, - checksum: await sha256(packagePath), - }; - - const installation = (manifest.installation ?? {}) as Record; - const platformPackages = Object.fromEntries( - Object.entries((installation.platformArch ?? {}) as Record).filter( - ([, value]) => hasCompletePackageInfo(value), - ), - ); - platformPackages[platformArch] = packageInfo; - installation.platformArch = platformPackages; - installation.downloadUrl = packageInfo.downloadUrl; - installation.size = packageInfo.size; - installation.checksum = packageInfo.checksum; - manifest.installation = installation; - - await writeExtensionManifest(manifestPath, manifest); - packagedCount += 1; - } -} finally { - if (temporaryBuildTargetDir) { - await rm(temporaryBuildTargetDir, { recursive: true, force: true }); - } -} - -console.log(`Packaged ${packagedCount} database sidecar extension(s) for ${platformArch}.`); diff --git a/windows/tauri/src/extensions/tooling/package-extensions.ts b/windows/tauri/src/extensions/tooling/package-extensions.ts index f33a67ce4..3e8f68df6 100644 --- a/windows/tauri/src/extensions/tooling/package-extensions.ts +++ b/windows/tauri/src/extensions/tooling/package-extensions.ts @@ -19,7 +19,6 @@ import { const cdnBaseUrl = requireExtensionsCdnBaseUrl(); function shouldPackage(manifest: Record) { - const hasNativeSidecar = getContributionArray(manifest, "databases").length > 0; const isLanguage = getContributionArray(manifest, "languages").length > 0; const isPureAssetExtension = getContributionArray(manifest, "themes").length > 0 || @@ -27,7 +26,7 @@ function shouldPackage(manifest: Record) { const isExecutableIntegration = getContributionArray(manifest, "integrations").length > 0 && typeof manifest.main === "string"; - return (isPureAssetExtension || isExecutableIntegration) && !hasNativeSidecar && !isLanguage; + return (isPureAssetExtension || isExecutableIntegration) && !isLanguage; } async function sha256(path: string) { diff --git a/windows/tauri/src/extensions/tooling/validate.ts b/windows/tauri/src/extensions/tooling/validate.ts index 65408ad4e..c69f740c7 100644 --- a/windows/tauri/src/extensions/tooling/validate.ts +++ b/windows/tauri/src/extensions/tooling/validate.ts @@ -150,7 +150,6 @@ async function validateInstallPackage( } | undefined; const requiresPackage = - getContributionArray(manifest, "databases").length > 0 || getContributionArray(manifest, "themes").length > 0 || getContributionArray(manifest, "icons").length > 0 || getContributionArray(manifest, "integrations").length > 0; @@ -273,7 +272,6 @@ async function validateExtension(folder: string): Promise { const contributionCount = getContributionArray(manifest, "languages").length + - getContributionArray(manifest, "databases").length + getContributionArray(manifest, "agents").length + getContributionArray(manifest, "themes").length + getContributionArray(manifest, "icons").length + @@ -297,16 +295,6 @@ async function validateExtension(folder: string): Promise { } } - for (const provider of getContributionArray(manifest, "databases")) { - if (!provider.id) error(folder, "Database entry missing 'id'"); - if (!provider.protocolVersion) { - error(folder, `Database '${provider.id}' missing 'protocolVersion'`); - } - if (!provider.sidecar || typeof provider.sidecar !== "object") { - error(folder, `Database '${provider.id}' missing 'sidecar' map`); - } - } - for (const agent of getContributionArray(manifest, "agents")) { if (!agent.id) error(folder, "Agent contribution missing 'id'"); if (!agent.name) error(folder, `Agent '${agent.id}' missing 'name'`); diff --git a/windows/tauri/src/extensions/types/extension-contributions.ts b/windows/tauri/src/extensions/types/extension-contributions.ts index 74ef97ca7..2b644f7da 100644 --- a/windows/tauri/src/extensions/types/extension-contributions.ts +++ b/windows/tauri/src/extensions/types/extension-contributions.ts @@ -1,6 +1,5 @@ import type { CommandContribution, - DatabaseProviderContribution, ExtensionManifest, AIProviderContribution, IconThemeContribution, @@ -66,17 +65,6 @@ function getManifestSnippetContributions(manifest: ExtensionManifest): SnippetCo return [...(manifest.snippets || []), ...(manifest.contributes?.snippets || [])]; } -export function getManifestDatabaseContributions( - manifest: ExtensionManifest, -): DatabaseProviderContribution[] { - return [ - ...(manifest.databases || []), - ...(manifest.databaseProviders || []), - ...(manifest.contributes?.databases || []), - ...(manifest.contributes?.databaseProviders || []), - ]; -} - export function getManifestAIProviderContributions( manifest: ExtensionManifest, ): AIProviderContribution[] { diff --git a/windows/tauri/src/extensions/types/extension-manifest.ts b/windows/tauri/src/extensions/types/extension-manifest.ts index a9c56037e..5d9aa8170 100644 --- a/windows/tauri/src/extensions/types/extension-manifest.ts +++ b/windows/tauri/src/extensions/types/extension-manifest.ts @@ -47,10 +47,6 @@ export interface ExtensionManifest { // Language support languages?: LanguageContribution[]; - // Database provider sidecars - databases?: DatabaseProviderContribution[]; - databaseProviders?: DatabaseProviderContribution[]; - // ACP agent contributions agents?: AgentContribution[]; @@ -132,7 +128,6 @@ export interface ExtensionManifest { export type ExtensionCategory = | "Language" - | "Database" | "AI" | "Integration" | "Agent" @@ -186,26 +181,6 @@ export interface LspConfiguration { capabilities?: Record; } -interface PlatformArchExecutable { - "darwin-arm64"?: string; - "darwin-x64"?: string; - "linux-x64"?: string; - "linux-arm64"?: string; - "win32-x64"?: string; -} - -export type DatabaseProviderId = "sqlite" | "duckdb" | "postgres" | "mysql" | "mongodb" | "redis"; - -export interface DatabaseProviderContribution { - id: DatabaseProviderId; - label: string; - isFileBased: boolean; - protocolVersion: number; - defaultPort?: number; - fileExtensions?: string[]; - sidecar: PlatformArchExecutable; -} - interface AgentContribution { id: string; name: string; @@ -479,8 +454,6 @@ export interface PlatformPackage { export interface UIContributions { languages?: LanguageContribution[]; - databases?: DatabaseProviderContribution[]; - databaseProviders?: DatabaseProviderContribution[]; agents?: AgentContribution[]; aiProviders?: AIProviderContribution[]; integrations?: IntegrationContribution[]; diff --git a/windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx b/windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx index 618939c74..2bda43336 100644 --- a/windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx +++ b/windows/tauri/src/extensions/ui/components/extensions-sidebar.tsx @@ -4,7 +4,6 @@ import { ArrowCounterClockwiseIcon as Reset, BrainIcon as Brain, CheckIcon as Check, - DatabaseIcon as Database, DownloadSimpleIcon as Download, PackageIcon as Package, PaintBrushIcon as PaintBrush, @@ -37,7 +36,6 @@ import { themeRegistry } from "@/extensions/themes/theme-registry"; import { DynamicIcon } from "@/extensions/ui/components/dynamic-icon"; import { getManifestAIProviderContributions, - getManifestDatabaseContributions, getManifestIconContributions, getManifestIntegrationContributions, getManifestThemeContributions, @@ -77,7 +75,6 @@ interface UnifiedExtension { | "language" | "theme" | "icon-theme" - | "database" | "ai" | "integration" | "skill" @@ -115,7 +112,6 @@ const FILTER_TABS = [ { id: "language", labelKey: "extensions.languages", icon: TextT }, { id: "theme", labelKey: "extensions.themes", icon: PaintBrush }, { id: "icon-theme", labelKey: "extensions.iconThemes", icon: Package }, - { id: "database", labelKey: "extensions.databases", icon: Database }, { id: "ai", labelKey: "extensions.ai", icon: Sparkles }, { id: "integration", labelKey: "extensions.integrations", icon: PlugsConnected }, { id: "skill", labelKey: "extensions.skills", icon: Brain }, @@ -195,10 +191,6 @@ const LOCAL_ICON_ALIASES: Record = { const SIMPLE_ICON_COLOR = "8B8F99"; -function isBuiltInDatabaseProvider(providerId: string): boolean { - return providerId === "sqlite"; -} - function resolvePackageSize(manifest: { installation?: { size?: number; @@ -365,8 +357,6 @@ function getCategoryIcon(category: UnifiedExtension["category"]): ReactNode { return ; case "icon-theme": return ; - case "database": - return ; case "ai": return ; case "integration": @@ -674,34 +664,6 @@ export const ExtensionsSidebar = ({ }); } - const databaseContributions = getManifestDatabaseContributions(ext.manifest); - if (databaseContributions.length > 0) { - const provider = databaseContributions[0]; - const isBuiltInDatabase = isBuiltInDatabaseProvider(provider.id); - allExtensions.push({ - id: ext.manifest.id, - name: ext.manifest.displayName, - description: ext.manifest.description, - category: "database", - isInstalled: ext.isInstalled, - isEnabled: ext.isEnabled, - version: ext.manifest.version, - extensions: provider.fileExtensions?.map((item) => item.replace(".", "")), - publisher: ext.manifest.publisher, - isMarketplace: !isBuiltInDatabase, - isBundled: isBuiltInDatabase, - icon: resolveManifestIcon( - ext.manifest.icon, - provider.id, - provider.label, - ext.manifest.displayName, - ), - runtimeIssues: ext.runtimeIssues, - packageSize: resolvePackageSize(ext.manifest), - contributionSummary: [`database:${provider.id}`], - }); - } - const themeContributions = getManifestThemeContributions(ext.manifest); if (themeContributions.length > 0) { const themeIds = themeContributions.map((theme) => theme.id); diff --git a/windows/tauri/src/features/ai/components/input/chat-input-bar.tsx b/windows/tauri/src/features/ai/components/input/chat-input-bar.tsx index 6c421705c..65ec9f5d7 100644 --- a/windows/tauri/src/features/ai/components/input/chat-input-bar.tsx +++ b/windows/tauri/src/features/ai/components/input/chat-input-bar.tsx @@ -1,7 +1,6 @@ import { CommandIcon, ArrowUpIcon as ArrowUp, - DatabaseIcon as Database, FileTextIcon as FileText, MicrophoneIcon as Mic, StopIcon as Stop, @@ -430,7 +429,6 @@ const AIChatInputBar = memo(function AIChatInputBar({ type: "buffer" as const, id: buffer.id, name: buffer.name, - databaseType: buffer.type === "database" ? buffer.databaseType : undefined, isDirty: buffer.type === "editor" && buffer.isDirty, })); @@ -1228,15 +1226,7 @@ const AIChatInputBar = memo(function AIChatInputBar({ }} > - {item.type === "buffer" ? ( - item.databaseType ? ( - - ) : ( - - ) - ) : ( - - )} + diff --git a/windows/tauri/src/features/ai/components/messages/tool-call-display.tsx b/windows/tauri/src/features/ai/components/messages/tool-call-display.tsx index caa413028..d138c5f4b 100644 --- a/windows/tauri/src/features/ai/components/messages/tool-call-display.tsx +++ b/windows/tauri/src/features/ai/components/messages/tool-call-display.tsx @@ -143,7 +143,6 @@ async function openToolDiff(path: string, output: unknown) { displayName, "", false, - undefined, true, true, diff, diff --git a/windows/tauri/src/features/ai/components/selectors/context-selector.tsx b/windows/tauri/src/features/ai/components/selectors/context-selector.tsx index d975ebbca..36e6be626 100644 --- a/windows/tauri/src/features/ai/components/selectors/context-selector.tsx +++ b/windows/tauri/src/features/ai/components/selectors/context-selector.tsx @@ -1,5 +1,4 @@ import { - DatabaseIcon as Database, FileTextIcon as FileText, GitPullRequestIcon as GitPullRequest, GlobeIcon as Globe, @@ -26,7 +25,6 @@ import type { PaneContent } from "@/features/panes/types/pane-content.types"; function getBufferContextDescription(buffer: PaneContent, t: (key: string, values?: Record) => string) { if (buffer.type === "webViewer") return buffer.url; if (buffer.type === "terminal") return buffer.workingDirectory || t("ai.terminal"); - if (buffer.type === "database") return t("ai.databaseContext", { type: buffer.databaseType }); if (buffer.type === "pullRequest") return t("ai.pullRequestNumber", { number: buffer.prNumber }); if (buffer.type === "githubIssue") return `Issue #${buffer.issueNumber}`; if (buffer.type === "githubAction") return t("ai.actionRunNumber", { number: buffer.runId }); @@ -36,7 +34,6 @@ function getBufferContextDescription(buffer: PaneContent, t: (key: string, value function getBufferContextIcon(buffer: PaneContent) { if (buffer.type === "webViewer") return ; if (buffer.type === "terminal") return ; - if (buffer.type === "database") return ; if (buffer.type === "pullRequest") return ; if (buffer.type === "githubIssue") return ; if (buffer.type === "githubAction") return ; diff --git a/windows/tauri/src/features/ai/lib/acp-diff-output.ts b/windows/tauri/src/features/ai/lib/acp-diff-output.ts index 0099ab122..1601c44c7 100644 --- a/windows/tauri/src/features/ai/lib/acp-diff-output.ts +++ b/windows/tauri/src/features/ai/lib/acp-diff-output.ts @@ -117,5 +117,5 @@ export function openAcpDiffOutput(output: unknown): string | null { return useBufferStore .getState() - .actions.openBuffer(virtualPath, displayName, "", false, undefined, true, true, multiDiff); + .actions.openBuffer(virtualPath, displayName, "", false, true, true, multiDiff); } diff --git a/windows/tauri/src/features/ai/utils/ai-context-builder.ts b/windows/tauri/src/features/ai/utils/ai-context-builder.ts index 051aad2b4..035a5f71f 100644 --- a/windows/tauri/src/features/ai/utils/ai-context-builder.ts +++ b/windows/tauri/src/features/ai/utils/ai-context-builder.ts @@ -14,7 +14,6 @@ function formatOpenContextSummary(buffer: PaneContent, projectRoot?: string) { if (buffer.type === "terminal") { return `Terminal: ${buffer.name}${buffer.workingDirectory ? ` (${buffer.workingDirectory})` : ""}`; } - if (buffer.type === "database") return `Database: ${buffer.name} (${buffer.databaseType})`; if (buffer.type === "pullRequest") return `GitHub pull request: ${buffer.name} (#${buffer.prNumber})`; if (buffer.type === "githubIssue") return `GitHub issue: ${buffer.name} (#${buffer.issueNumber})`; diff --git a/windows/tauri/src/features/command-palette/constants/database-actions.tsx b/windows/tauri/src/features/command-palette/constants/database-actions.tsx deleted file mode 100644 index 5a49fd985..000000000 --- a/windows/tauri/src/features/command-palette/constants/database-actions.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { DatabaseIcon as Database } from "@/ui/icons"; -import type { Action } from "../types/action.types"; - -interface DatabaseActionsParams { - openDatabaseCommand: () => void; -} - -export const createDatabaseActions = (params: DatabaseActionsParams): Action[] => { - const { openDatabaseCommand } = params; - - return [ - { - id: "database-connect", - label: "Database: Show Databases", - description: "Open workspace database connections", - icon: , - category: "Database", - commandId: "database.connect", - action: () => { - openDatabaseCommand(); - }, - }, - ]; -}; diff --git a/windows/tauri/src/features/command-palette/constants/markdown-actions.tsx b/windows/tauri/src/features/command-palette/constants/markdown-actions.tsx index 5c3b93c34..4400e2a18 100644 --- a/windows/tauri/src/features/command-palette/constants/markdown-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/markdown-actions.tsx @@ -10,7 +10,6 @@ interface MarkdownActionsParams { name: string, content: string, isImage?: boolean, - databaseType?: any, isDiff?: boolean, isVirtual?: boolean, diffData?: any, @@ -48,7 +47,6 @@ export const createMarkdownActions = (params: MarkdownActionsParams): Action[] = previewName, content, false, // isImage - undefined, // databaseType false, // isDiff true, // isVirtual undefined, // diffData diff --git a/windows/tauri/src/features/command-palette/types/view.types.ts b/windows/tauri/src/features/command-palette/types/view.types.ts index bcec9d0c9..8173cba43 100644 --- a/windows/tauri/src/features/command-palette/types/view.types.ts +++ b/windows/tauri/src/features/command-palette/types/view.types.ts @@ -4,7 +4,6 @@ type BuiltInCommandPaletteViewId = | "icon-theme" | "local-history" | "outline" - | "databases" | "encoding"; type ExtensionCommandPaletteViewId = `extension:${string}`; diff --git a/windows/tauri/src/features/database/components/cell-renderer.tsx b/windows/tauri/src/features/database/components/cell-renderer.tsx deleted file mode 100644 index b68e8fd73..000000000 --- a/windows/tauri/src/features/database/components/cell-renderer.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useState } from "react"; -import Badge from "@/ui/badge"; -import { Button } from "@/ui/button"; -import { useTranslation } from "@/i18n/locale-provider"; -import { cn } from "@/utils/cn"; -import type { ForeignKeyInfo } from "../types/common.types"; - -interface CellRendererProps { - value: unknown; - columnName: string; - isPrimaryKey: boolean; - foreignKey?: ForeignKeyInfo; - onFkClick?: (columnName: string, value: unknown) => void; - onContextMenu?: (e: React.MouseEvent, value: unknown, columnName: string) => void; -} - -export default function CellRenderer({ - value, - columnName, - isPrimaryKey, - foreignKey, - onFkClick, - onContextMenu, -}: CellRendererProps) { - const [expanded, setExpanded] = useState(false); - const { t } = useTranslation(); - - const handleContextMenu = (e: React.MouseEvent) => { - onContextMenu?.(e, value, columnName); - }; - - if (value === null || value === undefined) { - return ( - - NULL - - ); - } - - // JSON detection - if (typeof value === "string" && isJsonString(value)) { - return ( - - - {expanded && ( -
    -            {formatJson(value)}
    -          
    - )} -
    - ); - } - - // Date detection - if (typeof value === "string" && isIsoDate(value)) { - return ( - - {formatDate(value)} - - ); - } - - // Unix timestamp detection - if (typeof value === "number" && isUnixTimestamp(value)) { - const dateStr = new Date(value * 1000).toISOString(); - return ( - - {formatDate(dateStr)} - - ); - } - - // Foreign key value - if (foreignKey && onFkClick) { - return ( - - ); - } - - // Object/array - if (typeof value === "object") { - return ( - - {JSON.stringify(value)} - - ); - } - - // Long text - const text = String(value); - if (text.length > 100) { - return ( - - - - ); - } - - // Default - return ( - - {text} - - ); -} - -// Detection heuristics - -export function isIsoDate(value: string): boolean { - if (value.length < 10 || value.length > 30) return false; - return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value); -} - -export function isUnixTimestamp(value: number): boolean { - // Reasonable range: 2000-01-01 to 2100-01-01 - return value >= 946684800 && value <= 4102444800; -} - -export function isJsonString(value: string): boolean { - if (value.length < 2) return false; - const trimmed = value.trim(); - if ( - (trimmed.startsWith("{") && trimmed.endsWith("}")) || - (trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } - } - return false; -} - -function formatDate(isoString: string): string { - try { - const date = new Date(isoString); - if (Number.isNaN(date.getTime())) return isoString; - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return isoString; - } -} - -function formatJson(value: string): string { - try { - return JSON.stringify(JSON.parse(value), null, 2); - } catch { - return value; - } -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) return text; - return `${text.slice(0, maxLength)}...`; -} diff --git a/windows/tauri/src/features/database/components/column-filters.tsx b/windows/tauri/src/features/database/components/column-filters.tsx deleted file mode 100644 index b3c4b6228..000000000 --- a/windows/tauri/src/features/database/components/column-filters.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { PlusIcon as Plus, XIcon as X } from "@/ui/icons"; -import { Button } from "@/ui/button"; -import Input from "@/ui/input"; -import Select from "@/ui/select"; -import { useTranslation } from "@/i18n/locale-provider"; -import { databaseCardClassName } from "./database-surface"; -import type { ColumnFilter, ColumnInfo, FilterOperator } from "../types/common.types"; - -const FILTER_OPERATORS: { value: FilterOperator; labelKey: string; fallback: string }[] = [ - { value: "equals", labelKey: "database.filterEquals", fallback: "=" }, - { value: "notEquals", labelKey: "database.filterNotEquals", fallback: "!=" }, - { value: "contains", labelKey: "database.filterContains", fallback: "contains" }, - { value: "startsWith", labelKey: "database.filterStartsWith", fallback: "starts with" }, - { value: "endsWith", labelKey: "database.filterEndsWith", fallback: "ends with" }, - { value: "gt", labelKey: "database.filterGreaterThan", fallback: ">" }, - { value: "gte", labelKey: "database.filterGreaterThanOrEqual", fallback: ">=" }, - { value: "lt", labelKey: "database.filterLessThan", fallback: "<" }, - { value: "lte", labelKey: "database.filterLessThanOrEqual", fallback: "<=" }, - { value: "between", labelKey: "database.filterBetween", fallback: "between" }, - { value: "isNull", labelKey: "database.filterIsNull", fallback: "is null" }, - { value: "isNotNull", labelKey: "database.filterIsNotNull", fallback: "is not null" }, -]; - -const NO_VALUE_OPERATORS = new Set(["isNull", "isNotNull"]); - -interface ColumnFiltersProps { - filters: ColumnFilter[]; - columns: ColumnInfo[]; - onUpdate: (index: number, updates: Partial) => void; - onRemove: (index: number) => void; - onClear: () => void; - onAddFilter: (column: string) => void; -} - -export default function ColumnFilters({ - filters, - columns, - onUpdate, - onRemove, - onClear, - onAddFilter, -}: ColumnFiltersProps) { - const { t } = useTranslation(); - if (filters.length === 0) return null; - - return ( -
    -
    -
    - - {t("database.filtersCount", { count: filters.length })} - - {columns.length > 0 && ( - - )} -
    - -
    -
    - {filters.map((filter, index) => ( -
    - ({ - value: operator.value, - label: t(operator.labelKey) || operator.fallback, - }))} - onChange={(value) => onUpdate(index, { operator: value as FilterOperator })} - size="xs" - className="min-w-20" - /> - {!NO_VALUE_OPERATORS.has(filter.operator) && ( - onUpdate(index, { value: e.target.value })} - placeholder={t("database.value")} - size="xs" - className="flex-1" - /> - )} - {filter.operator === "between" && ( - onUpdate(index, { value2: e.target.value })} - placeholder={t("database.to")} - size="xs" - className="flex-1" - /> - )} - -
    - ))} -
    -
    - ); -} diff --git a/windows/tauri/src/features/database/components/connection/connection-config.ts b/windows/tauri/src/features/database/components/connection/connection-config.ts deleted file mode 100644 index 3cbe2e9c8..000000000 --- a/windows/tauri/src/features/database/components/connection/connection-config.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PROVIDER_REGISTRY } from "../../providers/provider-registry"; -import type { SavedConnection } from "../../stores/connection.store"; -import type { ConnectionValidationInput } from "./connection-validation"; - -type ConnectionConfigInput = Pick< - ConnectionValidationInput, - "dbType" | "mode" | "host" | "port" | "database" | "connectionString" | "filePath" -> & { - name: string; - username: string; - workspacePath?: string; -}; - -export function buildSavedConnectionConfig( - input: ConnectionConfigInput, - id?: string, -): SavedConnection { - const provider = PROVIDER_REGISTRY[input.dbType]; - const defaultPort = provider.defaultPort ?? 0; - const name = input.name.trim() || `${provider.label} Connection`; - const normalizedId = id?.trim() || `${input.dbType}-${Date.now()}`; - const workspacePath = input.workspacePath?.trim(); - - if (provider.isFileBased) { - return { - id: normalizedId, - name, - db_type: input.dbType, - ...(workspacePath ? { workspace_path: workspacePath } : {}), - file_path: input.filePath.trim(), - host: "", - port: 0, - database: "", - username: "", - connection_string: undefined, - }; - } - - const normalizedPort = - Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535 - ? input.port - : defaultPort; - - return { - id: normalizedId, - name, - db_type: input.dbType, - ...(workspacePath ? { workspace_path: workspacePath } : {}), - host: input.host.trim(), - port: normalizedPort, - database: input.database.trim(), - username: input.username.trim(), - connection_string: input.mode === "string" ? input.connectionString.trim() : undefined, - }; -} diff --git a/windows/tauri/src/features/database/components/connection/connection-dialog.tsx b/windows/tauri/src/features/database/components/connection/connection-dialog.tsx deleted file mode 100644 index c3906ad02..000000000 --- a/windows/tauri/src/features/database/components/connection/connection-dialog.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import { open } from "@tauri-apps/plugin-dialog"; -import { FolderOpenIcon as FolderOpen, PlugsConnectedIcon as PlugZap } from "@/ui/icons"; -import { useEffect, useRef, useState } from "react"; -import { useBufferStore } from "@/features/editor/stores/buffer.store"; -import { useExtensionStore } from "@/extensions/registry/extension-store"; -import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; -import { Button } from "@/ui/button"; -import { Checkbox } from "@/ui/checkbox"; -import Dialog from "@/ui/dialog"; -import { Field, FieldLabel } from "@/ui/field"; -import Input from "@/ui/input"; -import { Marker, MarkerContent } from "@/ui/marker"; -import { Spinner } from "@/ui/spinner"; -import Select from "@/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/ui/tabs"; -import { useTranslation } from "@/i18n/locale-provider"; -import { normalizeDatabaseError } from "../../lib/database-errors"; -import type { DatabaseType } from "../../types/provider.types"; -import { PROVIDER_REGISTRY } from "../../providers/provider-registry"; -import { useConnectionStore } from "../../stores/connection.store"; -import { buildSavedConnectionConfig } from "./connection-config"; -import { getInstalledDatabaseTypes, validateConnectionInput } from "./connection-validation"; - -interface ConnectionDialogProps { - isOpen: boolean; - onClose: () => void; -} - -export function ConnectionDialog({ isOpen, onClose }: ConnectionDialogProps) { - const actions = useConnectionStore.use.actions(); - const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); - const availableExtensions = useExtensionStore.use.availableExtensions(); - const [mode, setMode] = useState<"form" | "string">("form"); - const [dbType, setDbType] = useState("sqlite"); - const [name, setName] = useState(""); - const [filePath, setFilePath] = useState(""); - const [host, setHost] = useState("localhost"); - const [port, setPort] = useState(PROVIDER_REGISTRY.sqlite.defaultPort ?? 5432); - const [database, setDatabase] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [connectionString, setConnectionString] = useState(""); - const [saveCredential, setSaveCredential] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [error, setError] = useState(null); - const [testResult, setTestResult] = useState(null); - const connectionFeedbackVersionRef = useRef(0); - const { t } = useTranslation(); - - const installedDbTypes = getInstalledDatabaseTypes(availableExtensions); - - useEffect(() => { - if (!isOpen || installedDbTypes.length === 0 || installedDbTypes.includes(dbType)) { - return; - } - - handleDbTypeChange(installedDbTypes[0]); - }, [dbType, installedDbTypes, isOpen]); - - if (!isOpen) return null; - - const provider = PROVIDER_REGISTRY[dbType]; - const isFileBased = provider.isFileBased; - const validationError = validateConnectionInput( - { - dbType, - isFileBased, - mode, - filePath, - host, - port, - database, - connectionString, - }, - { - selectDatabaseFile: t("database.selectDatabaseFile"), - enterConnectionString: t("database.enterConnectionString"), - enterHost: t("database.enterHost"), - enterValidPort: t("database.enterValidPort"), - enterDatabaseName: t("database.enterDatabaseName"), - }, - ); - - const clearConnectionFeedback = () => { - connectionFeedbackVersionRef.current += 1; - setIsTesting(false); - setError(null); - setTestResult(null); - }; - - const updateConnectionField = (setter: (value: T) => void, value: T) => { - setter(value); - clearConnectionFeedback(); - }; - - const handleModeChange = (nextMode: "form" | "string") => { - if (mode === nextMode) return; - setMode(nextMode); - clearConnectionFeedback(); - }; - - const handleDbTypeChange = (type: DatabaseType) => { - setDbType(type); - setPort(PROVIDER_REGISTRY[type].defaultPort ?? 5432); - if (PROVIDER_REGISTRY[type].isFileBased) { - setMode("form"); - } - clearConnectionFeedback(); - }; - - const buildConfig = () => - buildSavedConnectionConfig({ - dbType, - mode, - name, - filePath, - host, - port, - database, - username, - connectionString, - workspacePath: rootFolderPath, - }); - - const handleBrowseDatabaseFile = async () => { - const selected = await open({ - multiple: false, - directory: false, - filters: [ - { - name: provider.label, - extensions: (provider.fileExtensions ?? []).map((ext) => ext.replace(/^\./, "")), - }, - ], - }); - - if (selected && typeof selected === "string") { - setFilePath(selected); - if (!name.trim()) { - const fileName = selected.split("/").pop() ?? selected; - setName(fileName); - } - clearConnectionFeedback(); - } - }; - - const handleTest = async () => { - if (isFileBased) return; - if (validationError) { - connectionFeedbackVersionRef.current += 1; - setError(validationError); - setTestResult(false); - return; - } - const feedbackVersion = connectionFeedbackVersionRef.current + 1; - connectionFeedbackVersionRef.current = feedbackVersion; - setIsTesting(true); - setError(null); - setTestResult(null); - try { - const result = await actions.testConnection(buildConfig(), password || undefined); - if (connectionFeedbackVersionRef.current !== feedbackVersion) return; - setTestResult(result.ok); - if (!result.ok) setError(result.error ?? t("database.connectionTestFailed")); - } catch (err) { - if (connectionFeedbackVersionRef.current !== feedbackVersion) return; - setError(normalizeDatabaseError(err)); - setTestResult(false); - } finally { - if (connectionFeedbackVersionRef.current === feedbackVersion) { - setIsTesting(false); - } - } - }; - - const handleConnect = async () => { - if (validationError) { - setError(validationError); - return; - } - - setIsConnecting(true); - setError(null); - try { - if (isFileBased) { - const config = buildConfig(); - await actions.saveConnection(config); - useBufferStore - .getState() - .actions.openDatabaseBuffer(config.file_path ?? filePath, config.name, dbType); - onClose(); - return; - } - - const config = buildConfig(); - - if (saveCredential && password) { - await actions.storeCredential(config.id, password); - } - - await actions.saveConnection(config); - const connId = await actions.connect(config, password || undefined); - useBufferStore - .getState() - .actions.openDatabaseBuffer(`connection://${connId}`, config.name, dbType, connId); - onClose(); - } catch (err) { - setError(normalizeDatabaseError(err)); - } finally { - setIsConnecting(false); - } - }; - - return ( - - {installedDbTypes.length > 0 && !isFileBased && ( - - )} - - - } - > - {installedDbTypes.length === 0 ? ( -
    - {t("database.installProviderFromExtensions")} -
    - ) : null} - - updateConnectionField(setName, e.target.value)} - /> - - - {isFileBased ? ( - - {t("database.databaseFile")} -
    - updateConnectionField(setFilePath, e.target.value)} - placeholder={t("database.selectSqliteDatabaseFile")} - /> - -
    -
    - ) : ( - <> -
    - - {t("database.host")} - updateConnectionField(setHost, e.target.value)} - /> - - - {t("database.port")} - updateConnectionField(setPort, Number(e.target.value))} - /> - -
    - {dbType !== "redis" && ( - - {t("database.database")} - updateConnectionField(setDatabase, e.target.value)} - /> - - )} -
    - - {t("database.username")} - updateConnectionField(setUsername, e.target.value)} - /> - - - {t("database.password")} - updateConnectionField(setPassword, e.target.value)} - /> - -
    - - updateConnectionField(setSaveCredential, checked)} - aria-label={t("database.savePasswordSecurely")} - /> - - {t("database.savePasswordSecurely")} - - - - )} - - ) : ( - - {t("database.connectionString")} - updateConnectionField(setConnectionString, e.target.value)} - /> - - )} - - {error && ( - - {error} - - )} - - {testResult === true && ( - - {t("database.connectionTestSuccessful")} - - )} -
    - ); -} diff --git a/windows/tauri/src/features/database/components/connection/connection-validation.ts b/windows/tauri/src/features/database/components/connection/connection-validation.ts deleted file mode 100644 index ebd5a7f05..000000000 --- a/windows/tauri/src/features/database/components/connection/connection-validation.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { DatabaseType } from "../../types/provider.types"; - -const CONNECTION_DB_TYPES: DatabaseType[] = [ - "sqlite", - "duckdb", - "postgres", - "mysql", - "mongodb", - "redis", -]; - -export interface DatabaseExtensionAvailability { - isInstalled?: boolean; - manifest: { - databases?: Array<{ id: string; protocolVersion?: number }>; - databaseProviders?: Array<{ id: string; protocolVersion?: number }>; - }; -} - -export interface ConnectionValidationInput { - dbType: DatabaseType; - isFileBased: boolean; - mode: "form" | "string"; - filePath: string; - host: string; - port: number; - database: string; - connectionString: string; -} - -export interface ConnectionValidationMessages { - selectDatabaseFile: string; - enterConnectionString: string; - enterHost: string; - enterValidPort: string; - enterDatabaseName: string; -} - -const DEFAULT_CONNECTION_VALIDATION_MESSAGES: ConnectionValidationMessages = { - selectDatabaseFile: "Select a database file", - enterConnectionString: "Enter a connection string", - enterHost: "Enter a host", - enterValidPort: "Enter a valid port", - enterDatabaseName: "Enter a database name", -}; - -export function getInstalledDatabaseTypes( - _availableExtensions: Map, -): DatabaseType[] { - return CONNECTION_DB_TYPES; -} - -export function validateConnectionInput( - input: ConnectionValidationInput, - messages: ConnectionValidationMessages = DEFAULT_CONNECTION_VALIDATION_MESSAGES, -): string | null { - if (input.isFileBased) { - return input.filePath.trim() ? null : messages.selectDatabaseFile; - } - - if (input.mode === "string") { - return input.connectionString.trim() ? null : messages.enterConnectionString; - } - - if (!input.host.trim()) { - return messages.enterHost; - } - - if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) { - return messages.enterValidPort; - } - - if (input.dbType !== "redis" && !input.database.trim()) { - return messages.enterDatabaseName; - } - - return null; -} diff --git a/windows/tauri/src/features/database/components/context-menus.tsx b/windows/tauri/src/features/database/components/context-menus.tsx deleted file mode 100644 index 8f977e610..000000000 --- a/windows/tauri/src/features/database/components/context-menus.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { PencilSimpleIcon as EditIcon, PlusIcon, TrashIcon } from "@/ui/icons"; -import { useUIState } from "@/features/window/stores/ui-state.store"; -import { Dropdown, type MenuItem } from "@/ui/dropdown"; -import { useTranslation } from "@/i18n/locale-provider"; -import type { DatabaseRow } from "../types/common.types"; - -export const SqlTableMenu = ({ - onCreateRow, - onDeleteTable, -}: { - onCreateRow: (tableName: string) => void; - onDeleteTable: (tableName: string) => void; -}) => { - const { databaseTableMenu, setDatabaseTableMenu } = useUIState(); - const { t } = useTranslation(); - - const onCloseMenu = () => setDatabaseTableMenu(null); - const objectKind = databaseTableMenu?.objectKind ?? "table"; - const canCreateRow = objectKind === "table"; - const deleteLabel = - objectKind === "view" - ? t("database.deleteView") - : objectKind === "materialized_view" - ? t("database.deleteMaterializedView") - : objectKind === "index" - ? t("database.deleteIndex") - : t("database.deleteTable"); - const items: MenuItem[] = databaseTableMenu - ? [ - ...(canCreateRow - ? [ - { - id: "create-row", - label: t("database.addNewRow"), - icon: , - onClick: () => onCreateRow(databaseTableMenu.tableName), - }, - { id: "separator", label: "", separator: true, onClick: () => {} }, - ] - : []), - { - id: "delete-table", - label: deleteLabel, - icon: , - onClick: () => onDeleteTable(databaseTableMenu.tableName), - }, - ] - : []; - - return ( - - ); -}; - -export const SqlRowMenu = ({ - onEditRow, - onDeleteRow, -}: { - onEditRow: (tableName: string, rowData: DatabaseRow) => void; - onDeleteRow: (tableName: string, rowData: DatabaseRow) => void; -}) => { - const { databaseRowMenu, setDatabaseRowMenu } = useUIState(); - const { t } = useTranslation(); - - const onCloseMenu = () => setDatabaseRowMenu(null); - const items: MenuItem[] = databaseRowMenu - ? [ - { - id: "edit-row", - label: t("database.editRow"), - icon: , - onClick: () => onEditRow(databaseRowMenu.tableName, databaseRowMenu.rowData), - }, - { - id: "delete-row", - label: t("database.deleteRow"), - icon: , - onClick: () => onDeleteRow(databaseRowMenu.tableName, databaseRowMenu.rowData), - }, - ] - : []; - - return ( - - ); -}; diff --git a/windows/tauri/src/features/database/components/crud-modals.tsx b/windows/tauri/src/features/database/components/crud-modals.tsx deleted file mode 100644 index 33790d348..000000000 --- a/windows/tauri/src/features/database/components/crud-modals.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { PlusIcon, XIcon } from "@/ui/icons"; -import { useEffect, useState } from "react"; -import { Button } from "@/ui/button"; -import { Checkbox } from "@/ui/checkbox"; -import Dialog from "@/ui/dialog"; -import Input from "@/ui/input"; -import Select from "@/ui/select"; -import { useTranslation } from "@/i18n/locale-provider"; -import type { ColumnInfo, DatabaseRow } from "../types/common.types"; -import { - getInitialCreateTableColumn, - normalizeCreateTableColumns, - type CreateTableColumnDraft, -} from "../utils/create-table-form"; -import { buildDatabaseRowValues, databaseRowToFormValues } from "../utils/value-coercion"; - -interface CreateRowModalProps { - isOpen: boolean; - onClose: () => void; - tableName: string; - columns: ColumnInfo[]; - onSubmit: (values: DatabaseRow) => void; -} - -export const CreateRowModal = ({ - isOpen, - onClose, - tableName, - columns, - onSubmit, -}: CreateRowModalProps) => { - const [values, setValues] = useState>({}); - const { t } = useTranslation(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSubmit(buildDatabaseRowValues(values, columns)); - setValues({}); - onClose(); - }; - - const handleClose = () => { - setValues({}); - onClose(); - }; - - if (!isOpen) return null; - - return ( - -
    - {columns - .filter((col) => col.name.toLowerCase() !== "rowid") - .map((column, index) => { - const fieldId = `create-row-field-${index}`; - return ( -
    - - ) => - setValues((prev) => ({ ...prev, [column.name]: e.target.value })) - } - className="w-full" - placeholder={column.notnull ? t("database.required") : t("database.optional")} - /> -
    - ); - })} - -
    - - -
    -
    -
    - ); -}; - -interface EditRowModalProps { - isOpen: boolean; - onClose: () => void; - tableName: string; - columns: ColumnInfo[]; - initialData: DatabaseRow; - onSubmit: (values: DatabaseRow) => void; -} - -export const EditRowModal = ({ - isOpen, - onClose, - tableName, - columns, - initialData, - onSubmit, -}: EditRowModalProps) => { - const [values, setValues] = useState>(() => - databaseRowToFormValues(initialData), - ); - const { t } = useTranslation(); - - useEffect(() => { - if (isOpen) { - setValues(databaseRowToFormValues(initialData)); - } - }, [initialData, isOpen]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSubmit(buildDatabaseRowValues(values, columns)); - onClose(); - }; - - const handleClose = () => { - setValues(databaseRowToFormValues(initialData)); - onClose(); - }; - - if (!isOpen) return null; - - return ( - -
    - {columns - .filter((col) => col.name.toLowerCase() !== "rowid") - .map((column, index) => { - const fieldId = `edit-row-field-${index}`; - return ( -
    - - ) => - setValues((prev) => ({ ...prev, [column.name]: e.target.value })) - } - className="w-full" - placeholder={column.notnull ? t("database.required") : t("database.optional")} - /> -
    - ); - })} - -
    - - -
    -
    -
    - ); -}; - -interface CreateTableModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (tableName: string, columns: CreateTableColumnDraft[]) => void; -} - -export const CreateTableModal = ({ isOpen, onClose, onSubmit }: CreateTableModalProps) => { - const [tableName, setTableName] = useState(""); - const [columns, setColumns] = useState([getInitialCreateTableColumn()]); - const normalizedColumns = normalizeCreateTableColumns(columns); - const canSubmit = tableName.trim().length > 0 && normalizedColumns.length > 0; - const { t } = useTranslation(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (canSubmit) { - onSubmit(tableName.trim(), normalizedColumns); - handleClose(); - } - }; - - const handleClose = () => { - setTableName(""); - setColumns([getInitialCreateTableColumn()]); - onClose(); - }; - - const addColumn = () => { - setColumns((prev) => [...prev, getInitialCreateTableColumn()]); - }; - - const removeColumn = (index: number) => { - if (columns.length > 1) { - setColumns((prev) => prev.filter((_, i) => i !== index)); - } - }; - - const updateColumn = ( - index: number, - field: keyof CreateTableColumnDraft, - value: string | boolean, - ) => { - setColumns((prev) => prev.map((col, i) => (i === index ? { ...col, [field]: value } : col))); - }; - - if (!isOpen) return null; - - return ( - -
    -
    - - ) => setTableName(e.target.value)} - placeholder={t("database.enterTableName")} - required - /> -
    - -
    -
    - {t("database.columns")} -
    - {columns.map((column, index) => ( -
    - ) => - updateColumn(index, "name", e.target.value) - } - placeholder={t("database.columnName")} - className="flex-1" - required - /> - el?.focus()} - type={info?.type.toLowerCase().includes("int") ? "number" : "text"} - value={editValue} - onChange={(e) => setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSubmit(); - if (e.key === "Escape") setEditing(null); - }} - onBlur={handleSubmit} - className="w-full rounded-lg border-border/70 bg-surface/80 ui-text-sm focus:border-primary/60" - /> - ) : ( - - handleCellContextMenu( - event, - value, - columnName, - copyText, - copyTextWithHeaders, - ) - } - /> - )} - - ); - })} - - ); - })} - {virtualPaddingBottom > 0 && ( - - - - )} - - -
    - - -
    - ); -} diff --git a/windows/tauri/src/features/database/components/database-sidebar.tsx b/windows/tauri/src/features/database/components/database-sidebar.tsx deleted file mode 100644 index ab20c6ed1..000000000 --- a/windows/tauri/src/features/database/components/database-sidebar.tsx +++ /dev/null @@ -1,660 +0,0 @@ -import { - ArrowLeftIcon as ArrowLeft, - DatabaseIcon as Database, - FilePlusIcon as FilePlus, - FolderOpenIcon as FolderOpen, - PlugsConnectedIcon as PlugsConnected, - PlusIcon as Plus, - TrashIcon as Trash, -} from "@/ui/icons"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useBufferStore } from "@/features/editor/stores/buffer.store"; -import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; -import { extractDroppedFilePaths } from "@/features/file-system/utils/file-system-dropped-paths"; -import { Button } from "@/ui/button"; -import { Checkbox } from "@/ui/checkbox"; -import { - CommandEmpty, - CommandFooter, - CommandFooterAction, - CommandHeader, - CommandHeaderAction, - CommandInput, - CommandItemAction, - CommandItemBadge, - CommandItemRow, - CommandList, -} from "@/ui/command"; -import Input from "@/ui/input"; -import { Spinner } from "@/ui/spinner"; -import { useTranslation } from "@/i18n/locale-provider"; -import { cn } from "@/utils/cn"; -import { normalizeDatabaseError } from "../lib/database-errors"; -import type { DatabaseType } from "../types/provider.types"; -import { PROVIDER_REGISTRY } from "../providers/provider-registry"; -import { type SavedConnection, useConnectionStore } from "../stores/connection.store"; -import { getDatabaseTypeForFilePath } from "../utils/database-file-drop"; -import { - getDatabaseFilePathKey, - getSavedFileConnectionPathKeys, - getWorkspaceDatabaseFiles, - type WorkspaceDatabaseFile, -} from "../utils/workspace-database-files"; -import { buildSavedConnectionConfig } from "./connection/connection-config"; -import { - getInstalledDatabaseTypes, - validateConnectionInput, -} from "./connection/connection-validation"; - -function getBaseName(path: string) { - return path.split(/[\\/]/).filter(Boolean).pop() ?? path; -} - -type SidebarMode = "list" | "choose-provider" | "file-provider" | "network-provider"; - -function getConnectionSubtitle(connection: SavedConnection) { - const provider = PROVIDER_REGISTRY[connection.db_type]; - if (provider.isFileBased) { - return connection.file_path ? getBaseName(connection.file_path) : provider.label; - } - - const database = connection.database ? ` / ${connection.database}` : ""; - return `${provider.label} ${connection.host}:${connection.port}${database}`; -} - -interface DatabaseCommandContentProps { - isActive: boolean; - onBack: () => void; - onClose: () => void; -} - -export function DatabaseCommandContent({ isActive, onBack, onClose }: DatabaseCommandContentProps) { - const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); - const filesVersion = useFileSystemStore((state) => state.filesVersion); - const getAllProjectFiles = useFileSystemStore((state) => state.getAllProjectFiles); - const savedConnections = useConnectionStore.use.savedConnections(); - const activeConnections = useConnectionStore.use.activeConnections(); - const isLoadingSaved = useConnectionStore.use.isLoadingSaved(); - const { - loadSavedConnections, - connect, - deleteConnection, - getCredential, - saveConnection, - storeCredential, - } = useConnectionStore.use.actions(); - const openDatabaseBuffer = useBufferStore.use.actions().openDatabaseBuffer; - const [query, setQuery] = useState(""); - const [mode, setMode] = useState("list"); - const [selectedDbType, setSelectedDbType] = useState("sqlite"); - const [name, setName] = useState(""); - const [host, setHost] = useState("localhost"); - const [port, setPort] = useState(PROVIDER_REGISTRY.postgres.defaultPort ?? 5432); - const [databaseName, setDatabaseName] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [saveCredential, setSaveCredential] = useState(false); - const [isDraggingFile, setIsDraggingFile] = useState(false); - const [busyConnectionId, setBusyConnectionId] = useState(null); - const [error, setError] = useState(null); - const [workspaceDatabaseFiles, setWorkspaceDatabaseFiles] = useState([]); - const [isScanningWorkspaceDatabases, setIsScanningWorkspaceDatabases] = useState(false); - const inputRef = useRef(null); - const { t } = useTranslation(); - const validationMessages = useMemo( - () => ({ - selectDatabaseFile: t("database.selectDatabaseFile"), - enterConnectionString: t("database.enterConnectionString"), - enterHost: t("database.enterHost"), - enterValidPort: t("database.enterValidPort"), - enterDatabaseName: t("database.enterDatabaseName"), - }), - [t], - ); - - useEffect(() => { - void loadSavedConnections(); - }, [loadSavedConnections, rootFolderPath]); - - useEffect(() => { - if (!isActive) return; - setQuery(""); - setMode("list"); - setError(null); - requestAnimationFrame(() => inputRef.current?.focus()); - }, [isActive]); - - const installedDbTypes = useMemo(() => getInstalledDatabaseTypes(new Map()), []); - const savedFileConnectionPathKeys = useMemo( - () => getSavedFileConnectionPathKeys(savedConnections), - [savedConnections], - ); - - const workspaceConnections = useMemo(() => { - const normalizedWorkspace = rootFolderPath?.trim(); - if (!normalizedWorkspace) return []; - - const normalizedQuery = query.trim().toLowerCase(); - return savedConnections - .filter((connection) => connection.workspace_path === normalizedWorkspace) - .filter((connection) => { - if (!normalizedQuery) return true; - return ( - connection.name.toLowerCase().includes(normalizedQuery) || - getConnectionSubtitle(connection).toLowerCase().includes(normalizedQuery) - ); - }); - }, [query, rootFolderPath, savedConnections]); - - const detectedWorkspaceDatabases = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - return workspaceDatabaseFiles - .filter((file) => !savedFileConnectionPathKeys.has(getDatabaseFilePathKey(file.path))) - .filter((file) => { - if (!normalizedQuery) return true; - const providerLabel = PROVIDER_REGISTRY[file.dbType].label.toLowerCase(); - return ( - file.name.toLowerCase().includes(normalizedQuery) || - file.relativePath.toLowerCase().includes(normalizedQuery) || - providerLabel.includes(normalizedQuery) - ); - }); - }, [query, savedFileConnectionPathKeys, workspaceDatabaseFiles]); - - useEffect(() => { - if (!rootFolderPath) { - setWorkspaceDatabaseFiles([]); - setIsScanningWorkspaceDatabases(false); - return; - } - - let isCurrent = true; - setIsScanningWorkspaceDatabases(true); - void getAllProjectFiles() - .then((files) => { - if (!isCurrent) return; - setWorkspaceDatabaseFiles( - getWorkspaceDatabaseFiles(files, rootFolderPath, savedFileConnectionPathKeys), - ); - }) - .catch((err) => { - if (!isCurrent) return; - console.warn("Failed to scan workspace database files", err); - setWorkspaceDatabaseFiles([]); - }) - .finally(() => { - if (isCurrent) setIsScanningWorkspaceDatabases(false); - }); - - return () => { - isCurrent = false; - }; - }, [filesVersion, getAllProjectFiles, rootFolderPath, savedFileConnectionPathKeys]); - - const getActiveStatus = (connectionId: string) => - activeConnections.find((connection) => connection.id === connectionId)?.status; - - const resetAddForm = (dbType: DatabaseType = selectedDbType) => { - const provider = PROVIDER_REGISTRY[dbType]; - setName(""); - setHost("localhost"); - setPort(provider.defaultPort ?? 5432); - setDatabaseName(""); - setUsername(""); - setPassword(""); - setSaveCredential(false); - setError(null); - }; - - const showProviderStep = () => { - resetAddForm(); - setMode("choose-provider"); - }; - - const chooseProvider = (dbType: DatabaseType) => { - setSelectedDbType(dbType); - resetAddForm(dbType); - setMode(PROVIDER_REGISTRY[dbType].isFileBased ? "file-provider" : "network-provider"); - }; - - const saveFileConnection = useCallback( - async (filePath: string, dbType = getDatabaseTypeForFilePath(filePath)) => { - if (!rootFolderPath) { - setError(t("database.openWorkspaceBeforeAdding")); - return; - } - - if (!dbType) { - setError(t("database.dropSupportedDatabaseFile")); - return; - } - - const fileName = getBaseName(filePath); - const config = buildSavedConnectionConfig({ - dbType, - mode: "form", - name: fileName, - host: "", - port: 0, - database: "", - username: "", - connectionString: "", - filePath, - workspacePath: rootFolderPath, - }); - - setBusyConnectionId(config.id); - setError(null); - try { - await saveConnection(config); - openDatabaseBuffer(filePath, config.name, dbType); - setMode("list"); - onClose(); - } catch (err) { - setError(normalizeDatabaseError(err)); - } finally { - setBusyConnectionId(null); - } - }, - [onClose, openDatabaseBuffer, rootFolderPath, saveConnection], - ); - - const chooseDatabaseFile = async (dbType: DatabaseType) => { - const provider = PROVIDER_REGISTRY[dbType]; - const selected = await open({ - multiple: false, - directory: false, - filters: [ - { - name: provider.label, - extensions: (provider.fileExtensions ?? []).map((extension) => - extension.replace(/^\./, ""), - ), - }, - ], - }); - - if (selected && typeof selected === "string") { - await saveFileConnection(selected, dbType); - } - }; - - const saveNetworkConnection = async () => { - if (!rootFolderPath) { - setError(t("database.openWorkspaceBeforeAdding")); - return; - } - - const validationError = validateConnectionInput( - { - dbType: selectedDbType, - isFileBased: false, - mode: "form", - filePath: "", - host, - port, - database: databaseName, - connectionString: "", - }, - validationMessages, - ); - if (validationError) { - setError(validationError); - return; - } - - const config = buildSavedConnectionConfig({ - dbType: selectedDbType, - mode: "form", - name, - host, - port, - database: databaseName, - username, - connectionString: "", - filePath: "", - workspacePath: rootFolderPath, - }); - - setBusyConnectionId(config.id); - setError(null); - try { - if (saveCredential && password) { - await storeCredential(config.id, password); - } - await saveConnection(config); - const connectionId = await connect(config, password || undefined); - openDatabaseBuffer(`connection://${connectionId}`, config.name, selectedDbType, connectionId); - setMode("list"); - onClose(); - } catch (err) { - setError(normalizeDatabaseError(err)); - } finally { - setBusyConnectionId(null); - } - }; - - const handleDrop = async (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDraggingFile(false); - const droppedPaths = extractDroppedFilePaths(event.dataTransfer); - const databasePath = droppedPaths.find((path) => getDatabaseTypeForFilePath(path)); - if (!databasePath) { - setError(t("database.dropSupportedDatabaseFile")); - return; - } - - await saveFileConnection(databasePath); - }; - - const handleDragOver = (event: React.DragEvent) => { - if (!Array.from(event.dataTransfer.types).includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDraggingFile(true); - }; - - const handleDragLeave = (event: React.DragEvent) => { - if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; - setIsDraggingFile(false); - }; - - const openConnection = async (connection: SavedConnection) => { - const provider = PROVIDER_REGISTRY[connection.db_type]; - setError(null); - - if (provider.isFileBased) { - if (!connection.file_path) { - setError(t("database.databaseFilePathMissing")); - return; - } - - openDatabaseBuffer(connection.file_path, connection.name, connection.db_type); - onClose(); - return; - } - - setBusyConnectionId(connection.id); - try { - const status = getActiveStatus(connection.id); - const connectionId = - status === "connected" - ? connection.id - : await connect(connection, (await getCredential(connection.id)) ?? undefined); - openDatabaseBuffer( - `connection://${connectionId}`, - connection.name, - connection.db_type, - connectionId, - ); - onClose(); - } catch (err) { - setError(normalizeDatabaseError(err)); - } finally { - setBusyConnectionId(null); - } - }; - - const openDetectedDatabase = (file: WorkspaceDatabaseFile) => { - setError(null); - openDatabaseBuffer(file.path, file.name, file.dbType); - onClose(); - }; - - const handleDeleteConnection = async (connectionId: string) => { - setError(null); - setBusyConnectionId(connectionId); - try { - await deleteConnection(connectionId); - } catch (err) { - setError(normalizeDatabaseError(err)); - } finally { - setBusyConnectionId(null); - } - }; - - const renderHeader = () => - mode === "list" ? ( - - - - - - - - - - ) : ( - - setMode("list")}> - - {t("database.databases")} - - - - - - ); - - return ( -
    void handleDrop(event)} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - > - {renderHeader()} - - - {mode === "choose-provider" ? ( - <> - {installedDbTypes.length === 0 ? ( - -
    -
    {t("database.noProvidersInstalled")}
    - -
    -
    - ) : ( - installedDbTypes.map((type) => ( - chooseProvider(type)} - icon={} - title={PROVIDER_REGISTRY[type].label} - /> - )) - )} - - ) : mode === "file-provider" ? ( -
    - -
    - ) : mode === "network-provider" ? ( -
    - -
    - setHost(event.target.value)} - placeholder={t("database.host")} - className="h-7 flex-1" - /> - setPort(Number(event.target.value))} - placeholder={t("database.port")} - className="h-7 w-20" - /> -
    - {selectedDbType !== "redis" ? ( - setDatabaseName(event.target.value)} - placeholder={t("database.database")} - className="h-7" - /> - ) : null} -
    - setUsername(event.target.value)} - placeholder={t("database.username")} - className="h-7 flex-1" - /> - setPassword(event.target.value)} - placeholder={t("database.password")} - className="h-7 flex-1" - /> -
    - -
    - ) : !rootFolderPath ? ( - {t("database.openWorkspaceToAdd")} - ) : isLoadingSaved ? ( - - - - ) : workspaceConnections.length === 0 && - detectedWorkspaceDatabases.length === 0 && - isScanningWorkspaceDatabases ? ( - - - - ) : workspaceConnections.length === 0 && detectedWorkspaceDatabases.length === 0 ? ( - - {query.trim() ? t("database.noMatchingDatabases") : t("database.noWorkspaceDatabases")} - - ) : ( -
    - {workspaceConnections.map((connection) => { - const status = getActiveStatus(connection.id); - const isBusy = busyConnectionId === connection.id || status === "connecting"; - return ( - void openConnection(connection)} - icon={} - title={connection.name} - description={getConnectionSubtitle(connection)} - accessory={ - status === "connected" ? ( - - - {t("database.connected")} - - ) : null - } - action={ - { - event.stopPropagation(); - void handleDeleteConnection(connection.id); - }} - > - - - } - /> - ); - })} - {detectedWorkspaceDatabases.map((file) => ( - openDetectedDatabase(file)} - icon={} - title={file.name} - description={`${PROVIDER_REGISTRY[file.dbType].label} / ${file.relativePath}`} - accessory={{t("database.detected")}} - /> - ))} -
    - )} -
    - - {mode === "network-provider" ? ( - - void saveNetworkConnection()} - > - - {t("database.addDatabase")} - - - ) : null} - - {error ? ( -
    - {error} -
    - ) : null} - - {isDraggingFile ? ( -
    - {t("database.dropDatabaseFile")} -
    - ) : null} -
    - ); -} diff --git a/windows/tauri/src/features/database/components/database-surface.ts b/windows/tauri/src/features/database/components/database-surface.ts deleted file mode 100644 index 7bc5beb46..000000000 --- a/windows/tauri/src/features/database/components/database-surface.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { cn } from "@/utils/cn"; - -export function databaseHeaderClassName(className?: string) { - return cn("mx-2 mt-2 rounded-xl bg-background/85 px-3 py-2", className); -} - -export function databasePanelClassName(className?: string) { - return cn("flex min-w-0 flex-col overflow-hidden rounded-xl bg-background/85", className); -} - -export function databaseChipClassName(className?: string) { - return cn("inline-flex items-center gap-1.5 rounded-full bg-surface/70 px-2.5 py-1", className); -} - -export function databaseCardClassName(className?: string) { - return cn("rounded-xl border border-border/60 bg-surface/40", className); -} - -export function databaseCodeBlockClassName(className?: string) { - return cn( - "font-sans whitespace-pre-wrap rounded-lg bg-surface/40 p-3 ui-text-sm leading-5", - className, - ); -} diff --git a/windows/tauri/src/features/database/components/info-view.tsx b/windows/tauri/src/features/database/components/info-view.tsx deleted file mode 100644 index 8798b8d6f..000000000 --- a/windows/tauri/src/features/database/components/info-view.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Button } from "@/ui/button"; -import { ScrollArea } from "@/ui/scroll-area"; -import { useTranslation } from "@/i18n/locale-provider"; -import { getDatabaseObjectOwner, groupDatabaseObjects } from "../lib/database-catalog"; -import type { ColumnFilter, DatabaseInfo, TableInfo } from "../types/common.types"; -import SqlHistoryList from "./sql-history-list"; - -interface InfoViewProps { - fileName: string; - dbInfo: DatabaseInfo | null; - selectedTable: string | null; - columnFilters: ColumnFilter[]; - tables: TableInfo[]; - sqlHistory: string[]; - onTableChange: (tableName: string) => void; - onQuerySelect: (query: string) => void; - onQueryRun: (query: string) => void; - onQueryRemove: (query: string) => void; - onQueryHistoryClear: () => void; -} - -export default function InfoView({ - fileName, - dbInfo, - selectedTable, - columnFilters, - tables, - sqlHistory, - onTableChange, - onQuerySelect, - onQueryRun, - onQueryRemove, - onQueryHistoryClear, -}: InfoViewProps) { - const objectGroups = groupDatabaseObjects(tables); - const { t } = useTranslation(); - - return ( - -
    - {/* Database stats */} -
    -
    {fileName}
    -
    - {t("database.tablesCount", { count: dbInfo?.tables || 0 })} - {t("database.indexesCount", { count: dbInfo?.indexes || 0 })} - v{dbInfo?.version || "0"} - {selectedTable && {t("database.currentObject", { name: selectedTable })}} - {columnFilters.length > 0 && ( - {t("database.filtersCount", { count: columnFilters.length })} - )} -
    -
    - - {/* Tables */} -
    -
    {t("database.objects")}
    -
    - {objectGroups.map((group) => ( -
    -
    - {t(group.labelKey)} ({group.objects.length}) -
    -
    - {group.objects.map((table) => { - const owner = getDatabaseObjectOwner(table); - return ( - - ); - })} -
    -
    - ))} -
    -
    - - {sqlHistory.length > 0 && ( -
    - -
    - )} -
    -
    - ); -} diff --git a/windows/tauri/src/features/database/components/pagination.tsx b/windows/tauri/src/features/database/components/pagination.tsx deleted file mode 100644 index cd07e9e24..000000000 --- a/windows/tauri/src/features/database/components/pagination.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ArrowLeftIcon as ArrowLeft, ArrowRightIcon as ArrowRight } from "@/ui/icons"; -import { useEffect, useState } from "react"; -import { Button } from "@/ui/button"; -import Input from "@/ui/input"; -import Select from "@/ui/select"; -import { useTranslation } from "@/i18n/locale-provider"; -import { parseQueryResultPageInput } from "../lib/query-result-pagination"; - -interface PaginationProps { - currentPage: number; - totalPages: number; - pageSize: number; - onPageChange: (page: number) => void; - onPageSizeChange: (size: number) => void; -} - -export default function Pagination({ - currentPage, - totalPages, - pageSize, - onPageChange, - onPageSizeChange, -}: PaginationProps) { - const [pageInput, setPageInput] = useState(currentPage.toString()); - const { t } = useTranslation(); - - const handlePageInputChange = (value: string) => { - setPageInput(value); - }; - - const handlePageInputSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const page = parseQueryResultPageInput(pageInput, totalPages); - if (page !== null) { - onPageChange(page); - } else { - setPageInput(currentPage.toString()); - } - }; - - const handlePageInputBlur = () => { - if (parseQueryResultPageInput(pageInput, totalPages) === null) { - setPageInput(currentPage.toString()); - } - }; - - useEffect(() => { - setPageInput(currentPage.toString()); - }, [currentPage]); - - if (totalPages <= 1) return null; - - return ( -
    -
    - ) => - handlePageInputChange(e.target.value) - } - onBlur={handlePageInputBlur} - min={1} - max={totalPages} - aria-label={t("database.currentPage")} - className="font-sans ui-text-sm h-6 w-12 px-1 py-0 text-center" - /> - / {totalPages} - - - -
    -
    - ); -} diff --git a/windows/tauri/src/features/database/components/query-bar.tsx b/windows/tauri/src/features/database/components/query-bar.tsx deleted file mode 100644 index 4204a84e4..000000000 --- a/windows/tauri/src/features/database/components/query-bar.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { MagnifyingGlassIcon as Search, XIcon as X } from "@/ui/icons"; -import { type KeyboardEvent, type RefObject, useEffect, useMemo, useRef, useState } from "react"; -import { useTokenizer } from "@/features/editor/hooks/use-tokenizer"; -import { Button } from "@/ui/button"; -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/ui/input-group"; -import { useTranslation } from "@/i18n/locale-provider"; -import Textarea from "@/ui/textarea"; -import { cn } from "@/utils/cn"; -import { databaseCardClassName } from "./database-surface"; -import type { ColumnInfo, TableInfo } from "../types/common.types"; -import { - applySqlCompletion, - getSqlCompletions, - type SqlCompletionState, -} from "../lib/sql-completions"; -import { buildSqlHighlightSegments } from "../lib/sql-highlight"; -import { getSelectedSqlText } from "../lib/sql-selection"; - -interface QueryBarProps { - searchTerm: string; - setSearchTerm: (term: string) => void; - customQuery: string; - setCustomQuery: (query: string) => void; - isCustomQuery: boolean; - setIsCustomQuery: (is: boolean) => void; - cancelCustomQuery: () => void; - executeCustomQuery: (queryOverride?: string) => void; - lastQueryExecutionMs?: number | null; - isLoading: boolean; - isCustomQueryLoading?: boolean; - tables: TableInfo[]; - tableMeta: ColumnInfo[]; -} - -interface SqlEditorProps { - value: string; - onChange: (value: string) => void; - onSelect: () => void; - onKeyDown: (event: KeyboardEvent) => void; - disabled: boolean; - textareaRef: RefObject; - tables: TableInfo[]; - tableMeta: ColumnInfo[]; -} - -function SqlEditor({ - value, - onChange, - onSelect, - onKeyDown, - disabled, - textareaRef, - tables, - tableMeta, -}: SqlEditorProps) { - const highlightRef = useRef(null); - const [completionState, setCompletionState] = useState(null); - const { tokens, tokenize, resetForBufferSwitch } = useTokenizer({ - filePath: "query.sql", - bufferId: "database-query-editor", - languageIdOverride: "sql", - incremental: false, - }); - const { t } = useTranslation(); - - useEffect(() => { - void tokenize(value); - }, [tokenize, value]); - - useEffect(() => resetForBufferSwitch, [resetForBufferSwitch]); - - const highlightedSql = useMemo(() => buildSqlHighlightSegments(value, tokens), [tokens, value]); - const updateCompletions = (cursor: number, nextValue = value) => { - const nextState = getSqlCompletions(nextValue, cursor, { tables, columns: tableMeta }); - setCompletionState(nextState.items.length > 0 ? nextState : null); - }; - const applyCompletion = (completionIndex: number) => { - if (!completionState) return; - const completion = completionState.items[completionIndex]; - if (!completion) return; - const next = applySqlCompletion(value, completion, completionState); - onChange(next.value); - setCompletionState(null); - window.requestAnimationFrame(() => { - if (!textareaRef.current) return; - textareaRef.current.focus(); - textareaRef.current.selectionStart = next.cursor; - textareaRef.current.selectionEnd = next.cursor; - }); - }; - - return ( -
    -
    - -