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 b0c934333..88e7ba187 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 @@ -74,6 +74,16 @@ Windows 产品是 `windows/tauri` 下的 React/Tauri 实现;此前的 Qt/C++ 一个 Tauri command。没有共享实现的命令必须显式失败,不得在桌面构建 里塞入伪造的成功值。 +`tauri-core.ts` 按归属规则分流,而不是维护一份原生命令白名单:`git_*`、 +`ai_commit_*` 和带点号的 Core 命令交给 `platform_invoke`,其余命令一律当作 +`main.rs` 注册的 Tauri 命令直接调用。白名单的问题在于新增宿主命令时很容易 +漏登记,漏掉的命令会被 dispatcher 当作“未实现”拒绝,而且每次调用都失败—— +#970 中 Maven 依赖树、Maven 安装解析、启动前步骤就是这样整体失效的。 +`tauri-core.routing.test.ts` 读取 `main.rs` 的注册列表校验这条规则。 +`invoke` 还会把宿主返回的字符串或结构化错误统一包装成 `Error` +(`HostCommandError`,保留 `code`、`details`),这样调用方写 +`error instanceof Error ? error.message : 通用提示` 时不会把真实原因吞掉。 + macOS 可执行目标使用按所有权划分的目录: ```text diff --git a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md index ae783b3ce..7ba3c5ee0 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md +++ b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md @@ -370,6 +370,9 @@ Monaco 视图。语法 token 仍使用统一的 `color-mappings.json` 与共享 选择、当前行和缩进引导线仍使用当前主题颜色。只把 SwiftUI 外层设为透明是不够的, 因为不透明的 WebView 或 `editor.background` 仍会覆盖图片。也不能把 Monaco 永久设成 透明,否则没有背景图时会丢失正常的编辑器表面和主题颜色。 +主题背景色还必须传给 `editorOverviewRuler.background`。启用右侧代码缩略图时, +Monaco 会给未指定背景的概览标尺填入默认分词背景色,在壁纸模式下形成黑色竖条; +显式传入透明色后,概览标尺与编辑区一起露出工作台图片。 ### 只读差异视图 diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 04ce26f65..98fb78d09 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -86,9 +86,9 @@ Maven 设置面板的显式配置 **4. 本地仓库通过生成的设置文档传递。** JDT LS 没有“本地仓库”这个首选项, 该值只能写在 `settings.xml` 里。所以当用户填了本地仓库时,Core 以生效的设置 文件为底稿做一次**保留式 XML 改写**——只替换 `` 节点, -``、``、``、注释和缩进原样带过——写进语言服务已有 -的缓存目录,再作为 `userSettings` 传下去。安装级设置仍在 `globalSettings` 上, -Maven 会把两者合并。 +``、``、``、注释和缩进原样带过——作为 `userSettings` +传下去。安装级设置仍在 `globalSettings` 上,Maven 会把两者合并。生成文档的 +存放位置和命名规则见下文“设置变更必须让 JDT LS 重新解析(#970)”。 绝不凭空合成一份只含 `` 的文档:那会丢掉镜像配置,把下载从 阿里云打回 Maven Central。这是本次事故里代价最大的一条。 @@ -131,11 +131,8 @@ Maven 会把两者合并。 - Wrapper 工程仍然得不到安装级设置。这是正确行为,但用户如果期望 「配了 Maven 主目录就该全局生效」,需要显式填写而不是依赖 Wrapper。 -- 本地仓库走生成文档这条路,意味着缓存目录里多出一份 `maven/settings.xml`。 - 它是派生产物,用户配置文件本身不被修改。 -- 生成文档的路径是固定的,所以仓库路径变化不体现在 JDT Profile 指纹上。 - 当前没有问题,因为改动 Maven 设置会触发 Java 会话重载并重新生成;如果将来 - 去掉这个重载,指纹需要一并纳入仓库路径。 +- 本地仓库走生成文档这条路,意味着 JDT LS 状态目录里多出派生的设置副本。 + 用户配置文件本身不被修改。副本的命名和生命周期见 #970 一节。 - Maven 上下文仍然不进日志。这次定位只能靠翻启动命令行里的 `-cp`,下次遇到 类似问题依然会很慢。补日志是独立的后续工作。 @@ -259,6 +256,90 @@ Maven 目标共用项目默认值继承逻辑。显式字段不再显示未启 标签也可能被当作路径。这里只提供只读提示,不生成 Maven 的完整生效模型; `${user.home}` 以外的属性保持原文,完整插值和最终构建配置仍由 Maven 负责。 +### 设置变更必须让 JDT LS 重新解析(#970) + +**先说结论:** 改 Maven 设置或点“重新加载”,都必须让 JDT LS 真的重新解析依赖。 +Lithe 不自己判断“要不要重解析”,而是按 JDT LS 上游(vscode-java 同一套)的机制 +把变化交给它:设置文件路径变了,JDT LS 会强制更新所有 Maven 项目;用户手动 +重新加载,就发 `java/projectConfigurationsUpdate`(上游的“更新项目”,不看 pom +有没有改)。 + +**问题。** #970 的用户在 JDT LS 导入过程中改了 Maven 设置,结果 Spring、MyBatis +等第三方 import 全红,但界面没有任何错误。代码里有三处缺陷叠在一起: + +1. 保存设置只标记“需要重新加载”,从不通知运行中的 JDT LS;Maven 配置只在 + `initialize` 时送过一次。 +2. 重新加载的做法是停掉 JDT LS 再启动。重启会复用 `-data` 工作区状态,而 + JDT LS 导入时对已导入项目只检查 pom 时间戳和摘要 + (`MavenProjectImporter.updateProjects` → `needsMavenUpdate`),pom 没变就跳过。 + 上次没解析成功的依赖会一直缺失。 +3. JDT LS 判断“设置是否变化”只比较设置文件的**路径字符串** + (`StandardPreferenceManager.update`)。以前生成的设置固定写在 + `<缓存>/maven/settings.xml`,改本地仓库或镜像后路径不变,JDT LS 看不出变化; + 所有项目还共用这一个文件,会互相覆盖。 + +**决策。** + +- **设置副本按内容命名。** JDT LS 收到的每一份设置文档都复制到该会话的 + `-data` 目录下 `.lithe/maven/`,文件名带内容哈希 + (`user-settings-<哈希>.xml`、`global-settings-<哈希>.xml`)。内容一变路径就变, + JDT LS 自己的变化检测才能生效。用户级副本的来源依次是:配置的 settings.xml → + Maven 默认的 `~/.m2/settings.xml`(与命令行一致)→ 只配了本地仓库时的空文档。 + 安装级 `conf/settings.xml` 同样复制。副本放在 `-data` 里,随 JDT 缓存保留期 + 清理、随“重建 Java 索引”删除,不写发行包,也不改用户文件。读不到的文档 + 按原路径传并写 warn 日志,沿用上文“降级而不是失败”的原则。 +- **配置变化实时送达。** 新增 Core 操作 `lsp.updateMavenConfiguration`:替换会话 + 持有的 Maven 配置;设置副本路径变了就发 `workspace/didChangeConfiguration` + (JDT LS 会强制更新所有 Maven 项目,导入中途也会排队执行);profile 变了就 + 重启 profile 任务。握手前到达的更新由 `initialized` 后那次设置通知带上。 + 显式的强制重载单独保留,合并重复请求后在 `ServiceReady`(项目已导入)时执行, + 不能因为设置没变而丢弃。 + 两个平台保存 Maven 设置、切换 profile 时都调用它,不再弹“需要重新加载”。 +- **重新加载改为强制更新。** 会话还在时,“重新加载”发 + `lsp.updateMavenConfiguration` 并带 `reloadProjects`,Core 在设置未变时发 + `java/projectConfigurationsUpdate`。只有会话不存在、或会话拒绝更新时才停止并 + 重新启动。 +- **profile 任务期间的变更不丢。** 任务完成时只把它开始时的配置记为“已应用”; + 如果期间配置被替换,无论旧任务成功还是失败,都自动再跑一轮。超时后的取消 + 是建议性的,必须等待旧请求全部收到终态响应才启动新配置;同一份失败配置不 + 自动无限重试。 +- **解析失败要让用户看见。** m2e 的解析结果就是 pom.xml 上的错误诊断 + (缺少构件、父 POM 解析失败等)。两个平台都把工作区内 pom.xml 的错误诊断 + 列在 Maven 工具窗口,并对每个不同的问题集合发一次可跳转的通知;原来的 + “Maven configuration applied” 提示只代表 profile 命令返回了,不代表依赖解析 + 成功,已经移除。 + +正确做法:改一项 Maven 设置 → 调 `lsp.updateMavenConfiguration` → 让 JDT LS 按 +上游规则重解析。不要这样做:重启 JDT LS 来“应用”设置,或者用固定文件名改写 +设置文档——两者都会让 JDT LS 以为什么都没变。 + +**考虑过的备选方案。** + +- **设置一变就删掉 JDT 工作区再冷启动。** 被否。能保证重解析,但每次改设置都 + 丢掉整个索引,冷导入大工程要几分钟,而上游已经有等价且增量的机制。 +- **在 Lithe 里自己跟踪“哪些项目需要更新”。** 被否。这是 m2e/JDT LS 的项目 + 状态,Lithe 另记一份会成为第二真相源,违反“复用上游子系统”的规则。 +- **保持固定文件名,另外发一次 `projectConfigurationsUpdate`。** 被否。m2e 会 + 缓存已加载的 settings,路径不变时不会重新读取,强制更新仍然用旧镜像和旧仓库。 + +**代价。** JDT 状态目录里多出小的设置副本,其中可能包含用户 settings.xml 里的 +服务器凭据;它们位于平台提供的用户缓存目录,Unix 新文件权限为 `0600`。 +配置生成和发布使用会话专用串行锁,磁盘操作不占用协议锁;临时文件独占创建并带 +进程内唯一编号,避免并发写入覆盖。旧副本保留到 JDT 工作区缓存过期或重建索引时 +一起清理,因为通知写入管道并不代表 JDT LS 已读取文件。不能在下一次生成时删除 +上一份副本;否则导入中的服务可能读到已经不存在的路径。这些副本属于会话可变 +状态,禁止跨工作树复用,见资源清单 `jdt-maven-settings` 排除项。强制 +更新会重新解析所有 Maven 项目,大工程上比“什么都不做”慢,但这正是用户点重新加载 +时要的结果。 + +**验证。** Rust Core `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib` +覆盖内容寻址(内容变路径变、相同内容复用、旧副本保留、临时文件所有权、默认用户设置、读不到时降级), +以及 `update_maven_configuration` 的四条路径(设置变化、强制更新、握手前更新、 +profile 变化、失败或超时期间变更补跑、握手前无设置变化的强制重载)。Windows `bun test src/platform src/features/maven` +覆盖命令路由、错误包装、实时同步、重新加载先走强制更新、pom 问题提取;macOS +`LanguageIntelligenceModuleTests`、`ExecutionModuleTests` 覆盖原地更新、回退重启和 +设置实时同步。 + ## 验证 - Rust Core:`cargo test --manifest-path rust/lithe-core/Cargo.toml` @@ -307,6 +388,13 @@ Maven 目标共用项目默认值继承逻辑。显式字段不再显示未启 - Rust Core Maven 域:`rust/lithe-core/src/project/maven.rs` - Rust Core JDT 适配:`rust/lithe-core/src/lsp/languages/jdt.rs` - Rust Core 语言服务引擎:`rust/lithe-core/src/lsp/interface/engine.rs` +- Rust Core 设置副本:`rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs` +- Windows 实时同步与问题展示:`windows/tauri/src/features/maven/services/java-maven-configuration.ts`、 + `windows/tauri/src/features/maven/services/reload-maven-workspace.ts`、 + `windows/tauri/src/features/maven/utils/maven-resolution-problems.ts` +- macOS 实时同步与问题展示: + `macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift`、 + `macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift` - Windows 前端:`windows/tauri/src/features/maven/services/resolve-maven-toolchain.ts`、 `windows/tauri/src/features/maven/stores/maven.store.ts` - Windows 宿主:`windows/tauri/src-tauri/src/run.rs`(`maven_resolve_installation`) diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-html-browser-and-windows-update-status.md b/.agents/notes/implemented/bug-fix/2026-09-29-html-browser-and-windows-update-status.md new file mode 100644 index 000000000..da71ff10c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-html-browser-and-windows-update-status.md @@ -0,0 +1,74 @@ +# Agent 笔记:HTML 浏览器选择与 Windows 更新状态 + +状态:已实现 + +## 先说结论 + +HTML 的“在浏览器中打开”必须选择系统默认网页浏览器,不能依赖 HTML +文件关联,因为用户可能把 HTML 默认交给文本编辑器。Windows 主程序更新 +和语言扩展安装是独立状态;缺少语言工具导致更新失败时,必须先保留旧扩展, +不能先卸载再发现依赖不可用。 + +## 问题 + +Issue #966 同时报告更新提示和浏览器预览问题。原来的两个浏览器按钮都 +调用普通文件打开接口。Windows 设置页还会在尚未检查时显示“已是最新版”, +菜单通知没有说明检查对象是主程序;扩展更新则在检查 Bun 等依赖前卸载旧扩展。 + +## 决策 + +- macOS 通过平台 UI 接口查询 HTTPS 默认处理程序,再用 AppKit(系统桌面 + UI 框架)把文件 URL 交给该程序;失败通过预览界面的弹窗显示。 +- Windows 采用 `webbrowser` 1.2.4 的默认浏览器选择能力。该上游通过 Windows + HTTP 协议关联发现浏览器,不复刻注册表查询和浏览器命令行解析。 + Tauri 宿主验证现有本地 HTML 文件后在阻塞工作线程调用它,前端显示失败提示。 +- 外部浏览器读取已保存文件,内嵌预览继续读取编辑缓冲区。此操作不自动保存, + 不创建服务器、不下载资源,也不写安装目录或 app bundle,因而不改变签名 + 和 Sparkle 增量更新的发行基线。 +- Windows 扩展更新先复用安装器的只读依赖检查,确认必需的语言服务器已存在或可安装, + 失败时不禁用旧扩展、不删除语法缓存、不清除待更新标记。预检返回后还要 + 核对注册记录是否被禁用、卸载或另一更新替换,过期结果不得恢复旧状态。这个保护针对 + 依赖失败,不承诺后续下载或激活步骤的完整事务回滚。 +- 没有自动安装器不等于不能使用工具。Windows 对用户已放入 PATH(系统 + 可执行文件搜索目录)的语言服务器仍执行发现,缺失时说明如何安装和重试。 + Bun 仍由用户安装;此修复没有引入托管运行时下载机制。 +- “已是最新版”只在成功检查且没有更新时显示。自动检查隐藏已跳过版本时 + 返回空闲状态,不将隐藏结果解释成最新版。菜单提示明确主程序与扩展独立更新。 + +正确做法:先检查工具,检查失败就保留已安装扩展并显示错误。 +不要这样做:先清空扩展状态,再用安装失败通知代替真实的安装状态。 + +## 考虑过的备选方案 + +本地 HTTP 静态服务器能进一步支持模块脚本和自动刷新,但需要增加端口、 +文件访问边界及工作区清理责任。这次修复的是浏览器选择,直接复用平台能力 +可以避免引入服务器生命周期。自动下载 Bun 属于独立的运行时分发能力,需要 +版本、校验、许可和资源复用规则;不能用未校验的安装脚本临时补齐。 + +## 后果 + +两端不再随 HTML 文件关联跳进编辑器。Windows 对主程序版本和语言依赖的 +提示更准确,依赖检查失败不再破坏旧扩展。代价是 Windows 多一个成熟库依赖, +语言工具安装仍要求用户准备所需运行时,普通文件预览仍受浏览器 file URL 限制。 + +## 验证 + +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-windows-boundaries.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-shared-contracts.sh` +- `./scripts/test-macos.sh`:包含 `HTMLBrowserOpenerTests`。 +- `windows/tauri/src-tauri/src/html_browser.rs` 的测试验证文件 URL、输入边界和失败传播。 +- Windows 前端的扩展生命周期测试验证缺失依赖时不卸载、不清状态;更新状态 + 测试验证隐藏更新和请求失败不显示最新版。 +- 实机验证需把 HTML 关联改为编辑器,再检查浏览器按钮;当前 Linux 环境 + 不能代替 macOS AppKit 和 Windows 默认浏览器运行验证,矩阵保留待验证状态。 + +## 适用范围 + +`macos/Sources/Lithe/Platform/MacOS/UI/`、 +`macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift`、 +`windows/tauri/src-tauri/src/html_browser.rs`、 +`windows/tauri/src-tauri/src/language_tools.rs`、 +`windows/tauri/src/extensions/registry/extension-store-lifecycle.ts`、 +`windows/tauri/src/features/settings/`。 diff --git a/.agents/notes/implemented/feature/2026-09-24-document-encoding-switch.md b/.agents/notes/implemented/feature/2026-09-24-document-encoding-switch.md index cf011e377..750015830 100644 --- a/.agents/notes/implemented/feature/2026-09-24-document-encoding-switch.md +++ b/.agents/notes/implemented/feature/2026-09-24-document-encoding-switch.md @@ -22,6 +22,16 @@ macOS 和 Windows 编辑器都在状态栏提供编码入口,并支持“以 - “以编码重新打开”由单独的应用服务执行,脏文件先选择保存、放弃或取消;读取完成后只允许替换捕获的同一个缓冲区。转换保存复用现有保存生命周期,干净文件会临时进入保存状态,写入成功后再恢复干净状态。 - 编码目录由稳定 ID、显示名称、别名、读写能力和 BOM 策略组成;新增编码必须同步更新共享契约、macOS 映射、Windows `encoding_rs` 映射、UI 目录和原始字节 fixture,具体步骤见 `docs/development/document-encoding.md`。 +### 文本判定与编码转换的边界(#963) + +编码转换完成后,macOS 和 Windows 都使用 Rust Core 原有的 Unicode 控制字符规则判定正文;中文和 emoji 的 UTF-8 续字节不再被计作二进制证据。macOS 通过借用 UTF-8 字节的 C 接口调用,避免大文档再复制成 JSON;Windows 通过 `document.classifyText` 调用同一函数。原生编码库仍是字节转换的唯一实现,Core 不重新猜编码。 + +正确流程是“原生读取和解码 → Core 判定正文 → 选择编辑器 → Monaco 高亮”。不要根据语言 ID 是否存在决定文件能否编辑,也不要因为 `.java` 已知而跳过正文检查。权限、文件大小和解码错误仍按原错误显示,不能伪装成二进制判定。 + +用户明确选择外部编辑器时,解码和大小限制由该编辑器处理,不先要求 Lithe 原生读取成功。Windows 内置编辑器首次打开与会话恢复复用 `loadFileContent`;点击已经在后台加载的标签必须等待该次读取和判定结束。共享 Monaco 层直接注册随 0.55.1 分发的 INI tokenizer,其上游扩展名包含 `.properties`,无需为这个问题新增 tree-sitter 语法或自写高亮器。原有原生编辑器高亮实现不参与 Monaco 路径。 + +`WorkspaceTextFilePolicy` 的 Swift 入口转发到 Core,不再维护第二份控制字符算法。因此 macOS 测试也必须链接真实 Core;`scripts/test-macos.sh` 使用现有构建路径完成链接,不能用弱符号的不可用结果来验证正常文本。 + ## 考虑过的备选方案 - **所有文件固定按 UTF-8 读取**:不能打开 GBK 文件,也无法满足 Issue #775。 @@ -29,10 +39,15 @@ macOS 和 Windows 编辑器都在状态栏提供编码入口,并支持“以 - **只比较解码后的字符串**:不同原始字节可能解码成同一文本,且 BOM、非规范编码和外部等长修改会绕过冲突保护,因此改用字节指纹。 - **选择编码后立即修改缓冲区编码**:异步读写失败或期间用户切换标签时会污染新状态,因此将编码提交放在成功读写之后,并对异步结果做快照校验。 +- **只把 properties 加入文本白名单**:会掩盖当前示例,未知扩展名的中文和已知扩展名下的控制字符仍会走不同规则,因此删除字节比例嗅探,并共享正文判定。 +- **完整照搬 VS Code 文件服务**:其工作台、Node.js 编码服务与本项目原生存储边界不同;复用其职责分离思路与已经分发的 Monaco tokenizer,保留现有原生编解码器。 + ## 后果 用户可以在 VS Code 类似的状态栏入口中修复乱码或转换文件编码;GBK/GB18030 的中文文件不会再被静默按 UTF-8 保存。代价是本地文档读写增加一次 SHA-256 计算,并且远程、WSL、虚拟和只读缓冲区不会显示可执行的本地编码转换操作。 +统一正文判定增加一次 Windows Core 调用;macOS 单元测试开始依赖 Rust 构建。PR #981 的 CI 冷构建中,Core 分别耗时 111 秒和 227 秒,Swift 主通道编译另耗时约 486 秒,原 660 秒总预算导致主通道测试中途和插件通道编译期间超时。因此三个 Swift 测试通道使用已有 Cargo 下载缓存,并把包含编译的总预算设为 960 秒、外层步骤设为 17 分钟;保留单测试预算与无输出看门狗,缓存未命中时也必须能完成冷构建。不会新增下载入口、缓存目录或安装包运行时写入;Monaco 产物仍走现有 editor 构建与完整性验证路径。 + ## 验证 - `./scripts/test-macos.sh --filter 'DocumentGuardedSaveTests'` @@ -42,6 +57,10 @@ macOS 和 Windows 编辑器都在状态栏提供编码入口,并支持“以 - `./scripts/verify-platform-feature-matrix.sh` - macOS 和 Windows 实机仍需按矩阵中的验证步骤运行编码读取、转换保存、脏文件选择和外部修改场景。 +- `./scripts/test-macos.sh --filter TextContentPolicyTests` +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core text_content_tests` +- `shared/fixtures/editor/text-content-v1.json` 覆盖中文、emoji、NUL、控制字符和旧采样范围之外的内容;Windows 路由测试另外验证首次打开与恢复的共用入口。 + ## 适用范围 - `macos/Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift` diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index ff2f1a1fa..06f737e12 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -52,8 +52,13 @@ Git subprocesses, sometimes rebuilding several repositories in one case. The SharedRust lane assigns the `tests::git_history_rewrite::` module and the older `tests::git::git_write_squashes_`, `git_write_deletes_a_local_commit_`, and `git_write_edits_a_local_commit_message_` scenarios a 30-second process budget. -Core rebase requests use a nested 20-second deadline. Other cases retain the -normal 15-second budget. The Rust runner's repeatable +The `tests::git_patch_exchange::patch_metadata_can_list_an_oversized_export_before_selecting_a_small_subset` +case also gets 30 seconds because it writes and inspects a real file larger +than the 32 MiB patch limit; Windows CI terminated it at 16.1 seconds under +the default 15-second limit. Core rebase requests use a nested 20-second +deadline. The workspace commit submodule publication test also gets 30 seconds +because it creates two local remotes and runs multiple real Git pushes. Other +cases retain the normal 15-second budget. The Rust runner's repeatable `--test-budget prefix=milliseconds` option uses the most specific matching prefix, records each case's effective budget in JSON, uses that budget for HTML/JUnit classification, and remains capped by the shared suite deadline. diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index 845a3dd94..4538d5f7a 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -69,13 +69,15 @@ function Invoke-TimedRustTests { $arguments += @("--package", $Package) } if ($Package -eq "lithe-core") { - # History-rewrite integration tests create and rewrite real repos, - # sometimes several per case. Keep unit tests at the normal limit. + # Integration tests that create multiple real repositories or push + # through Git subprocesses need a bounded allowance on Windows. foreach ($prefix in @( "tests::git_history_rewrite::", + "tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote", "tests::git::git_write_squashes_", "tests::git::git_write_deletes_a_local_commit_", - "tests::git::git_write_edits_a_local_commit_message_" + "tests::git::git_write_edits_a_local_commit_message_", + "tests::git_patch_exchange::patch_metadata_can_list_an_oversized_export_before_selecting_a_small_subset" )) { $arguments += @("--test-budget", "${prefix}=30000") } diff --git a/.github/workflows/ci-database.yml b/.github/workflows/ci-database.yml index 29c865e9a..be6bacbf4 100644 --- a/.github/workflows/ci-database.yml +++ b/.github/workflows/ci-database.yml @@ -99,10 +99,12 @@ jobs: uses: ./.github/actions/prepare-macos-dependency-cache with: swiftpm: "true" + cargo: "true" + # The suite deadline includes cold Rust Core and Swift compilation. - name: Run database-focused Swift tests - timeout-minutes: 12 - run: ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/macos-database.json --suite-timeout-seconds 660 -- --filter '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' + 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 -- --filter '(LitheDatabaseModuleTests|LitheTests\.LitheCoreLogicTests/database)' - name: Generate combined database test report if: always() diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index c424a3123..3bc3cf131 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -149,13 +149,15 @@ jobs: uses: ./.github/actions/prepare-macos-dependency-cache with: swiftpm: "true" + cargo: "true" + # The suite deadline includes cold Rust Core and Swift compilation. - name: Run Swift unit tests - timeout-minutes: 12 + timeout-minutes: 17 run: | ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ --report .artifacts/test-stability/macos-unit.json \ - --suite-timeout-seconds 660 -- \ + --suite-timeout-seconds 960 -- \ --skip fullSignedDiskImageStagesWithoutChangingInstalledApp \ --skip StableRollbackDiskImageIntegrationTests \ --skip GitStatusObservationTests \ diff --git a/.github/workflows/ci-plugins.yml b/.github/workflows/ci-plugins.yml index 06aa13242..4fccdf393 100644 --- a/.github/workflows/ci-plugins.yml +++ b/.github/workflows/ci-plugins.yml @@ -93,10 +93,12 @@ jobs: uses: ./.github/actions/prepare-macos-dependency-cache with: swiftpm: "true" + cargo: "true" + # The suite deadline includes cold Rust Core and Swift compilation. - name: Run plugin-focused Swift tests - timeout-minutes: 12 - run: ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/macos-plugins.json --suite-timeout-seconds 660 -- --filter '([Pp]lugin|LitheGoSupportModuleTests|LithePhpSupportModuleTests|LinuxDo)' + timeout-minutes: 17 + run: ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --report .artifacts/test-stability/macos-plugins.json --suite-timeout-seconds 960 -- --filter '([Pp]lugin|LitheGoSupportModuleTests|LithePhpSupportModuleTests|LinuxDo)' - name: Build and verify official plugin packages timeout-minutes: 10 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 55d54b65b..16875d359 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -460,9 +460,11 @@ jobs: --max-ms 15000 ` --test-budget lsp::interface::engine::real_jdt_tests::real_jdtls_discovers_builds_and_launches_java_25_entrypoints=600000 ` --test-budget tests::git_history_rewrite::=30000 ` + --test-budget tests::git_workspace_commit::git_workspace_commit_push_checks_submodule_publication_before_updating_the_remote=30000 ` --test-budget tests::git::git_write_squashes_=30000 ` --test-budget tests::git::git_write_deletes_a_local_commit_=30000 ` --test-budget tests::git::git_write_edits_a_local_commit_message_=30000 ` + --test-budget tests::git_patch_exchange::patch_metadata_can_list_an_oversized_export_before_selecting_a_small_subset=30000 ` --build-timeout-ms 600000 ` --suite-timeout-ms 1200000 ` --report .artifacts/test-stability/windows-real-jdt.json diff --git a/README.md b/README.md index 45bfc01ba..a7bb6eaf1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@

+https://github.com/user-attachments/assets/17e2325b-afed-4bbb-9926-a962f777269b + ## Join the community Welcome to join the Lithe community, share your experience, ask questions, and follow the latest updates. @@ -110,20 +112,26 @@ Only use these steps for an app whose source you trust. Homebrew installations u

Double-Shift Search Everywhere - Command Shift F project-wide search and replace + Side-by-side Git diff review

- Side-by-side Git diff review + Generate commit messages with AI + Automatic project detection and one-click run configuration

+
+More screenshots +
+

- Import AI provider settings from local tools + Command Shift F project-wide search and replace Customize AI commit message formats

- Generate commit messages with AI + Import AI provider settings from local tools + Per-language service settings

@@ -137,19 +145,16 @@ Only use these steps for an app whose source you trust. Homebrew installations u

- Automatic project detection and one-click run configuration -

- -

- Per-language service settings Multi-line editor tabs + Database connection workspace

- Database connection workspace Database SQL operation and table structure

+
+ ## Download and install - **macOS 13+:** Download the `arm64` DMG for Apple silicon or the `x86_64` DMG for Intel Macs from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). diff --git a/README.zh-CN.md b/README.zh-CN.md index c94d64702..d7b891dab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,6 +31,8 @@

+https://github.com/user-attachments/assets/17e2325b-afed-4bbb-9926-a962f777269b + ## 欢迎加入群聊 欢迎加入 Lithe 社区,分享使用体验、交流问题,并获取项目最新动态。 @@ -110,20 +112,26 @@ Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替

双击 Shift 全局搜索 - Command Shift F 全局搜索和替换 + Git 双栏 Diff 审查

- Git 双栏 Diff 审查 + 使用 AI 自动生成 Commit Message + 项目自动识别和一键运行配置

+
+更多截图 +
+

- 从本机 AI 工具导入 API 配置 + Command Shift F 全局搜索和替换 自定义 AI Commit Message 格式

- 使用 AI 自动生成 Commit Message + 从本机 AI 工具导入 API 配置 + 按语言配置语言服务

@@ -137,19 +145,16 @@ Lithe 是一款主要面向 Java 和 Spring Boot 开发者的轻量级 IDEA 替

- 项目自动识别和一键运行配置 -

- -

- 按语言配置语言服务 多行编辑器标签 + 数据库连接工作台

- 数据库连接工作台 数据库 SQL 操作和表结构

+
+ ## 下载与安装 - **macOS 13+:**前往 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载 `.dmg`。M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index ef96ea0f2..07bff98f0 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -111,6 +111,13 @@ LITHE_RUN_GIT_COMPOSITOR_TESTS=1 \ 没有可用显示或没有收到显示链接回调时,用例输出未采样原因,不能将其计为真实 帧率验证。不要在普通开发验证或无人值守 CI 中默认设置该环境变量。 +Swift 文本文件策略直接调用 Rust Core,因此 `scripts/test-macos.sh` 会先构建并链接 +当前工作树的 Core 静态库。它复用现有 `rust/target/macos` 构建路径;这是可变编译状态, +不允许跨工作树复制或共享,也没有新增可复用资源目录。依赖下载仍按下文清单复用。 +Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存;包含冷构建的套件总时限 +为 960 秒,外层步骤为 17 分钟,给超时清理与报告留出一分钟。单测试预算和无输出 +看门狗仍由原计时工具控制。 + ### 独立工作树的本地编译 Git worktree 只共享 Git 对象,不共享各自的 `.artifacts` 目录。如果从一个 @@ -172,6 +179,10 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 - `.build` 中的 SwiftPM 构建状态; - `rust/target/` 中与当前源码、编译器或构建参数绑定的构建输出; - LSP workspace `-data`、运行时数据库、测试报告和其他会被进程修改的状态。 +- 平台缓存中 JDT `-data/.lithe/maven/` 的 settings 副本:按工作区隔离,可能含凭据, + 在缓存过期或重建索引时清理;文件内容哈希不是版本、平台、架构或工具链构建 + identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, + 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。应用缓存下 `language-tools//` 是插件拥有的可变运行时资源,随插件卸载清理,不是构建缓存。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index b280121c5..04a1222ad 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -15,6 +15,7 @@ workspace-tabs-projects,工作区,工作区生命周期,多项目与多标签, editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs editor-pointer-selection,编辑器,文本编辑,鼠标与触控板点击、选词和拖选,已实现,待验证,已实现,待验证,Editor,macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。,macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。,macos/EditorFrontend/main.ts; macos/EditorFrontend/mouse-input.ts; macos/Experiments/Monaco/mouse-input.integration.ts,windows/tauri/src/features/editor/components/monaco-editor.tsx editor-language-basics,编辑器,文本编辑,语法高亮与编辑器模型,已实现,待验证,已实现,待验证,Editor,分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; frontend/editor; windows/tauri/src/features/editor/engines/monaco/theme.ts +editor-wallpaper-overview-ruler,编辑器,编辑器外观,macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景,已实现,待验证,平台专属,不适用,Editor,在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。,,frontend/editor/src/theme.ts; macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift; macos/Experiments/Monaco/workbench.integration.ts,windows/tauri/src/features/editor/engines/monaco/theme.ts editor-semantic-highlighting,编辑器,文本编辑,LSP 语义高亮,已实现,待验证,已实现,待验证,Editor / Language Tooling,打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。,Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。,macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift; macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift; frontend/editor/src/semantic-tokens.ts,windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/features/editor/lsp/lsp-client.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts; windows/tauri/src/platform/lsp-core-adapter.test.ts editor-multiline-tabs,编辑器,文本编辑,多行标签与标签导航,已实现,待验证,已实现,待验证,Editor,打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/tabs search-quick-open,搜索,搜索与导航,快速打开文件与符号,已实现,待验证,已实现,待验证,Search,使用文件名、路径和符号查询,确认排序、键盘导航和打开位置。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Views/Workbench,windows/tauri/src/features/quick-open @@ -36,11 +37,12 @@ ai-chat-session,AI,AI 对话,对话会话与流式响应,未实现,不适用,已 ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用,已实现,待验证,AI,Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/ai-chat-history-service.ts; windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现,已实现,待验证,已实现,待验证,Java / Maven,配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run,windows/tauri/src/features/maven; windows/tauri/src/features/workspace -java-maven-project-settings,Java,Java/Maven,按项目配置 Maven 工具链、settings.xml 与本地仓库,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。,,macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift,windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx; windows/tauri/src/features/maven/stores/maven.store.ts; windows/tauri/src-tauri/src/maven.rs; windows/tauri/src-tauri/src/maven/settings_xml.rs; windows/tauri/src/features/maven/components/maven-detected-value.test.tsx; windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx +java-maven-project-settings,Java,Java/Maven,按项目配置 Maven 工具链、settings.xml 与本地仓库,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。,,macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift,windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx; windows/tauri/src/features/maven/stores/maven.store.ts; windows/tauri/src-tauri/src/maven.rs; windows/tauri/src-tauri/src/maven/settings_xml.rs; windows/tauri/src/features/maven/components/maven-detected-value.test.tsx; windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx; windows/tauri/src/features/run/stores/run.store.ts; windows/tauri/src/features/run/stores/run-maven-context.test.ts java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace -java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs +java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs +java-maven-settings-reach-jdt,Java,Java/Maven,Maven 设置与重新加载实时作用于 Java 语言服务,并展示依赖解析问题,已实现,待验证,已实现,待验证,Java / Maven,打开一个依赖尚未下载的 Maven 项目,在 Java 语言服务导入过程中修改 settings.xml、本地仓库或镜像并保存,不点重新加载,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后点「重新加载」,确认 Java 语言服务不重启而依赖重新解析;故意写错一个依赖版本,确认 Maven 工具窗口列出 pom.xml 问题、通知可跳转到对应行,修正后问题消失;初始化期间设置不变时点击重新加载,确认导入就绪后仍执行一次强制更新;旧 profile 任务失败或超时期间切换 profile,确认旧请求结束后应用最新选择;保存设置与重新加载交错执行时,已发送的 settings 副本保持可读。,,macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift; macos/Sources/Lithe/Views/Run/MavenView.swift; rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs,windows/tauri/src/features/maven/services/java-maven-configuration.ts; windows/tauri/src/features/maven/services/reload-maven-workspace.ts; windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts; windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/platform/tauri-core.ts; rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs java-maven-toolbar-tooltips,Java,Java/Maven,Maven 工具栏图标悬停说明,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。,,macos/Sources/Lithe/Views/Run/MavenView.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/maven/components/maven-pane.tsx -java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics +java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 Windows Maven 构建输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics; windows/tauri/src/features/maven/components/maven-run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx spring-index,Java,Spring / MyBatis,Spring 配置、Bean 与 Endpoint 索引,已实现,待验证,已实现,待验证,Java / Spring,使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Run; shared/fixtures/spring,windows/tauri/src/features/spring; shared/fixtures/spring spring-navigation,Java,Spring / MyBatis,Spring 符号导航与代码关联,已实现,待验证,已实现,待验证,Java / Spring,从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Language,windows/tauri/src/features/spring spring-endpoints-search,Java,Spring / MyBatis,Spring HTTP 接口筛选与源码跳转,已实现,待验证,已实现,待验证,Java / Spring,在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。,,macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift; macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift,windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx; windows/tauri/src/features/spring/hooks/use-spring-index.ts; windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx; windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -49,9 +51,9 @@ lsp-lifecycle,Java,语言服务,LSP/JDTLS 启动与工作区准备,已实现,待 lsp-completion-hover,Java,语言服务,补全与 Hover,已实现,待验证,已实现,待验证,Language Tooling,在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。,,macos/Sources/Lithe/Core/Language; shared/fixtures/lsp,windows/tauri/src/features; shared/fixtures/lsp lsp-diagnostics,Java,语言服务,诊断与编译错误定位,已实现,待验证,已实现,待验证,Language Tooling,制造语法和类型错误,比较诊断等级、消息、行列号和清理行为。,,macos/Sources/Lithe/Core/Language; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics; shared/fixtures/lsp lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑,已实现,待验证,已实现,待验证,Language Tooling,验证定义、引用、语义标记和 UTF-16 文本编辑的坐标转换。,,macos/Sources/Lithe/Core/Language; shared/contracts/rust-core-api.md,windows/tauri/src/features; shared/fixtures/lsp -run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration +run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven -run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug +run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug; windows/tauri/src/features/run/components/run-pane.tsx; windows/tauri/src/features/run/components/run-output-text.tsx run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动缩短,已实现,待验证,已实现,待验证,Run,使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。,,macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift; macos/Sources/LitheExecutionModule/Services/RunService.swift; rust/lithe-core/src/execution/launch_command.rs,windows/tauri/src-tauri/src/run/launch_arguments.rs; rust/lithe-core/src/execution/launch_command.rs debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug @@ -73,21 +75,23 @@ outline-symbols,编辑器,代码结构,当前文件 Outline 与符号树,部分 references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实现,待验证,已实现,待验证,Language Tooling,从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。,,macos/Sources/Lithe/Views/Language/JavaReferencesView.swift,windows/tauri/src/features/references diagnostics-workspace,可观测性,诊断与日志,工作区诊断与问题面板,已实现,待验证,已实现,待验证,Diagnostics,制造多个来源的错误和警告,验证筛选、排序、定位和清除。,,macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift; macos/Sources/Lithe/Views/Language/JavaProblemsView.swift,windows/tauri/src/features/diagnostics; windows/tauri/src/features/logging; windows/tauri/src/ui/dialog.tsx diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实现,待验证,已实现,待验证,Diagnostics,导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。,,macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics -application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift,windows/tauri/src/features/settings -keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx -plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings +application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift; macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift,windows/tauri/src/features/settings +keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx +plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings; windows/tauri/src-tauri/src/language_tools.rs; windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette +workbench-compact-menu-bar,工作台,命令与布局,紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区,平台专属,不适用,已实现,待验证,Workbench,在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。,,macos/Sources/Lithe/LitheApp.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx; windows/tauri/src/features/window/components/window-menu-bar.tsx; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx +window-controls,工作台,窗口管理,标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏,已实现,待验证,已实现,待验证,Workbench,在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift,windows/tauri/src/features/window/components/title-bar/window-controls.tsx; windows/tauri/src/features/command-palette/constants/window-actions.tsx; windows/tauri/src/features/window/utils/window-actions.ts workbench-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。,,macos/Sources/Lithe/Views/Workbench; macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Resources/IDEAIcons/expui; macos/Resources/IDEAIcons/terminal,windows/tauri/src/features/panes; windows/tauri/src/features/layout workbench-activity-tooltips,工作台,工作台布局,活动栏图标的稳定悬停说明,已实现,待验证,已实现,待验证,Workbench,逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。,,macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx workbench-activity-rail-window-edge,工作台,工作台布局,活动栏贴合窗口外沿,已实现,待验证,已实现,待验证,Workbench,打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。,,macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift,windows/tauri/src/features/layout/components/main-layout.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx; windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx -embedded-viewers,编辑器,资源预览,HTML、SVG、媒体、PDF 与二进制预览,已实现,待验证,已实现,待验证,Editor,分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。,,macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift; macos/Sources/Lithe/Views/Editor/MediaViewerView.swift; macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift,windows/tauri/src/features/viewer +embedded-viewers,编辑器,资源预览,HTML、SVG、媒体、PDF 与二进制预览,已实现,待验证,已实现,待验证,Editor,分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。,,macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift; macos/Sources/Lithe/Views/Editor/MediaViewerView.swift; macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift; macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift,windows/tauri/src/features/viewer; windows/tauri/src-tauri/src/html_browser.rs; windows/tauri/src/features/editor/components/html/html-preview.tsx docker-compose,运行与调试,项目运行,Docker / Compose 项目识别与运行,部分实现,待验证,已实现,待验证,Run / Docker,使用 Dockerfile 和 Compose fixture 验证识别、命令参数、输出和进程停止;确认 macOS 是否只有入口识别。,,macos/Sources/Lithe/Views/Run/RunConfigurationIcon.swift; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/docker; windows/tauri/src/features/run remote-workspace,工作区,远程开发,远程连接与远程路径工作区,未实现,不适用,已实现,待验证,Remote,Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。,,macos/Sources/Lithe/Services,windows/tauri/src/features/remote; windows/tauri/src/features/file-system vim-mode,编辑器,编辑模式,Vim 模式与命令状态,未实现,不适用,已实现,待验证,Editor,Windows 验证 Normal/Insert/Visual 模式、命令执行和设置持久化;macOS 需要确认产品范围。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/vim; windows/tauri/src/features/editor workbench-notifications,可观测性,通知与资源,操作通知、错误提示与自动消失,已实现,待验证,已实现,待验证,Workbench,触发成功、警告和失败通知,验证队列、关闭、超时和重复通知。,,macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift,windows/tauri/src/features/notifications memory-monitor,可观测性,通知与资源,应用与语言服务内存监控,已实现,待验证,已实现,待验证,Workbench,打开内存指标并观察应用、语言服务和总内存变化,确认采样失败不会阻塞工作台。,,macos/Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift,windows/tauri/src/features/layout/services/memory-api.ts; windows/tauri/src/platform/tauri-core.ts -updates-check-download,发行与更新,应用更新,检查、下载与安装更新,已实现,待验证,已实现,待验证,Release,使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。,,macos/Sources/Lithe/Views/App/UpdateControl.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Platform/MacOS/Updates,windows/tauri/src/features/layout/components/app-update-control.tsx; windows/tauri/src/features/layout/components/app-update-details-dialog.tsx; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/hooks/use-updater.ts +updates-check-download,发行与更新,应用更新,检查、下载与安装更新,已实现,待验证,已实现,待验证,Release,使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 Windows 未检查、正在检查、跳过版本和失败时不得显示已是最新版;菜单通知明确指 Lithe 主程序,不代表语言扩展状态。,,macos/Sources/Lithe/Views/App/UpdateControl.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Platform/MacOS/Updates,windows/tauri/src/features/layout/components/app-update-control.tsx; windows/tauri/src/features/layout/components/app-update-details-dialog.tsx; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/hooks/use-updater.ts; windows/tauri/src/features/settings/stores/update.store.test.ts updates-failure-rollback,发行与更新,应用更新,失败重试与回滚,部分实现,待验证,已实现,待验证,Release,模拟清单错误、下载失败和安装失败,确认重试、回滚和用户提示。,macOS 回退更新方案仍在 #734 跟进。,macos/Sources/Lithe/Platform/MacOS/Updates,docs/releases/windows-updater.md; windows/tauri/src/features/settings/hooks/use-updater.ts community-feed,社区,LINUX DO 社区,主题列表与详情,未实现,不适用,未实现,不适用,Community,如果重新引入社区入口,在 macOS 和 Windows 验证 Latest/Top、主题详情、分页和浏览器跳转。,macOS 的 LINUX DO 插件已退役,当前两端均无社区入口。,macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift,windows/tauri/src/features community-auth,社区,LINUX DO 社区,社区认证与请求失败处理,未实现,不适用,未实现,不适用,Community,如果重新引入社区入口,在 macOS 和 Windows 验证认证、过期、限流和网络错误。,macOS 的 LINUX DO 插件已退役,现存 fixture 不代表产品接入。,macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift; shared/fixtures/community,windows/tauri/src/features @@ -100,3 +104,4 @@ php-run-test,语言支持,PHP,PHP 插件运行与 PHPUnit 测试,已实现,待 git-workspace-staged-commit,版本控制,Git,按文件所属仓库批量提交及推送,保留每仓库结果,已实现,待验证,已实现,待验证,Git,勾选两个独立仓库文件,一次提交并推送,验证各自 HEAD 和远程;停止一个操作后其余独立仓库继续,已成功仓库不会回滚。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-submodule-commit-plan,版本控制,Git,子模块先提交与推送、自动更新父引用、计划变化再次确认,已实现,待验证,已实现,待验证,Git,只勾选孙仓库文件,确认计划自动列出父祖仓库的引用更新且可关闭;确认期间改变暂存内容或分支,必须显示新计划再次确认。父仓库未暂存文件不能被带入;只勾选父引用时先推送子仓库现有提交,Git 发布检查应拒绝未发布的子引用。子仓库元数据被外部删除后必须拒绝读取和写入,不能向上回退父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx git-workspace-commit-retry,版本控制,Git,失败后只重试未完成的提交或推送步骤,已实现,待验证,已实现,待验证,Git,让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift; macos/Sources/Lithe/Views/Git/CommitAreaView.swift; macos/Tests/LitheGitModuleTests/GitModuleTests.swift; rust/lithe-core/src/git/commit_state.rs; rust/lithe-core/src/tests/git_workspace_commit.rs; rust/lithe-core/src/git/workspace_commit.rs; rust/lithe-core/src/git/workspace_commit/tests.rs; shared/fixtures/git/workspace-commit-workflow-v1.json,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json; windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx; windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx +editor-unicode-text-classification,编辑器,文本编辑,中文 properties 与未知扩展名文本的一致打开和恢复,已实现,待验证,已实现,待验证,Editor,双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。,,macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift; macos/Tests/LitheTests/TextContentPolicyTests.swift; frontend/editor/src/language-contributions.ts,windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts; windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts; frontend/editor/src/language-contributions.ts diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 898919c65..1a720daa8 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,11 +2,11 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-27 +- 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:101 -- macOS:实现:✅ 88 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 91 待验证,— 10 不适用 -- Windows:实现:✅ 87 已实现,🟡 10 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 97 待验证,— 4 不适用 +- 功能项:106 +- macOS:实现:✅ 92 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 95 待验证,— 11 不适用 +- Windows:实现:✅ 91 已实现,🟡 10 部分实现,❌ 4 未实现,🧩 1 平台专属;验证:✔️ 0 已验证,🔍 101 待验证,— 5 不适用 ## 实现状态定义 @@ -66,23 +66,25 @@
-编辑器 · 13 个能力点 +编辑器 · 15 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 文本编辑 | **文本编辑与标签生命周期**
editor-text-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/tabs` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 | | | 文本编辑 | **鼠标与触控板点击、选词和拖选**
editor-pointer-selection | ✅ 已实现
🔍 待验证
`macos/EditorFrontend/main.ts`、`macos/EditorFrontend/mouse-input.ts`、`macos/Experiments/Monaco/mouse-input.integration.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/components/monaco-editor.tsx` | Editor | macOS 分别启用微信输入法和系统 ABC,快速轻点不同位置后松手移动,确认不扩展选区、不移动文字;验证同位置双击选词、Shift 点击扩选、按住拖选、选中文字拖放、主编辑器和分屏及 diff。运行 scripts/probe-macos-monaco.sh --workbench-tests;Windows 验证同样的正常鼠标交互。 | macOS 在宿主修正 WebKit 零按钮按下事件,保留 Monaco 选择语义。Linux Chromium 真实 Monaco 控制事件回放通过,仍待 macOS 微信输入法与 Windows WebView2 实机验证。 | | 文本编辑 | **语法高亮与编辑器模型**
editor-language-basics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`frontend/editor`、`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。 | | +| 编辑器外观 | **macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景**
editor-wallpaper-overview-ruler | ✅ 已实现
🔍 待验证
`frontend/editor/src/theme.ts`、`macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift`、`macos/Experiments/Monaco/workbench.integration.ts` | 🧩 平台专属
— 不适用
`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。 | | | 文本编辑 | **LSP 语义高亮**
editor-semantic-highlighting | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift`、`macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift`、`frontend/editor/src/semantic-tokens.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/features/editor/lsp/lsp-client.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts`、`windows/tauri/src/platform/lsp-core-adapter.test.ts` | Editor / Language Tooling | 打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。 | Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。 | | 文本编辑 | **多行标签与标签导航**
editor-multiline-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/tabs` | Editor | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | | Markdown | **Markdown 预览与富文本渲染**
markdown-rendering | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift`、`macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown` | Editor | 使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。 | | | Markdown | **图片导入与链接定位**
markdown-images-links | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Markdown`、`macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown`、`windows/tauri/src/features/viewer` | Editor | 验证本地图片、远程图片、相对链接和打开源文件行为。 | | | 代码结构 | **当前文件 Outline 与符号树**
outline-symbols | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Views/Language`、`macos/Sources/Lithe/Views/Search` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/outline` | Language Tooling | 使用 Java 文件验证符号树、展开折叠、排序和跳转;确认 macOS 是否提供同等独立 Outline 入口。 | | | 代码结构 | **引用结果面板与引用跳转**
references-pane | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Language/JavaReferencesView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/references` | Language Tooling | 从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。 | | -| 资源预览 | **HTML、SVG、媒体、PDF 与二进制预览**
embedded-viewers | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift`、`macos/Sources/Lithe/Views/Editor/MediaViewerView.swift`、`macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/viewer` | Editor | 分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 | | +| 资源预览 | **HTML、SVG、媒体、PDF 与二进制预览**
embedded-viewers | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift`、`macos/Sources/Lithe/Views/Editor/MediaViewerView.swift`、`macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift`、`macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/viewer`、`windows/tauri/src-tauri/src/html_browser.rs`、`windows/tauri/src/features/editor/components/html/html-preview.tsx` | Editor | 分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 | | | 编辑模式 | **Vim 模式与命令状态**
vim-mode | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/vim`、`windows/tauri/src/features/editor` | Editor | Windows 验证 Normal/Insert/Visual 模式、命令执行和设置持久化;macOS 需要确认产品范围。 | | | 文本编辑 | **按指定编码重新打开文本文件**
editor-file-encoding-reopen | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/services/document-encoding-workflow.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | | 文本编辑 | **按指定编码保存文本文件**
editor-file-encoding-save | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/stores/editor-app.store.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | | +| 文本编辑 | **中文 properties 与未知扩展名文本的一致打开和恢复**
editor-unicode-text-classification | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift`、`macos/Tests/LitheTests/TextContentPolicyTests.swift`、`frontend/editor/src/language-contributions.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts`、`windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts`、`frontend/editor/src/language-contributions.ts` | Editor | 双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。 | |
@@ -128,16 +130,17 @@
-Java · 14 个能力点 +Java · 15 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | Java/Maven | **JDK 与 Maven 工具链发现**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。 | | -| Java/Maven | **按项目配置 Maven 工具链、settings.xml 与本地仓库**
java-maven-project-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx`、`windows/tauri/src/features/maven/stores/maven.store.ts`、`windows/tauri/src-tauri/src/maven.rs`、`windows/tauri/src-tauri/src/maven/settings_xml.rs`、`windows/tauri/src/features/maven/components/maven-detected-value.test.tsx`、`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx` | Java / Maven | 在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。 | | +| Java/Maven | **按项目配置 Maven 工具链、settings.xml 与本地仓库**
java-maven-project-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx`、`windows/tauri/src/features/maven/stores/maven.store.ts`、`windows/tauri/src-tauri/src/maven.rs`、`windows/tauri/src-tauri/src/maven/settings_xml.rs`、`windows/tauri/src/features/maven/components/maven-detected-value.test.tsx`、`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx`、`windows/tauri/src/features/run/stores/run.store.ts`、`windows/tauri/src/features/run/stores/run-maven-context.test.ts` | Java / Maven | 在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。 | | | Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | -| Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 | | +| Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。 | | +| Java/Maven | **Maven 设置与重新加载实时作用于 Java 语言服务,并展示依赖解析问题**
java-maven-settings-reach-jdt | ✅ 已实现
🔍 待验证
`macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift`、`macos/Sources/Lithe/Views/Run/MavenView.swift`、`rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/services/java-maven-configuration.ts`、`windows/tauri/src/features/maven/services/reload-maven-workspace.ts`、`windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts`、`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/platform/tauri-core.ts`、`rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs` | Java / Maven | 打开一个依赖尚未下载的 Maven 项目,在 Java 语言服务导入过程中修改 settings.xml、本地仓库或镜像并保存,不点重新加载,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后点「重新加载」,确认 Java 语言服务不重启而依赖重新解析;故意写错一个依赖版本,确认 Maven 工具窗口列出 pom.xml 问题、通知可跳转到对应行,修正后问题消失;初始化期间设置不变时点击重新加载,确认导入就绪后仍执行一次强制更新;旧 profile 任务失败或超时期间切换 profile,确认旧请求结束后应用最新选择;保存设置与重新加载交错执行时,已发送的 settings 副本保持可读。 | | | Java/Maven | **Maven 工具栏图标悬停说明**
java-maven-toolbar-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/MavenView.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/components/maven-pane.tsx` | Java / Maven | 在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。 | | -| Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 | | +| Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics`、`windows/tauri/src/features/maven/components/maven-run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 Windows Maven 构建输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | | Spring / MyBatis | **Spring 配置、Bean 与 Endpoint 索引**
spring-index | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Run`、`shared/fixtures/spring` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring`、`shared/fixtures/spring` | Java / Spring | 使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。 | | | Spring / MyBatis | **Spring 符号导航与代码关联**
spring-navigation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Language` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring` | Java / Spring | 从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。 | | | Spring / MyBatis | **Spring HTTP 接口筛选与源码跳转**
spring-endpoints-search | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift`、`macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx`、`windows/tauri/src/features/spring/hooks/use-spring-index.ts`、`windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx`、`windows/tauri/src/features/keymaps/commands/view-command-actions.ts` | Java / Spring | 在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。 | | @@ -154,9 +157,9 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因。 | | +| 运行配置 | **入口点与运行配置发现**
run-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/run-configuration` | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。 | | | 运行配置 | **保存前同步与工具链解析**
run-save-toolchain | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`windows/tauri/src/features/maven` | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | | -| 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 | | +| 运行配置 | **Java main 与测试运行**
run-java-test | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/run`、`shared/fixtures/debug`、`windows/tauri/src/features/run/components/run-pane.tsx`、`windows/tauri/src/features/run/components/run-output-text.tsx` | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 | | | 运行配置 | **超长 Java 类路径自动缩短**
run-java-long-classpath | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift`、`macos/Sources/LitheExecutionModule/Services/RunService.swift`、`rust/lithe-core/src/execution/launch_command.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src-tauri/src/run/launch_arguments.rs`、`rust/lithe-core/src/execution/launch_command.rs` | Run | 使用包含大量依赖的 Java 项目运行 main,确认超长类路径自动写入参数文件、进程可启动且参数文件在退出后清理。 | | | 调试器 | **启动调试与断点**
debug-breakpoints | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 | | 调试器 | **变量、异常与断开策略**
debug-state | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/debugger`、`shared/fixtures/debug` | Debug | 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 | Windows 真实调试产品链路仍在 #466 跟进。 | @@ -165,7 +168,7 @@
-工作台 · 10 个能力点 +工作台 · 12 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -176,6 +179,8 @@ | 本地历史 | **快照列表与 Diff**
history-snapshots | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 | | | 本地历史 | **恢复、删除与持久化**
history-restore | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 验证恢复、删除、应用重启后持久化和失败回滚。 | | | 命令与布局 | **命令面板与全局动作**
command-palette | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift`、`macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette` | Workbench | 搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。 | | +| 命令与布局 | **紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区**
workbench-compact-menu-bar | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/LitheApp.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx`、`windows/tauri/src/features/window/components/window-menu-bar.tsx`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx` | Workbench | 在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。 | | +| 窗口管理 | **标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏**
window-controls | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/window-controls.tsx`、`windows/tauri/src/features/command-palette/constants/window-actions.tsx`、`windows/tauri/src/features/window/utils/window-actions.ts` | Workbench | 在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 | | | 命令与布局 | **分栏、面板与工具窗布局**
workbench-panes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench`、`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Resources/IDEAIcons/expui`、`macos/Resources/IDEAIcons/terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/panes`、`windows/tauri/src/features/layout` | Workbench | 打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 | | | 工作台布局 | **活动栏图标的稳定悬停说明**
workbench-activity-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx` | Workbench | 逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。 | | | 工作台布局 | **活动栏贴合窗口外沿**
workbench-activity-rail-window-edge | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/main-layout.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx`、`windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx` | Workbench | 打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。 | | @@ -211,8 +216,8 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。 | | -| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 | +| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift`、`macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 | | +| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 |
@@ -221,7 +226,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings` | Extensions | 启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。 | | +| 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings`、`windows/tauri/src-tauri/src/language_tools.rs`、`windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts` | Extensions | macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。 | | @@ -239,7 +244,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 应用更新 | **检查、下载与安装更新**
updates-check-download | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/UpdateControl.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Platform/MacOS/Updates` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/app-update-control.tsx`、`windows/tauri/src/features/layout/components/app-update-details-dialog.tsx`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/hooks/use-updater.ts` | Release | 使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 | | +| 应用更新 | **检查、下载与安装更新**
updates-check-download | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/UpdateControl.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Platform/MacOS/Updates` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/app-update-control.tsx`、`windows/tauri/src/features/layout/components/app-update-details-dialog.tsx`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/hooks/use-updater.ts`、`windows/tauri/src/features/settings/stores/update.store.test.ts` | Release | 使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 Windows 未检查、正在检查、跳过版本和失败时不得显示已是最新版;菜单通知明确指 Lithe 主程序,不代表语言扩展状态。 | | | 应用更新 | **失败重试与回滚**
updates-failure-rollback | 🟡 部分实现
🔍 待验证
`macos/Sources/Lithe/Platform/MacOS/Updates` | ✅ 已实现
🔍 待验证
`docs/releases/windows-updater.md`、`windows/tauri/src/features/settings/hooks/use-updater.ts` | Release | 模拟清单错误、下载失败和安装失败,确认重试、回滚和用户提示。 | macOS 回退更新方案仍在 #734 跟进。 | diff --git a/frontend/editor/src/language-contributions.ts b/frontend/editor/src/language-contributions.ts index a38a9dae4..ae4ad0ba7 100644 --- a/frontend/editor/src/language-contributions.ts +++ b/frontend/editor/src/language-contributions.ts @@ -11,6 +11,7 @@ import "monaco-editor/esm/vs/basic-languages/graphql/graphql.contribution.js"; import "monaco-editor/esm/vs/basic-languages/hcl/hcl.contribution.js"; import "monaco-editor/esm/vs/basic-languages/html/html.contribution.js"; import "monaco-editor/esm/vs/basic-languages/java/java.contribution.js"; +import "monaco-editor/esm/vs/basic-languages/ini/ini.contribution.js"; import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js"; import "monaco-editor/esm/vs/basic-languages/kotlin/kotlin.contribution.js"; import "monaco-editor/esm/vs/basic-languages/less/less.contribution.js"; @@ -53,6 +54,7 @@ const monarchLanguageLoaders: Record Promise import("monaco-editor/esm/vs/basic-languages/graphql/graphql.js"), hcl: () => import("monaco-editor/esm/vs/basic-languages/hcl/hcl.js"), html: () => import("monaco-editor/esm/vs/basic-languages/html/html.js"), + ini: () => import("monaco-editor/esm/vs/basic-languages/ini/ini.js"), java: () => import("monaco-editor/esm/vs/basic-languages/java/java.js"), javascript: () => import("monaco-editor/esm/vs/basic-languages/javascript/javascript.js"), kotlin: () => import("monaco-editor/esm/vs/basic-languages/kotlin/kotlin.js"), diff --git a/frontend/editor/src/language.ts b/frontend/editor/src/language.ts index 160880bee..2b2735166 100644 --- a/frontend/editor/src/language.ts +++ b/frontend/editor/src/language.ts @@ -18,6 +18,8 @@ export const MONACO_LANGUAGE_BY_LITHE_ID: Record = { graphql: "graphql", html: "html", java: "java", + ini: "ini", + properties: "ini", javascript: "javascript", json: "json", jsonc: "json", diff --git a/frontend/editor/src/theme.ts b/frontend/editor/src/theme.ts index 4ca63b489..cadd04353 100644 --- a/frontend/editor/src/theme.ts +++ b/frontend/editor/src/theme.ts @@ -47,8 +47,9 @@ export function defineWorkbenchTheme(input: WorkbenchThemeInput, palette: Syntax "editorOverviewRuler.wordHighlightTextForeground": "#00000000", }; const surfaceColors: [keyof NonNullable, string[]][] = [ + // Monaco otherwise fills the right overview ruler from tokenizer defaults when the minimap is enabled. ["background", ["editor.background", "editorGutter.background", "editorStickyScroll.background", - "editorStickyScrollGutter.background", "minimap.background"]], + "editorStickyScrollGutter.background", "minimap.background", "editorOverviewRuler.background"]], ["cursor", ["editorCursor.foreground"]], ["selection", ["editor.selectionBackground", "editor.inactiveSelectionBackground"]], ["lineHighlight", ["editor.lineHighlightBackground"]], diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index 5eaec4160..c56e14927 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -393,6 +393,14 @@ async function verify() { }); const surface = editor.getDomNode()!; const backgroundColor = () => getComputedStyle(surface).backgroundColor.replace(/ /g, ""); + const overviewRulerBackground = () => getComputedStyle(surface) + .getPropertyValue("--vscode-editorOverviewRuler-background").trim().replace(/ /g, ""); + const overviewRulerPixel = () => { + editor.render(); + const canvas = surface.querySelector(".decorationsOverviewRuler")!; + assert(canvas.width > 0 && canvas.height > 0, "overview ruler has no render surface"); + return [...canvas.getContext("2d")!.getImageData(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2), 1, 1).data]; + }; try { window.lithe.configure({ fontFamily: "monospace", fontSize: 13, wrap: false, minimap: true, @@ -403,6 +411,9 @@ async function verify() { const solid = backgroundColor(); assert(["rgb(242,243,244)", "rgba(242,243,244,1)"].includes(solid), `host light theme did not replace Monaco's initial dark surface: ${solid}`); + assert(overviewRulerBackground() === "#f2f3f4", + `opaque overview ruler background did not match the editor surface: ${overviewRulerBackground()}`); + assert(overviewRulerPixel().join() === "242,243,244,255", "solid overview ruler rendered with a different background"); window.lithe.configure({ fontFamily: "monospace", fontSize: 13, wrap: false, minimap: true, theme: { id: "probe-light-wallpaper", dark: false, colors: { @@ -412,6 +423,9 @@ async function verify() { const transparent = backgroundColor(); assert(transparent === "rgba(0,0,0,0)", `transparent Monaco surface still covered the native wallpaper: ${transparent}`); + assert(overviewRulerBackground() === "rgba(0,0,0,0)", + `overview ruler still covered the native wallpaper: ${overviewRulerBackground()}`); + assert(overviewRulerPixel().join() === "0,0,0,0", "transparent overview ruler rendered an opaque strip"); } finally { window.lithe.configure({ dark: false, fontFamily: "monospace", fontSize: 13, wrap: false, minimap: true }); await window.lithe.retain([]); diff --git a/macos/Resources/Fonts/Inter-LICENSE.txt b/macos/Resources/Fonts/Inter-LICENSE.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/macos/Resources/Fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/macos/Resources/Fonts/Inter-Regular.otf b/macos/Resources/Fonts/Inter-Regular.otf new file mode 100644 index 000000000..84e6a61c3 Binary files /dev/null and b/macos/Resources/Fonts/Inter-Regular.otf differ diff --git a/macos/Resources/Fonts/Inter-SemiBold.otf b/macos/Resources/Fonts/Inter-SemiBold.otf new file mode 100644 index 000000000..daf4c4413 Binary files /dev/null and b/macos/Resources/Fonts/Inter-SemiBold.otf differ diff --git a/macos/Resources/IDEAIcons/expui/general/chevronRight.svg b/macos/Resources/IDEAIcons/expui/general/chevronRight.svg new file mode 100644 index 000000000..5120ed891 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/chevronRight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/chevronRight_dark.svg b/macos/Resources/IDEAIcons/expui/general/chevronRight_dark.svg new file mode 100644 index 000000000..838142106 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/chevronRight_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/collapseAll.svg b/macos/Resources/IDEAIcons/expui/general/collapseAll.svg new file mode 100644 index 000000000..f5463c66f --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/collapseAll.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/collapseAll_dark.svg b/macos/Resources/IDEAIcons/expui/general/collapseAll_dark.svg new file mode 100644 index 000000000..c66426bb7 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/collapseAll_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/edit.svg b/macos/Resources/IDEAIcons/expui/general/edit.svg new file mode 100644 index 000000000..bd3c0a391 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/edit_dark.svg b/macos/Resources/IDEAIcons/expui/general/edit_dark.svg new file mode 100644 index 000000000..e75126d05 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/edit_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/expandAll.svg b/macos/Resources/IDEAIcons/expui/general/expandAll.svg new file mode 100644 index 000000000..71ccb3cfb --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/expandAll.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/expandAll_dark.svg b/macos/Resources/IDEAIcons/expui/general/expandAll_dark.svg new file mode 100644 index 000000000..51e066ac1 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/expandAll_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/search.svg b/macos/Resources/IDEAIcons/expui/general/search.svg new file mode 100644 index 000000000..021b0c394 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/IDEAIcons/expui/general/search_dark.svg b/macos/Resources/IDEAIcons/expui/general/search_dark.svg new file mode 100644 index 000000000..d5a82d9c1 --- /dev/null +++ b/macos/Resources/IDEAIcons/expui/general/search_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 329b2bc59..4d2109787 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -9,6 +9,18 @@ "Plugins" = "Plugins"; "Marketplace" = "Marketplace"; "Installed" = "Installed"; +"Installed (%lld of %lld enabled)" = "Installed (%lld of %lld enabled)"; +"Install Plugin from Disk…" = "Install Plugin from Disk…"; +"Install PHP Support from a signed plugin package." = "Install PHP Support from a signed plugin package."; +"Install PHP Support Plugin" = "Install PHP Support Plugin"; +"Select a PHP Support plugin package." = "Select a PHP Support plugin package."; +"Plugin marketplace is not available" = "Plugin marketplace is not available"; +"Plugin settings" = "Plugin settings"; +"Appearance & Behavior" = "Appearance & Behavior"; +"Version Control" = "Version Control"; +"Build, Execution, Deployment" = "Build, Execution, Deployment"; +"Languages & Frameworks" = "Languages & Frameworks"; +"Tools" = "Tools"; "Type / to see options" = "Type / to see options"; "Downloaded (%lld of %lld enabled)" = "Downloaded (%lld of %lld enabled)"; "Install" = "Install"; @@ -1291,3 +1303,6 @@ "Waiting for subscription quota…" = "Waiting for subscription quota…"; "ChatGPT sign-in was cancelled." = "ChatGPT sign-in was cancelled."; + +"Could not open HTML in browser" = "Could not open HTML in browser"; +"Choose a default web browser in System Settings, then retry." = "Choose a default web browser in System Settings, then retry."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0ab01e710..c572a80d5 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -6,6 +6,11 @@ "Try a different search term." = "请尝试其他搜索词。"; "Project" = "项目"; "General" = "通用"; +"Appearance & Behavior" = "外观与行为"; +"Build, Execution, Deployment" = "构建、执行、部署"; +"Languages & Frameworks" = "语言与框架"; +"Version Control" = "版本控制"; +"Tools" = "工具"; "Appearance" = "外观"; "Theme" = "主题"; "Color theme" = "配色主题"; @@ -32,6 +37,8 @@ "Keymap" = "快捷键"; "Customize shortcuts for Lithe actions. Changes apply immediately." = "自定义 Lithe 操作的快捷键,修改后立即生效。"; "Search actions or shortcuts" = "搜索操作或快捷键"; +"Expand All" = "全部展开"; +"Collapse All" = "全部折叠"; "Restore All Defaults" = "全部恢复默认"; "Not Assigned" = "未分配"; "Press shortcut…" = "请按下快捷键…"; @@ -1330,6 +1337,18 @@ "Plugins" = "插件"; "Marketplace" = "插件市场"; "Installed" = "已安装"; +"Installed (%lld of %lld enabled)" = "已安装(%lld / %lld 个已启用)"; +"Install Plugin from Disk…" = "从磁盘安装插件…"; +"Install PHP Support from a signed plugin package." = "请从已签名的插件包安装 PHP Support。"; +"Install PHP Support Plugin" = "安装 PHP Support 插件"; +"Select a PHP Support plugin package." = "请选择 PHP Support 插件包。"; +"Plugin marketplace is not available" = "插件市场暂不可用"; +"Plugin settings" = "插件设置"; +"Appearance & Behavior" = "外观与行为"; +"Version Control" = "版本控制"; +"Build, Execution, Deployment" = "构建、执行、部署"; +"Languages & Frameworks" = "语言与框架"; +"Tools" = "工具"; "Type / to see options" = "输入 / 查看选项"; "Downloaded (%lld of %lld enabled)" = "已下载(%lld 个,%lld 个已启用)"; "Install" = "安装"; @@ -1341,10 +1360,6 @@ "Uninstall" = "卸载"; "Overview" = "概览"; "No Plugins" = "暂无插件"; -"More Language Support" = "扩展更多语言"; -"%lld languages · %lld enabled" = "%lld 种语言 · 已启用 %lld 个"; -"Expanded" = "已展开"; -"Collapsed" = "已收起"; "Database" = "数据库连接"; "Enabled" = "已启用"; "Disabled" = "已禁用"; @@ -2519,3 +2534,9 @@ "Waiting for subscription quota…" = "正在等待订阅额度…"; "ChatGPT sign-in was cancelled." = "已取消 ChatGPT 登录。"; +"Maven could not resolve this project (%lld problems): %@" = "Maven 无法解析此项目(%lld 个问题):%@"; +"Maven could not resolve this project (%lld problems)" = "Maven 无法解析此项目(%lld 个问题)"; +"The Java language server did not take the Maven configuration: %@" = "Java 语言服务器未能应用 Maven 配置:%@"; + +"Could not open HTML in browser" = "无法在浏览器中打开 HTML"; +"Choose a default web browser in System Settings, then retry." = "请在系统设置中选择默认网页浏览器,然后重试。"; diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift index bddb79e39..364b0c4d1 100644 --- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -9,6 +9,7 @@ protocol PlatformUI: AnyObject { func chooseFile(title: String, prompt: String) -> URL? func revealInFileBrowser(_ url: URL) func open(_ url: URL) + func openHTMLInBrowser(_ url: URL) async throws func copyToClipboard(_ value: String) func markdownImageFromClipboard() -> MarkdownImageSource? func startAccessingProject(_ url: URL) -> Bool @@ -16,6 +17,10 @@ protocol PlatformUI: AnyObject { } extension PlatformUI { + func openHTMLInBrowser(_ url: URL) async throws { + throw CocoaError(.featureUnsupported) + } + func activateApplication() {} func startAccessingProject(_ url: URL) -> Bool { false } func stopAccessingProject(_ url: URL) {} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index f82ab67af..7d14671b0 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1816,6 +1816,12 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let sessionId: String } + private struct LspUpdateMavenConfigurationRequest: Encodable { + let sessionId: String + let mavenContext: MavenLaunchContext + let reloadProjects: Bool + } + private struct LspSyncDocumentRequest: Encodable { struct Change: Encodable { let range: Range @@ -3491,6 +3497,23 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Sends a changed Maven context to a running JDTLS session. JDT LS + /// re-resolves in place instead of restarting on its unchanged workspace state. + func lspUpdateMavenConfiguration( + sessionID: String, + context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result { + executeResult( + command: "lsp.updateMavenConfiguration", + payload: LspUpdateMavenConfigurationRequest( + sessionId: sessionID, + mavenContext: context, + reloadProjects: reloadProjects + ) + ) + } + /// Publishes the current text of a document. Rust decides whether that means /// an open or a change, and assigns the version. func lspSyncDocument( diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index c6d0b5336..349896de1 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -88,6 +88,18 @@ extension RustCoreBridge: LanguageServerRuntimeCore { lspRetryMavenProfiles(sessionID: sessionID).map { _ in () }.mapError(Self.runtimeFailure) } + func updateMavenConfiguration( + sessionID: String, + context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result { + lspUpdateMavenConfiguration( + sessionID: sessionID, + context: context, + reloadProjects: reloadProjects + ).mapError(Self.runtimeFailure) + } + func syncLanguageServerDocument( sessionID: String, fileURL: URL, diff --git a/macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift b/macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift new file mode 100644 index 000000000..005b809e6 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift @@ -0,0 +1,16 @@ +import Foundation +import LitheRustCore + +/// Core owns the Unicode control-character policy; native adapters own decoding. +enum RustTextContentPolicy { + static func isPlainText(_ text: String) -> Bool { + let length = text.utf8.count + let result = text.withCString { bytes in + bytes.withMemoryRebound(to: UInt8.self, capacity: length + 1) { + lithe_bridge_is_plain_text($0, length) + } + } + if result < 0 { NSLog("Rust text classification unavailable or invalid UTF-8 input") } + return result == 1 + } +} diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 078f484c9..aa3baf8e4 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -671,16 +671,13 @@ struct LitheApp: App { .windowStyle(.hiddenTitleBar) Window(settingsWindowTitle(for: settings.language), id: LitheWindowID.settings) { - SettingsWindow( - model: model, - settings: settings - ) + SettingsWindowHost(projectSessions: projectSessions, settings: settings) .tint(LitheTheme.accent) .environmentObject(settings) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) } - .defaultSize(width: 1040, height: 720) + .defaultSize(width: 900, height: 668) .windowResizability(.contentMinSize) .windowStyle(.hiddenTitleBar) @@ -733,15 +730,46 @@ private struct ProjectWindowMissingSessionView: View { } } +private struct SettingsWindowHost: View { + @ObservedObject var projectSessions: ProjectSessionManager + @ObservedObject var settings: AppSettings + + var body: some View { + if let model = projectSessions.settingsModel { + SettingsWindow( + model: model, + settings: settings, + projectSessions: projectSessions, + bindingID: projectSessions.settingsBindingID + ) { + projectSessions.releaseSettings(for: model.id) + } + .id(model.id) + } + } +} + private struct SettingsWindow: View { @ObservedObject var model: AppModel @ObservedObject var settings: AppSettings + let projectSessions: ProjectSessionManager + let bindingID: UUID @StateObject private var windowReference = SettingsWindowReference() @StateObject private var viewState: SettingsViewState + let onDisappear: () -> Void - init(model: AppModel, settings: AppSettings) { + init( + model: AppModel, + settings: AppSettings, + projectSessions: ProjectSessionManager, + bindingID: UUID, + onDisappear: @escaping () -> Void + ) { self.model = model self.settings = settings + self.projectSessions = projectSessions + self.bindingID = bindingID + self.onDisappear = onDisappear _viewState = StateObject(wrappedValue: SettingsViewState( initialCategory: model.requestedSettingsCategory )) @@ -758,21 +786,29 @@ private struct SettingsWindow: View { ) .environmentObject(model) } + .environment(\.lithePointingHandCursorEnabled, false) .background( SettingsWindowAccessor( reference: windowReference, title: settingsWindowTitle(for: settings.language), - themePreference: settings.themePreference + themePreference: settings.themePreference, + closeEnabled: !viewState.isApplyingPluginChanges ) ) .onDisappear { + viewState.pendingPluginEnabledStates.removeAll() model.isSettingsPresented = false + onDisappear() } } private func close() { - model.isSettingsPresented = false - windowReference.window?.performClose(nil) + guard !viewState.isApplyingPluginChanges else { return } + projectSessions.closeSettingsIfCurrent(for: model.id, bindingID: bindingID) { + model.isSettingsPresented = false + windowReference.window?.standardWindowButton(.closeButton)?.isEnabled = true + windowReference.window?.performClose(nil) + } } } @@ -816,6 +852,7 @@ private struct SettingsWindowAccessor: NSViewRepresentable { let reference: SettingsWindowReference let title: String let themePreference: AppThemePreference + let closeEnabled: Bool func makeNSView(context: Context) -> SettingsWindowProbe { let view = SettingsWindowProbe(frame: .zero) @@ -840,15 +877,34 @@ private struct SettingsWindowAccessor: NSViewRepresentable { DispatchQueue.main.async { guard let window = view.window else { return } reference.window = window - SettingsWindowChrome.configure(window, title: title, themePreference: themePreference) + SettingsWindowChrome.configure( + window, + title: title, + themePreference: themePreference, + closeEnabled: closeEnabled + ) } } } +@MainActor enum SettingsWindowChrome { - static func configure(_ window: NSWindow, title: String, themePreference: AppThemePreference) { + static weak var ownerWindow: NSWindow? + static weak var settingsWindow: NSWindow? + + static func configure( + _ window: NSWindow, + title: String, + themePreference: AppThemePreference, + closeEnabled: Bool? = nil + ) { + settingsWindow = window window.title = title window.level = .normal + if let ownerWindow, ownerWindow !== window, window.parent !== ownerWindow { + window.parent?.removeChildWindow(window) + ownerWindow.addChildWindow(window, ordered: .above) + } let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { window.appearance = windowAppearance @@ -866,6 +922,9 @@ enum SettingsWindowChrome { applySettingsSurface(toTitlebarOf: window, color: settingsSurface) window.standardWindowButton(.miniaturizeButton)?.isEnabled = false window.standardWindowButton(.zoomButton)?.isEnabled = true + if let closeEnabled { + window.standardWindowButton(.closeButton)?.isEnabled = closeEnabled + } } private static func applySettingsSurface(toTitlebarOf window: NSWindow, color: NSColor) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift index 899a97f0f..437187f52 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift @@ -23,6 +23,32 @@ extension AppModel { editorDiagnosticsStore.replace( EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) ) + announceMavenResolutionProblemsIfChanged() + } + + /// Maven problems JDT LS currently reports for the open workspace. + var mavenResolutionProblems: [EditorDiagnostic] { + MavenResolutionProblems.problems( + workspaceURL: workspaceURL, + diagnosticsByURL: editorDiagnosticsStore.diagnosticsByURL + ) + } + + /// Notifies once per distinct Maven problem set, even when the Maven tool + /// window is closed. A failed import otherwise looks like a successful one. + private func announceMavenResolutionProblemsIfChanged() { + let problems = mavenResolutionProblems + let signature = MavenResolutionProblems.signature(problems) + // An empty set keeps the last signature: the same failure reappearing + // after a rebuild is visible in the Maven tool window already. + guard !signature.isEmpty, signature != announcedMavenResolutionProblems, + let first = problems.first else { return } + announcedMavenResolutionProblems = signature + showNotification(String( + format: String(localized: "Maven could not resolve this project (%lld problems): %@"), + problems.count, + first.message + )) } func refreshCodeVision(for fileURL: URL) async { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 53a1f51e1..e6910f797 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -18,6 +18,19 @@ extension AppModel { // hand back a run feature that teardown releases moments later, and the // deferred action waiting on it would never be resumed. guard let access = await executionModuleCoordinator.activateAccess() else { return nil } + access.mavenFeature.configureJavaConfigurationSync { [weak self] rootURL in + guard let sessions = self?.languageToolingSessionsIfActive else { return false } + switch sessions.updateJavaMavenConfiguration(rootURL: rootURL, reloadProjects: false) { + case .updated: return true + case .noSession: return false + case .failed(let failure): + throw NSError( + domain: "MavenJavaConfiguration", + code: 1, + userInfo: [NSLocalizedDescriptionKey: failure.userMessage] + ) + } + } for ownership in services.pluginCatalog.languageSupports.values { registerLanguageDependencySourceIfAvailable(support: ownership.declaration) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index e86b33b02..fe0628bdb 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -10,6 +10,25 @@ extension AppModel { services.pluginManager.issues } + func installPHPPluginPackage() { + guard let packageURL = platformUI.chooseDirectory( + title: NSLocalizedString("Install PHP Support Plugin", comment: "PHP plugin package picker title"), + prompt: "Install" + ) else { return } + do { + let manifestURL = packageURL.appendingPathComponent("plugin.json") + let manifest = try JSONDecoder().decode(PluginManifest.self, from: Data(contentsOf: manifestURL)) + guard manifest.id == OfficialPluginCatalog.phpPluginID else { + showNotification(NSLocalizedString("Select a PHP Support plugin package.", comment: "Wrong plugin package selected")) + return + } + try services.pluginManager.installPackage(at: packageURL) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + func applyPluginEnabledChanges(_ changes: [PluginID: Bool]) async -> Set { let snapshotsByID = Dictionary(uniqueKeysWithValues: pluginSnapshots.map { ($0.id, $0) }) let closesDatabase = changes.contains { pluginID, enabled in @@ -36,19 +55,6 @@ extension AppModel { return appliedPluginIDs } - func installPluginPackage() { - guard let packageURL = platformUI.chooseDirectory( - title: "Install Plugin Package", - prompt: "Install" - ) else { return } - do { - try services.pluginManager.installPackage(at: packageURL) - objectWillChange.send() - } catch { - showNotification(error.localizedDescription) - } - } - func rollbackPlugin(_ pluginID: PluginID) { do { try services.pluginManager.rollback(pluginID) @@ -58,14 +64,4 @@ extension AppModel { } } - func uninstallPlugin(_ pluginID: PluginID) { - Task { @MainActor in - do { - try await services.pluginManager.uninstall(pluginID) - objectWillChange.send() - } catch { - showNotification(error.localizedDescription) - } - } - } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 92ffdd9a8..8938fb18a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -335,6 +335,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { editorDiagnosticsStore.diagnosticsByURL } var languageSessionChromeSignature: LanguageSessionChromeSignature? + /// Last Maven problem set announced for the workspace, so one failure is + /// reported once rather than on every diagnostics refresh. + var announcedMavenResolutionProblems = "" var detectedCodexConfiguration: CodexConfigurationSnapshot? { detectedAIConfigurations.first { $0.source == .codex } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 27b70633f..a7a18e913 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -15,6 +15,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case git = "Git" case updates = "Updates" case diagnostics = "Diagnostics" + case plugins = "Plugins" var id: String { rawValue } var title: String { self == .project ? "Project · JDK & Maven" : rawValue } @@ -33,6 +34,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .git: "arrow.triangle.branch" case .updates: "arrow.down.circle" case .diagnostics: "stethoscope" + case .plugins: "puzzlepiece.extension" } } } diff --git a/macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift b/macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift new file mode 100644 index 000000000..5724b54df --- /dev/null +++ b/macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Maven problems JDT LS reports for the open workspace. +/// +/// JDT LS imports Maven projects through m2e and reports every failure to read a +/// POM or resolve an artifact as an error marker on that `pom.xml`. Those markers +/// are the resolution result; without surfacing them a failed import looks +/// successful while every third-party import stays unresolved (#970). Warnings +/// such as an out-of-date project configuration stay in the Problems view. +enum MavenResolutionProblems { + static func problems( + workspaceURL: URL?, + diagnosticsByURL: [URL: [EditorDiagnostic]] + ) -> [EditorDiagnostic] { + guard let workspaceURL else { return [] } + let rootPath = workspaceURL.standardizedFileURL.path + return diagnosticsByURL + .filter { url, _ in + let file = url.standardizedFileURL + return file.lastPathComponent.lowercased() == "pom.xml" + && file.path.hasPrefix(rootPath + "/") + } + .values + .flatMap { $0 } + .filter { $0.severity == .error } + .sorted { + if $0.fileURL.path != $1.fileURL.path { + return $0.fileURL.path.localizedStandardCompare($1.fileURL.path) == .orderedAscending + } + if $0.line != $1.line { return $0.line < $1.line } + if $0.utf16Column != $1.utf16Column { return $0.utf16Column < $1.utf16Column } + return $0.message < $1.message + } + } + + /// Stable identity of a problem set, used to notify once per distinct set. + static func signature(_ problems: [EditorDiagnostic]) -> String { + problems + .map { "\($0.fileURL.path)\u{0}\($0.line)\u{0}\($0.utf16Column)\u{0}\($0.message)" } + .joined(separator: "\n") + } +} diff --git a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index 1d1d2cbf1..ca25d9d6f 100644 --- a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -32,6 +32,9 @@ final class ProjectSessionManager: ObservableObject { @Published private(set) var activeSessionIDs: [ProjectWindowScope: UUID] = [:] /// Window that currently owns menu-bar commands and focus-driven actions. @Published private(set) var focusedScope: ProjectWindowScope = .primary + @Published private(set) var settingsModel: AppModel? + /// Invalidates close callbacks from an earlier opening of the shared settings window. + private(set) var settingsBindingID = UUID() @Published var pendingProjectOpen: PendingProjectOpen? private let settings: AppSettings @@ -119,6 +122,21 @@ final class ProjectSessionManager: ObservableObject { sessions.first(where: { $0.id == id }) } + func bindSettings(to sessionID: UUID) { + settingsBindingID = UUID() + settingsModel = session(for: sessionID) + } + + func closeSettingsIfCurrent(for sessionID: UUID, bindingID: UUID, close: () -> Void) { + guard settingsModel?.id == sessionID, settingsBindingID == bindingID else { return } + close() + } + + func releaseSettings(for sessionID: UUID) { + guard settingsModel?.id == sessionID else { return } + settingsModel = nil + } + func sessions(in scope: ProjectWindowScope) -> [AppModel] { sessions.filter { sessionScopes[$0.id] == scope } } diff --git a/macos/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift b/macos/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift index 7c8291941..3f47974d9 100644 --- a/macos/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift +++ b/macos/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift @@ -16,11 +16,7 @@ enum WorkspaceTextFilePolicy { /// Reject the control characters that are strong indicators of binary /// data while allowing normal whitespace such as tabs and newlines. static func isPlainText(_ text: String) -> Bool { - text.unicodeScalars.allSatisfy { scalar in - let value = scalar.value - return value != 0 && - !(value < 0x09 || (value > 0x0D && value < 0x20) || value == 0x7F) - } + RustTextContentPolicy.isPlainText(text) } static func isPlainText(_ data: Data) -> Bool { diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift index 0f734e1fb..156556472 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift @@ -7,7 +7,9 @@ enum MacBundledFontRegistry { (resource: "JetBrainsMono-Regular", postScriptName: "JetBrainsMono-Regular"), (resource: "JetBrainsMono-Italic", postScriptName: "JetBrainsMono-Italic"), (resource: "JetBrainsMono-Bold", postScriptName: "JetBrainsMono-Bold"), - (resource: "JetBrainsMono-BoldItalic", postScriptName: "JetBrainsMono-BoldItalic") + (resource: "JetBrainsMono-BoldItalic", postScriptName: "JetBrainsMono-BoldItalic"), + (resource: "Inter-Regular", postScriptName: "Inter-Regular"), + (resource: "Inter-SemiBold", postScriptName: "Inter-SemiBold") ] static func registerFonts(bundle: Bundle = .main) { @@ -23,12 +25,13 @@ enum MacBundledFontRegistry { } for font in fonts where NSFont(name: font.postScriptName, size: 13) == nil { + let fileExtension = font.resource.hasPrefix("Inter-") ? "otf" : "ttf" guard let url = bundle.url( forResource: font.resource, - withExtension: "ttf", + withExtension: fileExtension, subdirectory: "Fonts" ) else { - reporter("Lithe font registration: Missing bundled font: \(font.resource).ttf\n") + reporter("Lithe font registration: Missing bundled font: \(font.resource).\(fileExtension)\n") continue } @@ -36,7 +39,7 @@ enum MacBundledFontRegistry { guard CTFontManagerRegisterFontsForURL(url as CFURL, .process, ®istrationError) else { let detail = registrationError?.takeRetainedValue().localizedDescription ?? "Unknown CoreText error" - reporter("Lithe font registration: Could not register \(font.resource).ttf: \(detail)\n") + reporter("Lithe font registration: Could not register \(font.resource).\(fileExtension): \(detail)\n") continue } } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift new file mode 100644 index 000000000..13c92ae08 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift @@ -0,0 +1,34 @@ +import AppKit +import Foundation + +/// Chooses the HTTP handler, not the application's association with .html files. +@MainActor +struct MacHTMLBrowserOpener { + var applicationForURL: @MainActor (URL) -> URL? = { NSWorkspace.shared.urlForApplication(toOpen: $0) } + var openURLs: @MainActor ([URL], URL) async throws -> Void = { urls, application in + _ = try await NSWorkspace.shared.open( + urls, withApplicationAt: application, configuration: .init() + ) + } + + var isRegularFile: @MainActor (URL) throws -> Bool = { + try $0.resolvingSymlinksInPath().resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true + } + + func open(_ url: URL) async throws { + guard url.isFileURL, ["html", "htm"].contains(url.pathExtension.lowercased()) else { + throw CocoaError(.fileReadUnsupportedScheme) + } + guard try isRegularFile(url) else { throw CocoaError(.fileReadNoSuchFile) } + // This URL is only used to query Launch Services; no request is made. + guard let probe = URL(string: "https://example.invalid"), + let browser = applicationForURL(probe) else { + throw CocoaError(.serviceApplicationNotFound, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString( + "Choose a default web browser in System Settings, then retry.", comment: "" + ) + ]) + } + try await openURLs([url], browser) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index b022bd105..cf7498d85 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -37,6 +37,10 @@ final class MacPlatformUI: PlatformUI { NSWorkspace.shared.open(url) } + func openHTMLInBrowser(_ url: URL) async throws { + try await MacHTMLBrowserOpener().open(url) + } + func copyToClipboard(_ value: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 9916de2fa..3d67bcc32 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -278,6 +278,7 @@ enum LitheTheme { // MARK: - 背景层次 static var window: Color { adaptive(\.window) } static var titlebar: Color { adaptive(\.titlebar) } + // IntelliJ Community Islands theme tokens: platform/platform-resources/src/themes/islands/ManyIslands{Dark,Light}.theme.json. static var settingsSurface: Color { Color(nsColor: NSColor(name: nil) { appearance in settingsSurfaceNSColor(for: appearance) @@ -285,16 +286,39 @@ enum LitheTheme { } static func settingsSurfaceNSColor(for appearance: NSAppearance) -> NSColor { let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - return isDark - ? NSColor(srgbRed: 0.157, green: 0.161, blue: 0.173, alpha: 1) - : NSColor(srgbRed: 0.910, green: 0.922, blue: 0.937, alpha: 1) - } - static var settingsPrimaryAction: Color { accent } - static let settingsSelection = Color( - red: 43.0 / 255.0, - green: 66.0 / 255.0, - blue: 113.0 / 255.0 + return nsColor(.sidebar, isDark: isDark) + } + static let settingsControlAccent = Color( + red: 56.0 / 255.0, + green: 113.0 / 255.0, + blue: 225.0 / 255.0 ) + static var settingsPrimaryAction: Color { settingsControlAccent } + static var settingsListSurface: Color { settingsSurface } + static var settingsFont: Font { .custom("Inter-Regular", size: 13) } + static var settingsStrongFont: Font { .custom("Inter-SemiBold", size: 13) } + static var settingsSearchBorder: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 78.0 / 255, green: 81.0 / 255, blue: 87.0 / 255, alpha: 1) + : NSColor(srgbRed: 201.0 / 255, green: 204.0 / 255, blue: 214.0 / 255, alpha: 1) + }) + } + static var settingsSelection: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 42.0 / 255.0, green: 67.0 / 255.0, blue: 113.0 / 255.0, alpha: 1) + : NSColor(srgbRed: 208.0 / 255.0, green: 223.0 / 255.0, blue: 254.0 / 255.0, alpha: 1) + }) + } + static var settingsSelectionText: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark ? .white : .black + }) + } static var settingsControlBackground: Color { Color(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua @@ -303,6 +327,37 @@ enum LitheTheme { : NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) }) } + static var settingsTextFieldBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark ? settingsSurfaceNSColor(for: appearance) : .white + }) + } + static var settingsControlBorder: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 64.0 / 255.0, green: 67.0 / 255.0, blue: 74.0 / 255.0, alpha: 1) + : NSColor(srgbRed: 209.0 / 255.0, green: 211.0 / 255.0, blue: 217.0 / 255.0, alpha: 1) + }) + } + static var settingsPopupBackground: Color { settingsControlBackground } + static var settingsPopupBorder: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 76.0 / 255.0, green: 79.0 / 255.0, blue: 86.0 / 255.0, alpha: 1) + : NSColor(srgbRed: 233.0 / 255.0, green: 234.0 / 255.0, blue: 238.0 / 255.0, alpha: 1) + }) + } + static var settingsSelectBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 57.0 / 255, green: 59.0 / 255, blue: 64.0 / 255, alpha: 1) + : .white + }) + } static var toolHeader: Color { adaptive(\.toolHeader) } static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) } static var sidebar: Color { adaptive(\.sidebar) } @@ -503,13 +558,17 @@ struct LitheIconButtonStyle: ButtonStyle { } } -/// Keeps file-tree rows visually stable while they are being activated. -struct LitheTreeRowButtonStyle: ButtonStyle { +/// Keeps borderless button labels at full opacity while pressed. +struct LitheNoPressButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label } } +extension ButtonStyle where Self == LitheNoPressButtonStyle { + static var litheNoPress: LitheNoPressButtonStyle { .init() } +} + private struct LitheRowHoverModifier: ViewModifier { let isActive: Bool let cornerRadius: CGFloat @@ -535,14 +594,16 @@ private struct LitheRowHoverModifier: ViewModifier { struct LithePrimaryButtonStyle: ButtonStyle { var backgroundColor = LitheTheme.accent var restingOpacity = 0.92 + var horizontalPadding: CGFloat = 18 + var height: CGFloat = 30 @State private var isHovering = false func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) - .padding(.horizontal, 18) - .frame(height: 30) + .padding(.horizontal, horizontalPadding) + .frame(height: height) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .fill(backgroundColor.opacity(configuration.isPressed ? 0.78 : (isHovering ? 1 : restingOpacity))) @@ -571,7 +632,7 @@ struct LitheSecondaryButtonStyle: ButtonStyle { ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + .strokeBorder(LitheTheme.panelBorder, lineWidth: 1) } .contentShape(Rectangle()) .onHover { isHovering = $0 } @@ -579,18 +640,33 @@ struct LitheSecondaryButtonStyle: ButtonStyle { } } +private struct LithePointingHandCursorKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var lithePointingHandCursorEnabled: Bool { + get { self[LithePointingHandCursorKey.self] } + set { self[LithePointingHandCursorKey.self] = newValue } + } +} + private struct LithePointerModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled + @Environment(\.lithePointingHandCursorEnabled) private var pointingHandCursorEnabled @State private var cursor = LithePointerCursor() func body(content: Content) -> some View { content .onHover { isInside in cursor.isHovered = isInside - cursor.update(isPointing: isInside && isEnabled) + cursor.update(isPointing: isInside && isEnabled && pointingHandCursorEnabled) } .onChange(of: isEnabled) { _ in - cursor.update(isPointing: cursor.isHovered && isEnabled) + cursor.update(isPointing: cursor.isHovered && isEnabled && pointingHandCursorEnabled) + } + .onChange(of: pointingHandCursorEnabled) { _ in + cursor.update(isPointing: cursor.isHovered && isEnabled && pointingHandCursorEnabled) } .onDisappear { cursor.isHovered = false diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index c474e66ad..1df5e9fc8 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -87,7 +87,7 @@ struct AgentComposerView: View { Button { showsFilePicker = true } label: { Label(isDropTargeted ? "Drop files here" : "Attach files", systemImage: "paperclip") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Drag files here or click to choose files") Spacer(minLength: 0) if showsSubscriptionQuota { @@ -136,7 +136,7 @@ struct AgentComposerView: View { .frame(width: 26, height: 26) .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 4)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring))) .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index d7b637f4b..f01e37aa5 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -401,7 +401,7 @@ private struct AgentSessionTab: View { .font(.system(size: 9, weight: .bold)) .frame(width: 14, height: 14) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.tertiaryText) .help("Close conversation") diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift index 4eaa5e1f9..950a8f921 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift @@ -15,7 +15,7 @@ struct AgentFileReferenceList: View { Button { onRemove(file.id) } label: { Image(systemName: "xmark").font(.system(size: 9)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel(String(format: String(localized: "Remove file %@"), file.name)) } .font(.system(size: 11)) diff --git a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift index 7e1ec1549..0eb5e6d4c 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift @@ -72,7 +72,7 @@ struct AgentHistoryView: View { VStack(spacing: 0) { HStack { Button(action: onBack) { Label("Back", systemImage: "arrow.left") } - .buttonStyle(.plain).lithePointer() + .buttonStyle(.litheNoPress).lithePointer() .accessibilityIdentifier("agent-history-back") Spacer() Text("Conversation history").fontWeight(.medium) @@ -98,7 +98,7 @@ struct AgentHistoryView: View { ProgressView().controlSize(.mini) Text("Exporting conversations…") Spacer() - Button("Cancel") { history.cancelExport() }.buttonStyle(.plain) + Button("Cancel") { history.cancelExport() }.buttonStyle(.litheNoPress) }.font(.system(size: 12)).padding(12) } if sessions.isEmpty { @@ -161,7 +161,7 @@ struct AgentHistoryView: View { } } label: { Text("Manage selected") } .disabled(selected.isEmpty) - }.font(.system(size: 11)).buttonStyle(.borderless) + }.font(.system(size: 11)).buttonStyle(.litheNoPress) } if filter == .removed { Text("Removed conversations are hidden only in Lithe. The Agent's original records are kept.") @@ -204,7 +204,7 @@ struct AgentHistoryView: View { .accessibilityIdentifier("agent-history-search") if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark") } - .buttonStyle(.plain).help("Clear search") + .buttonStyle(.litheNoPress).help("Clear search") } else { Image(systemName: "magnifyingglass") } } .font(.system(size: 12)) @@ -302,7 +302,7 @@ private struct AgentHistoryRow: View { if isSelecting { Button(action: onToggleSelection) { Image(systemName: isSelected ? "checkmark.square.fill" : "square") - }.buttonStyle(.plain).help("Select conversation") + }.buttonStyle(.litheNoPress).help("Select conversation") } AgentBrandIcon(name: agentName, size: 18).foregroundStyle(AgentPanelStyle.text) if isEditing { @@ -318,7 +318,7 @@ private struct AgentHistoryRow: View { Text(title).font(.system(size: 14, weight: .semibold)) .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - }.buttonStyle(.plain).lithePointer() + }.buttonStyle(.litheNoPress).lithePointer() .disabled(!isSelecting && !canOpen).help(title) if let date = AgentHistoryPresentation.date(session.updatedAt) { Text(date, format: .relative(presentation: .named, unitsStyle: .abbreviated)) @@ -365,6 +365,6 @@ private struct AgentHistoryRow: View { private func icon(_ name: String, _ title: LocalizedStringKey, _ action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: name).font(.system(size: 11)).frame(width: 22, height: 22) } - .buttonStyle(.plain).lithePointer().help(title).accessibilityLabel(title) + .buttonStyle(.litheNoPress).lithePointer().help(title).accessibilityLabel(title) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift index 77791f9f5..f4abe6717 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift @@ -94,7 +94,7 @@ struct AgentPanelSettingsView: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(item.title) } @@ -207,7 +207,7 @@ private struct AgentsManagementPage: View { .frame(width: 30, height: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .disabled(feature.phase == .checking) @@ -240,7 +240,7 @@ private struct AgentsManagementPage: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -407,7 +407,7 @@ private struct AgentDetailView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if isExpanded { HStack(alignment: .top, spacing: 8) { diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift index 19e94c632..e26f085db 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -70,7 +70,7 @@ struct AgentSessionSelectors: View { Text(AgentSessionSelectorPresentation.currentTitle(mode)) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(AgentSessionSelectorPresentation.localized(mode.name)) .accessibilityLabel(Text("Approval mode")) .accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode)) @@ -86,7 +86,7 @@ struct AgentSessionSelectors: View { Text(modelSummary(model)) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(model.currentLabel) .accessibilityLabel(Text("Model")) .accessibilityValue(model.currentLabel) @@ -253,7 +253,7 @@ private struct AgentModelSettingRow: View { .contentShape(Rectangle()) .litheRowHover() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } @@ -313,7 +313,7 @@ private struct AgentSelectorRow: View { .background(isSelected ? AgentPanelStyle.selected : (isHovering ? AgentPanelStyle.context : .clear)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .onHover { isHovering = $0 } .accessibilityAddTraits(isSelected ? .isSelected : []) diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index e864c590f..2fb8d30d3 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -38,7 +38,7 @@ struct AgentTranscriptView: View { .frame(height: 26) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .litheRowHover() } } @@ -115,7 +115,7 @@ struct AgentHeroView: View { AgentBrandIcon(name: agentName, size: 60) .foregroundStyle(isHovering ? AgentPanelStyle.secondary : AgentPanelStyle.logo) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .accessibilityLabel("Switch Agent") .onHover { isHovering = $0 } @@ -271,7 +271,7 @@ private struct AgentToolCallRow: View { Button { expanded.toggle() } label: { header } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(expanded ? "Hide tool details" : "Show tool details") if expanded && !message.toolDetails.isEmpty { AgentToolEvidenceView(details: message.toolDetails, onOpenFile: onOpenFile) @@ -337,7 +337,7 @@ private struct AgentToolEvidenceView: View { .lineLimit(2) .truncationMode(.middle) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 11, design: .monospaced)) .help(location.path) } @@ -452,7 +452,7 @@ private struct AgentCodeBlock: View { Image(systemName: didCopy ? "checkmark" : "doc.on.doc") .font(.system(size: 10.5)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(didCopy ? LitheTheme.success : LitheTheme.tertiaryText) .help("Copy code") diff --git a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift index eb0ad34dd..fa35ee630 100644 --- a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift @@ -15,7 +15,7 @@ struct GitExecutionSettingsView: View { GitSettingsRow("Git executable") { HStack(spacing: 8) { TextField("Use Git from PATH", text: $executable) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() .frame(maxWidth: .infinity) Button("Save") { settings.gitExecutable = executable.trimmingCharacters(in: .whitespacesAndNewlines) } .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) @@ -112,12 +112,13 @@ private struct GitExecutionConfigurationPane: View { subtitle: "Choose whether this setting belongs to the open repository or to Git everywhere." ) GitSettingsRow("Configuration scope") { - Picker("Configuration scope", selection: $scope) { - Text("Current repository").tag("local") - Text("Global Git configuration").tag("global") - } - .labelsHidden() - .pickerStyle(.segmented) + LitheSettingsSegmentedControl( + selection: $scope, + options: ["local", "global"], + width: 340, + title: { $0 == "local" ? "Current repository" : "Global Git configuration" } + ) + .accessibilityLabel("Configuration scope") } GitBranchContextView(feature: feature, remoteURL: remoteURL) diff --git a/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift b/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift index 5560fad46..e030e0a4f 100644 --- a/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift @@ -6,7 +6,7 @@ struct GitFetchSettingsView: View { var body: some View { GitSettingsCard { GitSettingsHeader(icon: "arrow.down.circle", title: "Fetch defaults", subtitle: "Set the defaults used by ordinary Fetch in every project.") - GitFetchPolicyControls(options: $options) + GitFetchPolicyControls(options: $options, usesSettingsControls: true) Text("Credentials use your existing Git helper and SSH configuration.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) Button("Reset Fetch defaults") { options = GitFetchOptions() }.lithePointer() @@ -16,26 +16,62 @@ struct GitFetchSettingsView: View { struct GitFetchPolicyControls: View { @Binding var options: GitFetchOptions + var usesSettingsControls = false var body: some View { VStack(alignment: .leading, spacing: 9) { GitSettingsRow("Prune") { Toggle("Prune stale remote-tracking references", isOn: $options.prune) } GitSettingsRow("Fetch submodules") { - Picker("Fetch submodules", selection: $options.submodules) { - Text("Use Git configuration").tag(GitFetchSubmodules.inherit) - Text("Do not fetch submodules").tag(GitFetchSubmodules.no) - Text("Fetch submodules on demand").tag(GitFetchSubmodules.onDemand) - Text("Fetch all submodules").tag(GitFetchSubmodules.yes) - }.labelsHidden() + if usesSettingsControls { + LitheSettingsSelect( + selection: $options.submodules, + options: GitFetchSubmodules.allCases, + width: 260, + accessibilityLabel: "Fetch submodules", + title: { option in + switch option { + case .inherit: "Use Git configuration" + case .no: "Do not fetch submodules" + case .onDemand: "Fetch submodules on demand" + case .yes: "Fetch all submodules" + } + } + ) + } else { + Picker("Fetch submodules", selection: $options.submodules) { + Text("Use Git configuration").tag(GitFetchSubmodules.inherit) + Text("Do not fetch submodules").tag(GitFetchSubmodules.no) + Text("Fetch submodules on demand").tag(GitFetchSubmodules.onDemand) + Text("Fetch all submodules").tag(GitFetchSubmodules.yes) + }.labelsHidden() + } } GitSettingsRow("Fetch tags") { - Picker("Fetch tags", selection: $options.tags) { - Text("Use Git configuration").tag(GitFetchTags.inherit) - Text("Fetch all tags").tag(GitFetchTags.all) - Text("Do not fetch tags").tag(GitFetchTags.none) - Text("Synchronize tags and remove local tags missing from the remote").tag(GitFetchTags.prune) - }.labelsHidden() + if usesSettingsControls { + LitheSettingsSelect( + selection: $options.tags, + options: GitFetchTags.allCases, + width: 260, + accessibilityLabel: "Fetch tags", + title: { option in + switch option { + case .inherit: "Use Git configuration" + case .all: "Fetch all tags" + case .none: "Do not fetch tags" + case .prune: "Synchronize tags and remove local tags missing from the remote" + } + }, + expandsToFitOptions: true + ) + } else { + Picker("Fetch tags", selection: $options.tags) { + Text("Use Git configuration").tag(GitFetchTags.inherit) + Text("Fetch all tags").tag(GitFetchTags.all) + Text("Do not fetch tags").tag(GitFetchTags.none) + Text("Synchronize tags and remove local tags missing from the remote").tag(GitFetchTags.prune) + }.labelsHidden() + } } } .onChange(of: options.tags) { tags in if tags == .prune { options.prune = true } } diff --git a/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift b/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift index 222939cc2..63187a952 100644 --- a/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift @@ -29,10 +29,14 @@ private struct GitIdentitySettingsPane: View { Text("Commit identity").font(.system(size: 15, weight: .semibold)) Text("Git records this name and email in new commits. These settings do not change existing commits.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) - Picker("Configuration scope", selection: $scope) { - Text("Current repository").tag(GitIdentityScope.local) - Text("Global Git configuration").tag(GitIdentityScope.global) - }.pickerStyle(.segmented).disabled(editor.isBusy) + LitheSettingsSegmentedControl( + selection: $scope, + options: GitIdentityScope.allCases, + width: 340, + title: { $0 == .local ? "Current repository" : "Global Git configuration" } + ) + .accessibilityLabel("Configuration scope") + .disabled(editor.isBusy) Text(scope == .global ? LocalizedStringKey("Global identity applies to other repositories unless they override it.") : LocalizedStringKey("Repository identity overrides inherited global values. Clear an override to use inherited settings.")) @@ -69,7 +73,7 @@ private struct GitIdentitySettingsPane: View { VStack(alignment: .leading, spacing: 7) { Text(title).font(.system(size: 12, weight: .medium)) HStack { - TextField(title, text: draft).textFieldStyle(.roundedBorder) + TextField(title, text: draft).litheSettingsTextField() Button("Save") { save(field) } .disabled(draft.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || draft.wrappedValue == configured) .lithePointer() diff --git a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift index 08dfc0902..c2e275661 100644 --- a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift @@ -4,6 +4,8 @@ struct KeyboardShortcutSettingsView: View { @ObservedObject var feature: KeyboardShortcutFeatureModel let language: AppLanguage @State private var query = "" + @State private var expandedGroups: Set = [] + @State private var selectedCommandID: String? @State private var editingTarget: EditingTarget? @State private var validationIssue: ValidationIssue? @@ -21,15 +23,9 @@ struct KeyboardShortcutSettingsView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { header + toolbar ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - let sections = feature.groupedCommands(query: query) { command in - [ - localizedString(command.title), - localizedString(command.subtitle), - localizedString(command.group.rawValue) - ].joined(separator: " ") - } + LazyVStack(alignment: .leading, spacing: 0) { if sections.isEmpty { emptyState } else { @@ -38,39 +34,32 @@ struct KeyboardShortcutSettingsView: View { } } } - .padding(.horizontal, 24) - .padding(.bottom, 24) + .padding(.horizontal, 12) + .padding(.vertical, 4) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } .background(LitheTheme.settingsSurface) } - private var header: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 5) { - Text("Keymap") - .font(.system(size: 20, weight: .semibold)) - Text("Customize shortcuts for Lithe actions. Changes apply immediately.") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Button("Restore All Defaults") { - editingTarget = nil - validationIssue = nil - feature.resetAll() - } - .buttonStyle(.bordered) - .lithePointer() - } + private var sections: [KeyboardShortcutCommandSection] { + feature.groupedCommands(query: query) { command in + [ + localizedString(command.title), + localizedString(command.subtitle), + localizedString(command.group.rawValue) + ].joined(separator: " ") + } + } + private var header: some View { + HStack(spacing: 10) { LitheSettingsSelect( selection: Binding( get: { feature.selectedPreset }, set: { preset in cancelEditing() + selectedCommandID = nil feature.selectPreset(preset) } ), @@ -79,62 +68,167 @@ struct KeyboardShortcutSettingsView: View { accessibilityLabel: "Keymap", title: { $0.title } ) - - LitheSettingsSearchField("Search actions or shortcuts", text: $query) { _ in - editingTarget = nil - validationIssue = nil + Menu { + Button("Restore All Defaults") { + cancelEditing() + feature.resetAll() + } + } label: { + LitheSystemIcon(systemImage: "gearshape", size: 16) + .foregroundStyle(LitheTheme.secondaryText) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .tint(LitheTheme.secondaryText) + .frame(width: 26) + .accessibilityLabel("Restore All Defaults") + .lithePointer() + Spacer() } .foregroundStyle(LitheTheme.primaryText) - .padding(24) + .padding(.horizontal, 16) + .padding(.bottom, 12) } - private func commandSection(_ section: KeyboardShortcutCommandSection) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(LocalizedStringKey(section.group.rawValue)) - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .textCase(.uppercase) - VStack(spacing: 0) { - ForEach(Array(section.commands.enumerated()), id: \.element.id) { index, command in - commandRow(command) - if index < section.commands.count - 1 { - Rectangle().fill(LitheTheme.divider).frame(height: 1) + private var toolbar: some View { + HStack(spacing: 8) { + Button { + expandedGroups = Set(LitheActionGroup.allCases) + } label: { + LitheIDEAIcon(resourcePath: "expui/general/expandAll.svg", size: 16, fallbackSystemImage: "arrow.down.right.and.arrow.up.left", preservesOriginalColors: true) + } + .help("Expand All") + .accessibilityLabel("Expand All") + + Button { + expandedGroups = [] + cancelEditing() + } label: { + LitheIDEAIcon(resourcePath: "expui/general/collapseAll.svg", size: 16, fallbackSystemImage: "arrow.up.left.and.arrow.down.right", preservesOriginalColors: true) + } + .help("Collapse All") + .accessibilityLabel("Collapse All") + + Menu { + if let selectedCommandID, + let command = LitheCommandCatalog.command(id: selectedCommandID) { + Button("Add Shortcut") { + expandedGroups.insert(command.group) + beginEditing(commandID: selectedCommandID, bindingIndex: nil) + } + ForEach(Array(feature.effectiveBindings(for: selectedCommandID).enumerated()), id: \.offset) { index, binding in + Button("Remove \(binding.displayText)") { + removeBinding(commandID: selectedCommandID, index: index) + } + } + if feature.isCustomized(selectedCommandID) { + Button("Restore Default") { + cancelEditing() + feature.resetCommand(selectedCommandID) + } } } + } label: { + LitheIDEAIcon(resourcePath: "expui/general/edit.svg", size: 16, fallbackSystemImage: "pencil", preservesOriginalColors: true) } - .background(LitheTheme.sidebar) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(LitheTheme.divider, lineWidth: 1) + .menuStyle(.borderlessButton) + .tint(LitheTheme.secondaryText) + .disabled(selectedCommandID == nil) + .help("Edit Shortcuts") + .accessibilityLabel("Edit Shortcuts") + + Spacer(minLength: 12) + LitheSettingsSearchField("", text: $query) { _ in + cancelEditing() + selectedCommandID = nil } + .frame(width: 244) + .accessibilityLabel("Search actions or shortcuts") } + .font(.system(size: 12)) + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 16) + .frame(height: 36) + .background(alignment: .top) { Rectangle().fill(LitheTheme.divider).frame(height: 1) } + .background(alignment: .bottom) { Rectangle().fill(LitheTheme.divider).frame(height: 1) } + } + + private func commandSection(_ section: KeyboardShortcutCommandSection) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + if expandedGroups.contains(section.group) { + expandedGroups.remove(section.group) + cancelEditing() + } else { + expandedGroups.insert(section.group) + } + } label: { + HStack(spacing: 7) { + LitheIDEAIcon(resourcePath: "expui/general/chevronRight.svg", size: 16, fallbackSystemImage: "chevron.right", preservesOriginalColors: true) + .rotationEffect(.degrees(isExpanded(section.group) ? 90 : 0)) + LitheIcon(kind: .folder, size: 16) + Text(LocalizedStringKey(section.group.rawValue)) + .font(.system(size: 12.5)) + Spacer() + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .disabled(!query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if isExpanded(section.group) { + ForEach(section.commands) { command in + commandRow(command) + } + } + } + } + + private func isExpanded(_ group: LitheActionGroup) -> Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || expandedGroups.contains(group) } private func commandRow(_ command: LitheCommandDefinition) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey(command.title)) - .font(.system(size: 12.5, weight: .medium)) - Text(command.id) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.tertiaryText) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Button { + selectedCommandID = command.id + } label: { + HStack(spacing: 0) { + Text(LocalizedStringKey(command.title)) + .font(.system(size: 12.5)) + .lineLimit(1) + Spacer(minLength: 8) + } + .contentShape(Rectangle()) } - .frame(minWidth: 160, maxWidth: .infinity, alignment: .leading) + .buttonStyle(.litheNoPress) shortcutControls(for: command) - + } + .foregroundStyle(selectedCommandID == command.id ? LitheTheme.settingsSelectionText : LitheTheme.primaryText) + .padding(.leading, 36) + .padding(.trailing, 6) + .frame(height: 24) + .background(selectedCommandID == command.id ? LitheTheme.settingsSelection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .litheContextMenu { + var items: [LitheContextMenuItem] = [ + .action("Add Shortcut") { + beginEditing(commandID: command.id, bindingIndex: nil) + } + ] if feature.isCustomized(command.id) { - Button("Reset") { + items.append(.action("Restore Default") { cancelEditing() feature.resetCommand(command.id) - } - .buttonStyle(.borderless) - .font(.system(size: 11.5)) - .lithePointer() + }) } + return items } if editingTarget?.commandID == command.id { @@ -148,6 +242,7 @@ struct KeyboardShortcutSettingsView: View { onCancel: cancelEditing ) .id("\(command.id)-\(editingTarget?.bindingIndex ?? -1)") + .padding(.leading, 31) if let validationIssue { Label { @@ -155,14 +250,12 @@ struct KeyboardShortcutSettingsView: View { } icon: { Image(systemName: "exclamationmark.triangle.fill") } - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.warning) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.warning) + .padding(.leading, 31) } } } - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 14) - .padding(.vertical, 11) } private func shortcutControls(for command: LitheCommandDefinition) -> some View { @@ -170,7 +263,8 @@ struct KeyboardShortcutSettingsView: View { return HStack(spacing: 6) { if bindings.isEmpty { Button("Not Assigned") { beginEditing(commandID: command.id, bindingIndex: nil) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) + .font(.system(size: 12)) .foregroundStyle(LitheTheme.tertiaryText) .lithePointer() } else { @@ -178,16 +272,6 @@ struct KeyboardShortcutSettingsView: View { bindingChip(binding, commandID: command.id, index: index) } } - - Button { - beginEditing(commandID: command.id, bindingIndex: nil) - } label: { - Image(systemName: "plus") - .font(.system(size: 10, weight: .semibold)) - } - .buttonStyle(.borderless) - .help("Add Shortcut") - .lithePointer() } } @@ -196,34 +280,41 @@ struct KeyboardShortcutSettingsView: View { commandID: String, index: Int ) -> some View { - HStack(spacing: 3) { - Button(binding.displayText) { - beginEditing(commandID: commandID, bindingIndex: index) - } - .buttonStyle(.borderless) - .font(.system(size: 11.5, weight: .medium, design: .rounded)) - .lithePointer() - - Button { - removeBinding(commandID: commandID, index: index) - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) + Button { + beginEditing(commandID: commandID, bindingIndex: index) + } label: { + HStack(spacing: 2) { + ForEach(Array(keycapLabels(for: binding).enumerated()), id: \.offset) { _, label in + Text(label) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .frame(minWidth: 14, minHeight: 17) + .padding(.horizontal, 2) + .litheSettingsControlChrome(cornerRadius: 3) + } } - .buttonStyle(.borderless) - .help("Remove") - .lithePointer() } - .padding(.horizontal, 7) - .frame(height: 24) - .background(LitheTheme.settingsControlBackground) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .buttonStyle(.litheNoPress) + .lithePointer() + .help("Edit Shortcut") + .accessibilityLabel(binding.displayText) + .litheContextMenu { + [LitheContextMenuItem.action("Remove Shortcut") { + removeBinding(commandID: commandID, index: index) + }] } } + private func keycapLabels(for binding: KeyboardShortcutBinding) -> [String] { + guard let (_, modifiers) = binding.keyPressValue else { return [binding.displayText] } + let symbols = [ + modifiers.contains(.control) ? "⌃" : nil, + modifiers.contains(.option) ? "⌥" : nil, + modifiers.contains(.shift) ? "⇧" : nil, + modifiers.contains(.command) ? "⌘" : nil + ].compactMap { $0 } + return symbols + [String(binding.displayText.dropFirst(symbols.count))] + } + private var emptyState: some View { VStack(spacing: 8) { Image(systemName: "keyboard.badge.ellipsis") @@ -237,6 +328,7 @@ struct KeyboardShortcutSettingsView: View { } private func beginEditing(commandID: String, bindingIndex: Int?) { + selectedCommandID = commandID validationIssue = nil editingTarget = EditingTarget(commandID: commandID, bindingIndex: bindingIndex) } diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index 54b968366..4e6fa6720 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -2,18 +2,27 @@ import SwiftUI import LitheModuleAPI struct PluginManagementView: View { + static let minimumWidth: CGFloat = listMinimumWidth + SplitHandleView.thickness + detailMinimumWidth + static let listMinimumWidth: CGFloat = 200 + private static let detailMinimumWidth: CGFloat = 160 @EnvironmentObject private var model: AppModel + @ObservedObject var settingsState: SettingsViewState @State private var searchText = "" @State private var selectedPluginID: PluginID? @State private var hoveredPluginID: PluginID? - @State private var pendingEnabledStates: [PluginID: Bool] = [:] - @State private var isApplyingChanges = false - @State private var isLanguageExtensionsExpanded = false + @AppStorage("lithe.settings.pluginListWidth") private var pluginListWidth = 320.0 + + private var pendingEnabledStates: [PluginID: Bool] { settingsState.pendingPluginEnabledStates } + private var isApplyingChanges: Bool { settingsState.isApplyingPluginChanges } + + private var installedPlugins: [PluginManagementSnapshot] { + PluginManagementListContent(plugins: model.pluginSnapshots).plugins + } private var filteredPlugins: [PluginManagementSnapshot] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !query.isEmpty else { return model.pluginSnapshots } - return model.pluginSnapshots.filter { + guard !query.isEmpty else { return installedPlugins } + return installedPlugins.filter { $0.manifest.displayName.lowercased().contains(query) || $0.manifest.vendor.displayName.lowercased().contains(query) } @@ -23,160 +32,93 @@ struct PluginManagementView: View { filteredPlugins.first { $0.id == selectedPluginID } ?? filteredPlugins.first } - private var listContent: PluginManagementListContent { - PluginManagementListContent(plugins: filteredPlugins) - } - - private var isSearching: Bool { - !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var showsLanguageExtensions: Bool { - isLanguageExtensionsExpanded || isSearching + private var availablePHPManifest: PluginManifest? { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let manifest = PluginManagementListContent(plugins: model.pluginSnapshots).availablePHPManifest, + query.isEmpty || manifest.displayName.lowercased().contains(query) else { return nil } + return manifest } private var enabledPluginCount: Int { - model.pluginSnapshots.filter { effectiveEnabledState(for: $0) }.count + installedPlugins.filter { effectiveEnabledState(for: $0) }.count } var body: some View { VStack(spacing: 0) { header - HStack(spacing: 0) { - sidebar - Rectangle().fill(LitheTheme.divider).frame(width: 1) - detail + GeometryReader { geometry in + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(pluginListWidth), + minimum: Self.listMinimumWidth, + maximum: max(Self.listMinimumWidth, geometry.size.width - SplitHandleView.thickness - Self.detailMinimumWidth), + flexibleMinimum: Self.detailMinimumWidth, + highlightsOnHover: false, + onCommit: { pluginListWidth = Double($0) }, + sized: { sidebar }, + flexible: { detail } + ) + } + if !pendingEnabledStates.isEmpty || isApplyingChanges { + footer } - footer } - .frame(minWidth: 820, minHeight: 560) - .litheWorkbenchSurface(LitheTheme.window) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LitheTheme.settingsSurface) .onAppear { - let initialContent = PluginManagementListContent(plugins: model.pluginSnapshots) - selectedPluginID = initialContent.standalonePlugins.first?.id - ?? initialContent.languageExtensions.first?.id - } - .onChange(of: searchText) { newValue in - if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - isLanguageExtensionsExpanded = true - } + selectedPluginID = installedPlugins.first?.id } } private var header: some View { HStack(spacing: 24) { - Text(LocalizedStringKey("Plugins")).font(.system(size: 17, weight: .semibold)) + Text(LocalizedStringKey("Plugins")).font(LitheTheme.settingsStrongFont) Spacer() - Text(LocalizedStringKey("Marketplace")).foregroundStyle(LitheTheme.secondaryText) - HStack(spacing: 7) { - Text(LocalizedStringKey("Installed")) - Text("\(model.pluginSnapshots.count)") - .font(.system(size: 11, weight: .bold)) - .frame(width: 20, height: 20) - .background(LitheTheme.selection) - .clipShape(Circle()) - } - .padding(.horizontal, 12).padding(.vertical, 7) - .background(LitheTheme.selection.opacity(0.45)) + Text(LocalizedStringKey("Marketplace")) + .foregroundStyle(LitheTheme.secondaryText) + .help("Plugin marketplace is not available") + Text(LocalizedStringKey("Installed")) + .font(LitheTheme.settingsFont) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(LitheTheme.settingsSelection) .clipShape(RoundedRectangle(cornerRadius: 7)) - Image(systemName: "gearshape").foregroundStyle(LitheTheme.secondaryText) } - .padding(.horizontal, 20) - .frame(height: 52) - .litheWorkbenchSurface(LitheTheme.toolHeader) + .padding(.horizontal, 16) + .frame(height: 42) + .background(LitheTheme.settingsSurface) } private var sidebar: some View { VStack(spacing: 0) { HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(LitheTheme.secondaryText) - TextField(LocalizedStringKey("Type / to see options"), text: $searchText) - .textFieldStyle(.plain) - Image(systemName: "ellipsis").foregroundStyle(LitheTheme.secondaryText) + LitheSettingsSearchField("Type / to see options", text: $searchText) } - .padding(.horizontal, 14).frame(height: 48) + .padding(.horizontal, 8).frame(height: 44) Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack { - Text(LocalizedStringKey("Downloaded (\(model.pluginSnapshots.count) of \(enabledPluginCount) enabled)")) - .font(.system(size: 13, weight: .medium)) + Text(LocalizedStringKey("Installed (\(enabledPluginCount) of \(installedPlugins.count) enabled)")) + .font(LitheTheme.settingsFont) Spacer() - Button(LocalizedStringKey("Install")) { model.installPluginPackage() } - .buttonStyle(.borderless).foregroundStyle(LitheTheme.accent) } - .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.raised) + .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.settingsListSurface) ScrollView { LazyVStack(spacing: 0) { - ForEach(listContent.standalonePlugins) { plugin in + ForEach(filteredPlugins) { plugin in pluginRow(plugin) } - if !listContent.languageExtensions.isEmpty { - languageExtensionsDisclosure - if showsLanguageExtensions { - ForEach(listContent.languageExtensions) { plugin in - pluginRow(plugin, isNested: true) - } - } + if let manifest = availablePHPManifest { + availablePluginRow(manifest) } } } } - .frame(width: 320) - .litheWorkbenchSurface(LitheTheme.sidebar) - } - - private var languageExtensionsDisclosure: some View { - let plugins = listContent.languageExtensions - let enabledCount = plugins.filter { effectiveEnabledState(for: $0) }.count - return Button { - toggleLanguageExtensions() - } label: { - HStack(spacing: 10) { - Image(systemName: showsLanguageExtensions ? "chevron.down" : "chevron.right") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 14) - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(LitheTheme.accent) - .frame(width: 30, height: 30) - VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey("More Language Support")) - .font(.system(size: 13, weight: .semibold)) - Text(LocalizedStringKey("\(plugins.count) languages · \(enabledCount) enabled")) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Text("\(plugins.count)") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(LitheTheme.raised) - .clipShape(Capsule()) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityValue(showsLanguageExtensions ? Text("Expanded") : Text("Collapsed")) - .lithePointer() + .frame(maxWidth: .infinity) + .background(LitheTheme.settingsListSurface) } - private func toggleLanguageExtensions() { - guard !isSearching else { return } - if isLanguageExtensionsExpanded, - let selectedPluginID, - listContent.languageExtensions.contains(where: { $0.id == selectedPluginID }) { - self.selectedPluginID = listContent.standalonePlugins.first?.id - } - isLanguageExtensionsExpanded.toggle() - } - - private func pluginRow(_ plugin: PluginManagementSnapshot, isNested: Bool = false) -> some View { - let presentation = presentation(for: plugin) + private func pluginRow(_ plugin: PluginManagementSnapshot) -> some View { + let presentation = phpPresentation let isSelected = selectedPlugin?.id == plugin.id let isHovered = hoveredPluginID == plugin.id return HStack(spacing: 10) { @@ -194,14 +136,14 @@ struct PluginManagementView: View { Image(systemName: effectiveEnabledState(for: plugin) ? "checkmark.square.fill" : "square") .foregroundStyle(effectiveEnabledState(for: plugin) ? LitheTheme.accent : LitheTheme.secondaryText) } - .padding(.leading, isNested ? 32 : 14) + .padding(.leading, 14) .padding(.trailing, 14) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected - ? LitheTheme.selection - : (isHovered ? LitheTheme.raised : Color.clear) + ? LitheTheme.settingsSelection + : (isHovered ? LitheTheme.hoverBackground : Color.clear) ) .contentShape(Rectangle()) .onTapGesture(count: 1) { @@ -216,12 +158,32 @@ struct PluginManagementView: View { } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) - .lithePointer() + } + + private func availablePluginRow(_ manifest: PluginManifest) -> some View { + HStack(spacing: 10) { + Image(systemName: phpPresentation.systemImage) + .font(.system(size: 22)) + .foregroundStyle(phpPresentation.tint) + .frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(LocalizedStringKey(manifest.displayName)) + .font(.system(size: 13, weight: .semibold)) + Text(LocalizedStringKey("Not installed")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.settingsSelection) } @ViewBuilder private var detail: some View { if let plugin = selectedPlugin { - let presentation = presentation(for: plugin) + let presentation = phpPresentation let isEnabled = effectiveEnabledState(for: plugin) let hasPendingChange = pendingEnabledStates[plugin.id] != nil VStack(alignment: .leading, spacing: 0) { @@ -244,11 +206,6 @@ struct PluginManagementView: View { .buttonStyle(.borderedProminent) .tint(LitheTheme.accent) .disabled(isApplyingChanges || plugin.isRequired) - if plugin.origin == .marketplace { - Button(LocalizedStringKey("Uninstall"), role: .destructive) { model.uninstallPlugin(plugin.id) } - .buttonStyle(.bordered) - .disabled(isApplyingChanges) - } }.padding(24) Text(LocalizedStringKey("Overview")).font(.system(size: 15, weight: .semibold)).padding(.horizontal, 24) VStack(alignment: .leading, spacing: 12) { @@ -265,6 +222,22 @@ struct PluginManagementView: View { .padding(24) Spacer() } + .background(LitheTheme.settingsSurface) + } else if let manifest = availablePHPManifest { + VStack(alignment: .leading, spacing: 16) { + Text(LocalizedStringKey(manifest.displayName)) + .font(.system(size: 22, weight: .bold)) + Text(LocalizedStringKey("Install PHP Support from a signed plugin package.")) + .foregroundStyle(LitheTheme.secondaryText) + Button(LocalizedStringKey("Install Plugin from Disk…")) { + model.installPHPPluginPackage() + } + .buttonStyle(.borderedProminent) + .disabled(isApplyingChanges) + Spacer() + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack(spacing: 10) { Image(systemName: "puzzlepiece.extension") @@ -281,32 +254,32 @@ struct PluginManagementView: View { private var footer: some View { HStack { Spacer() - if !pendingEnabledStates.isEmpty || isApplyingChanges { - Text(LocalizedStringKey("Pending plugin changes: \(pendingEnabledStates.count)")) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - Button(LocalizedStringKey("Cancel")) { - pendingEnabledStates.removeAll() + Text(LocalizedStringKey("Pending plugin changes: \(pendingEnabledStates.count)")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + Button(LocalizedStringKey("Cancel")) { + settingsState.pendingPluginEnabledStates.removeAll() + } + .buttonStyle(.bordered) + .disabled(isApplyingChanges) + Button { + Task { @MainActor in + _ = await settingsState.applyPluginChanges(model.applyPluginEnabledChanges) } - .buttonStyle(.bordered) - .disabled(isApplyingChanges) - Button { - applyPendingChanges() - } label: { - if isApplyingChanges { - ProgressView().controlSize(.small) - } else { - Text(LocalizedStringKey("Confirm")) - } + } label: { + if isApplyingChanges { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey("Confirm")) } - .buttonStyle(.borderedProminent) - .tint(LitheTheme.accent) - .disabled(isApplyingChanges) } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(isApplyingChanges) } .padding(.horizontal, 14) .frame(height: 58) - .litheWorkbenchSurface(LitheTheme.toolHeader) + .background(LitheTheme.settingsSurface) .animation(.easeOut(duration: 0.15), value: pendingEnabledStates.isEmpty) } @@ -316,76 +289,31 @@ struct PluginManagementView: View { private func stageEnabledState(_ enabled: Bool, for plugin: PluginManagementSnapshot) { if enabled == plugin.isEnabled { - pendingEnabledStates.removeValue(forKey: plugin.id) + settingsState.pendingPluginEnabledStates.removeValue(forKey: plugin.id) } else { - pendingEnabledStates[plugin.id] = enabled + settingsState.pendingPluginEnabledStates[plugin.id] = enabled } } - private func applyPendingChanges() { - let changes = pendingEnabledStates - guard !changes.isEmpty, !isApplyingChanges else { return } - isApplyingChanges = true - Task { @MainActor in - let appliedPluginIDs = await model.applyPluginEnabledChanges(changes) - for pluginID in appliedPluginIDs { - pendingEnabledStates.removeValue(forKey: pluginID) - } - isApplyingChanges = false - } - } - - private func presentation(for plugin: PluginManagementSnapshot) -> PluginPresentation { - let id = plugin.id.rawValue - if id == "dev.lithe.plugin.go-support" { - return PluginPresentation( - systemImage: "g.circle.fill", - tint: LitheTheme.accent, - summary: "Adds Go language-server integration, formatting, running, and test support." - ) - } - - let languageID = plugin.manifest.languageSupports?.first?.id ?? "" - let supportsExecution = plugin.manifest.modules.contains { - $0.manifest.providedCapabilities.contains(.languageExecutionExtension(languageID)) - } - let summary = supportsExecution - ? "Adds language-server integration, formatting, running, and test support." - : "Adds language-server integration and formatting support." - - switch languageID { - case "python": return .init(systemImage: "chevron.left.forwardslash.chevron.right", tint: LitheTheme.warning, summary: summary) - case "node": return .init(systemImage: "hexagon.fill", tint: LitheTheme.success, summary: summary) - case "rust": return .init(systemImage: "gearshape.2.fill", tint: Color.orange, summary: summary) - case "swift": return .init(systemImage: "swift", tint: Color.orange, summary: summary) - case "clangd", "csharp", "fsharp", "kotlin", "scala", "groovy", "zig", "solidity": - return .init(systemImage: "chevron.left.forwardslash.chevron.right", tint: LitheTheme.accent, summary: summary) - case "html", "css", "vue", "svelte", "astro", "php": - return .init(systemImage: "globe", tint: LitheTheme.success, summary: summary) - case "json", "yaml", "xml", "toml", "graphql", "protobuf", "prisma": - return .init(systemImage: "curlybraces.square.fill", tint: Color.cyan, summary: summary) - case "markdown": return .init(systemImage: "doc.richtext.fill", tint: LitheTheme.secondaryText, summary: summary) - case "sql": return .init(systemImage: "cylinder.fill", tint: LitheTheme.warning, summary: summary) - case "dockerfile": return .init(systemImage: "shippingbox.fill", tint: Color.cyan, summary: summary) - case "terraform": return .init(systemImage: "square.3.layers.3d", tint: Color.indigo, summary: summary) - case "shell", "powershell", "make", "cmake": - return .init(systemImage: "terminal.fill", tint: LitheTheme.secondaryText, summary: summary) - default: return .init(systemImage: "puzzlepiece.extension.fill", tint: LitheTheme.accent, summary: summary) - } + private var phpPresentation: PluginPresentation { + PluginPresentation( + systemImage: "globe", + tint: LitheTheme.success, + summary: "Adds PHP language-server integration, formatting, running, and test support." + ) } } struct PluginManagementListContent { - let standalonePlugins: [PluginManagementSnapshot] - let languageExtensions: [PluginManagementSnapshot] + let plugins: [PluginManagementSnapshot] + + var availablePHPManifest: PluginManifest? { + guard plugins.isEmpty else { return nil } + return OfficialPluginCatalog.manifests.first { $0.id == OfficialPluginCatalog.phpPluginID } + } init(plugins: [PluginManagementSnapshot]) { - standalonePlugins = plugins.filter { plugin in - plugin.manifest.languageSupports?.isEmpty != false - } - languageExtensions = plugins.filter { plugin in - plugin.manifest.languageSupports?.isEmpty == false - } + self.plugins = plugins.filter { $0.id == OfficialPluginCatalog.phpPluginID } } } diff --git a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift index c5aacec88..5e3f984b4 100644 --- a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift @@ -13,7 +13,6 @@ struct ProjectRuntimeSettingsView: View { var body: some View { VStack(spacing: 0) { header - Rectangle().fill(LitheTheme.divider).frame(height: 1) if model.workspaceURL == nil { emptyWorkspace } else { @@ -43,9 +42,6 @@ struct ProjectRuntimeSettingsView: View { private var header: some View { HStack { - Text("Project · JDK & Maven") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) Spacer() if feature.isDiscovering { ProgressView() @@ -72,7 +68,7 @@ struct ProjectRuntimeSettingsView: View { .disabled(feature.isDiscovering) } .padding(.horizontal, 16) - .frame(height: 42) + .frame(height: 40) .background(LitheTheme.settingsSurface) } @@ -85,7 +81,7 @@ struct ProjectRuntimeSettingsView: View { .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) } - .padding(28) + .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -120,7 +116,7 @@ struct ProjectRuntimeSettingsView: View { if subproject.displaysPath { Text(subproject.relativePath) .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(isSelected ? Color.white.opacity(0.8) : LitheTheme.tertiaryText) + .foregroundStyle(isSelected ? LitheTheme.settingsSelectionText.opacity(0.8) : LitheTheme.tertiaryText) .lineLimit(1) } } @@ -133,8 +129,8 @@ struct ProjectRuntimeSettingsView: View { .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) - .foregroundStyle(isSelected ? Color.white : LitheTheme.primaryText) + .buttonStyle(.litheNoPress) + .foregroundStyle(isSelected ? LitheTheme.settingsSelectionText : LitheTheme.primaryText) .padding(.leading, subproject.parentID == nil ? 0 : 12) } @@ -148,9 +144,9 @@ struct ProjectRuntimeSettingsView: View { VStack(alignment: .leading, spacing: 8) { if let subproject = selectedSubproject { Text(subproject.title) - .font(.system(size: 22, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) - .padding(.bottom, 8) + .padding(.bottom, 4) if subproject.kind == .projectDefaults { projectDefaultsDetail } else if subproject.usesJava { @@ -160,8 +156,8 @@ struct ProjectRuntimeSettingsView: View { } } } - .padding(.horizontal, 28) - .padding(.vertical, 22) + .padding(.horizontal, 16) + .padding(.bottom, 20) .frame(maxWidth: .infinity, alignment: .leading) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) @@ -440,26 +436,28 @@ struct ProjectRuntimeSettingsView: View { } private func group(_ title: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 13) { - Text(LocalizedStringKey(title)) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize() + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } content() } .font(.system(size: 12.5)) .foregroundStyle(LitheTheme.primaryText) - .padding(.vertical, 16) + .padding(.top, 16) .frame(maxWidth: .infinity, alignment: .leading) - .overlay(alignment: .bottom) { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - } } private func row(_ title: String, @ViewBuilder content: () -> Content) -> some View { - HStack { + HStack(spacing: 8) { Text(LocalizedStringKey(title)) - Spacer() + .fixedSize(horizontal: true, vertical: false) content() + Spacer(minLength: 0) } .frame(minHeight: 28) } diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 723d05fbf..4fb5b0565 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import LitheLocalHistoryModule import SwiftUI @@ -207,8 +208,18 @@ private struct ActiveSessionChrome: View { title: windowTitle ) ) - .onReceive(session.workbenchFeature.$isSettingsPresented) { isPresented in - guard isPresented else { return } + .onReceive(session.workbenchFeature.$settingsCategoryRequest.dropFirst()) { _ in + SettingsWindowChrome.ownerWindow = NSApp.orderedWindows.first { + ($0.delegate as? LitheWindowCoordinator)?.projectSessions.windowScope == scope + } + projectSessions.bindSettings(to: session.id) + if let settingsWindow = SettingsWindowChrome.settingsWindow { + SettingsWindowChrome.configure( + settingsWindow, + title: settingsWindow.title, + themePreference: session.settings.themePreference + ) + } openWindow(id: LitheWindowID.settings) } .sheet(isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift b/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift index 2b612a509..923e87eee 100644 --- a/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift @@ -37,9 +37,9 @@ private struct RunConfigurationSettingsContent: View { .id(configuration.id) } else { ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text("Run configurations").font(.title2) + VStack(alignment: .leading, spacing: 12) { Text("Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.") + .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) if feature.isLoadingProject || isGenerating { ProgressView("Identifying project…") @@ -68,11 +68,12 @@ private struct RunConfigurationSettingsContent: View { .padding(10) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(feature.configurationStatus != .ready || feature.isLoadingProject || isGenerating) } } - .padding(24) + .padding(.horizontal, 16) + .padding(.bottom, 20) } .confirmationDialog("Identify Again", isPresented: $confirmGeneration) { Button("Rescan") { diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index fe00f9ebe..62a78437b 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -16,24 +16,46 @@ final class SettingsViewState: ObservableObject { @Published var isFormatPickerPresented = false @Published var detectedTerminalShells: [String] = [] @Published var knownTerminalShells: [String] = [] + @Published var pendingPluginEnabledStates: [PluginID: Bool] = [:] + @Published private(set) var isApplyingPluginChanges = false init(initialCategory: SettingsCategory) { selection = initialCategory } + + func applyPluginChanges( + _ apply: ([PluginID: Bool]) async -> Set + ) async -> Bool { + guard !isApplyingPluginChanges else { return false } + guard !pendingPluginEnabledStates.isEmpty else { return true } + isApplyingPluginChanges = true + let applied = await apply(pendingPluginEnabledStates) + for pluginID in applied { + pendingPluginEnabledStates.removeValue(forKey: pluginID) + } + isApplyingPluginChanges = false + return pendingPluginEnabledStates.isEmpty + } } struct SettingsView: View { + @FocusState private var isSearchFocused: Bool @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @EnvironmentObject private var model: AppModel @EnvironmentObject private var updateChecker: UpdateChecker @ObservedObject var settings: AppSettings @ObservedObject var viewState: SettingsViewState + @AppStorage("lithe.settings.categorySidebarWidth") private var categorySidebarWidth = 234.0 @State private var missingTerminalShellPath: String? + @State private var expandedSidebarGroups: Set = [] let initialCategory: SettingsCategory /// Changes with every category request; see `WorkbenchFeatureModel.settingsCategoryRequest`. let categoryRequest: Int private let onDismiss: (() -> Void)? + static let categoryMinimumWidth: CGFloat = 234 + static let contentMinimumWidth: CGFloat = 500 private static let footerActionLabelWidth: CGFloat = 52 init( @@ -52,20 +74,32 @@ struct SettingsView: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: 0) { - categories - Rectangle().fill(LitheTheme.divider).frame(width: 1) - content + GeometryReader { geometry in + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(categorySidebarWidth), + minimum: Self.categoryMinimumWidth, + maximum: max(Self.categoryMinimumWidth, geometry.size.width - SplitHandleView.thickness - Self.contentMinimumWidth), + flexibleMinimum: Self.contentMinimumWidth, + highlightsOnHover: false, + onCommit: { categorySidebarWidth = Double($0) }, + sized: { categories }, + flexible: { + content.simultaneousGesture(TapGesture().onEnded { isSearchFocused = false }) + } + ) } Rectangle().fill(LitheTheme.divider).frame(height: 1) footer } - .frame(minWidth: 820, minHeight: 620) + .frame(minWidth: 900, minHeight: 668) .background { LitheTheme.settingsSurface .ignoresSafeArea() } .onAppear { + expandGroup(containing: viewState.selection) syncVisibilityDrafts() model.refreshAIConfigurations() syncAIProviderDraft() @@ -81,6 +115,10 @@ struct SettingsView: View { .onChange(of: categoryRequest) { _ in viewState.searchQuery = "" viewState.selection = initialCategory + expandGroup(containing: initialCategory) + } + .onChange(of: viewState.selection) { category in + expandGroup(containing: category) } .onChange(of: viewState.searchQuery) { _ in guard !filteredCategories.contains(viewState.selection), @@ -88,6 +126,7 @@ struct SettingsView: View { viewState.selection = firstMatch } .environment(\.locale, settings.language.locale) + .font(LitheTheme.settingsFont) .alert("Shell not found", isPresented: Binding( get: { missingTerminalShellPath != nil }, set: { if !$0 { missingTerminalShellPath = nil } } @@ -107,15 +146,20 @@ struct SettingsView: View { private var categories: some View { VStack(spacing: 0) { settingsSearchField - .padding(12) - - Rectangle().fill(LitheTheme.divider).frame(height: 1) + .padding(.horizontal, 8) + .padding(.top, 11) + .padding(.bottom, 8) ScrollView { - VStack(spacing: 1) { - ForEach(filteredCategories) { category in - categoryButton(category) - } + VStack(spacing: 0) { + categoryGroup("Appearance & Behavior", categories: [.general, .updates]) + categoryButton(.keymap) + categoryButton(.editor) + categoryButton(.plugins) + categoryGroup("Version Control", categories: [.git]) + categoryGroup("Build, Execution, Deployment", categories: [.project, .run]) + categoryGroup("Languages & Frameworks", categories: [.lsp]) + categoryGroup("Tools", categories: [.terminal, .ai, .providers, .diagnostics]) if filteredCategories.isEmpty { VStack(spacing: 8) { @@ -129,41 +173,90 @@ struct SettingsView: View { .padding(.top, 28) } } - .padding(8) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } - .frame(width: 244) + .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .background(LitheTheme.settingsSurface) } private var settingsSearchField: some View { - LitheSettingsSearchField("Search settings", text: $viewState.searchQuery) + LitheSettingsSearchField("", text: $viewState.searchQuery, focus: $isSearchFocused) + .accessibilityLabel("Search settings") } - private func categoryButton(_ category: SettingsCategory) -> some View { - let isSelected = viewState.selection == category - return Button { - viewState.selection = category - } label: { - HStack(spacing: 10) { - Image(systemName: category.icon) - .font(.system(size: 12.5, weight: .medium)) - .frame(width: 18) - Text(LocalizedStringKey(category.title)) - .font(.system(size: 12.5, weight: .regular)) - Spacer(minLength: 8) + @ViewBuilder + private func categoryGroup(_ title: String, categories: [SettingsCategory]) -> some View { + let visible = categories.filter { filteredCategories.contains($0) } + if !visible.isEmpty { + let expanded = expandedSidebarGroups.contains(title) || !viewState.searchQuery.isEmpty + Button { + isSearchFocused = false + if expandedSidebarGroups.contains(title) { + expandedSidebarGroups.remove(title) + } else { + expandedSidebarGroups.insert(title) + } + } label: { + HStack(spacing: 8) { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .medium)) + .frame(width: 10) + Text(LocalizedStringKey(title)) + .font(LitheTheme.settingsStrongFont) + Spacer(minLength: 0) + } + .padding(.leading, 16) + .padding(.trailing, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.primaryText) + if expanded { + ForEach(visible) { category in + categoryButton(category, nested: true) + } } - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: LitheTheme.Metrics.treeRowHeight) - .background(isSelected ? LitheTheme.settingsSelection : .clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) - .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) - .foregroundStyle(isSelected ? Color.white : LitheTheme.primaryText) + } + + @ViewBuilder + private func categoryButton(_ category: SettingsCategory, nested: Bool = false) -> some View { + if filteredCategories.contains(category) { + let isSelected = viewState.selection == category + Button { + isSearchFocused = false + viewState.selection = category + } label: { + HStack(spacing: 8) { + Color.clear.frame(width: 10) + Text(LocalizedStringKey(category.title)) + .font(nested ? LitheTheme.settingsFont : LitheTheme.settingsStrongFont) + Spacer(minLength: 0) + } + .padding(.leading, nested ? 30 : 16) + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 24) + .background(isSelected ? LitheTheme.settingsSelection : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.primaryText) + } + } + + private func expandGroup(containing category: SettingsCategory) { + switch category { + case .general, .updates: expandedSidebarGroups.insert("Appearance & Behavior") + case .git: expandedSidebarGroups.insert("Version Control") + case .project, .run: expandedSidebarGroups.insert("Build, Execution, Deployment") + case .lsp: expandedSidebarGroups.insert("Languages & Frameworks") + case .terminal, .ai, .providers, .diagnostics: expandedSidebarGroups.insert("Tools") + case .keymap, .editor, .plugins: break + } } private var filteredCategories: [SettingsCategory] { @@ -204,6 +297,8 @@ struct SettingsView: View { ["Updates", "Application version", "Update status", "Check for Updates"] case .diagnostics: ["Diagnostics", "Diagnostics bundle", "Export logs", "Bug report"] + case .plugins: + ["Plugins", "Installed", "Marketplace", "Language support"] } } @@ -215,8 +310,51 @@ struct SettingsView: View { ) } - @ViewBuilder private var content: some View { + VStack(spacing: 0) { + if viewState.selection != .plugins { + HStack(spacing: 8) { + ForEach(Array(settingsBreadcrumb.enumerated()), id: \.offset) { index, title in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + } + Text(LocalizedStringKey(title)) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(height: 48) + } + + settingsContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var settingsBreadcrumb: [String] { + switch viewState.selection { + case .general: ["Appearance & Behavior", "General"] + case .editor: ["Editor"] + case .keymap: ["Keymap"] + case .project: ["Build, Execution, Deployment", "Project · JDK & Maven"] + case .run: ["Build, Execution, Deployment", "Run configurations"] + case .terminal: ["Tools", "Terminal"] + case .lsp: ["Languages & Frameworks", "LSP"] + case .ai: ["Tools", "AI & Commit"] + case .providers: ["Tools", "AI Providers"] + case .git: ["Version Control", "Git"] + case .updates: ["Appearance & Behavior", "Updates"] + case .diagnostics: ["Tools", "Diagnostics"] + case .plugins: ["Plugins"] + } + } + + @ViewBuilder + private var settingsContent: some View { if filteredCategories.isEmpty { VStack(spacing: 10) { Image(systemName: "magnifyingglass") @@ -243,14 +381,12 @@ struct SettingsView: View { language: settings.language ) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewState.selection == .plugins { + PluginManagementView(settingsState: viewState) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { - VStack(alignment: .leading, spacing: 8) { - Text(LocalizedStringKey(viewState.selection.title)) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - .padding(.bottom, 8) - + VStack(alignment: .leading, spacing: 12) { switch viewState.selection { case .general: generalSettings case .editor: editorSettings @@ -270,10 +406,11 @@ struct SettingsView: View { .frame(maxWidth: 760, alignment: .leading) case .updates: updatesSettings case .diagnostics: diagnosticsSettings + case .plugins: EmptyView() } } - .padding(.horizontal, 28) - .padding(.vertical, 22) + .padding(.horizontal, 16) + .padding(.bottom, 20) .frame(maxWidth: .infinity, alignment: .leading) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) @@ -287,7 +424,7 @@ struct SettingsView: View { LitheSettingsSelect( selection: $settings.colorTheme, options: AppColorTheme.allCases, - width: 180, + width: 190, accessibilityLabel: "Color theme", title: \AppColorTheme.title ) @@ -331,7 +468,7 @@ struct SettingsView: View { Button("Remove") { model.workbenchBackgroundFeature.clear() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .lithePointer() } @@ -350,7 +487,7 @@ struct SettingsView: View { Button("Retry") { model.workbenchBackgroundFeature.retry() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .lithePointer() } @@ -371,7 +508,7 @@ struct SettingsView: View { LitheSettingsSelect( selection: $settings.language, options: AppLanguage.allCases, - width: 180, + width: 190, accessibilityLabel: "Language", title: \AppLanguage.title ) @@ -387,7 +524,7 @@ struct SettingsView: View { LitheSettingsSelect( selection: $settings.projectOpenBehavior, options: ProjectOpenBehavior.allCases, - width: 180, + width: 190, accessibilityLabel: "Open projects in", title: \ProjectOpenBehavior.title ) @@ -441,26 +578,12 @@ struct SettingsView: View { Text("Directories") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $viewState.hiddenDirectoriesDraft) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 66) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 66) Text("File patterns") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $viewState.hiddenFilePatternsDraft) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 52) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 52) HStack(spacing: 8) { Spacer() @@ -497,7 +620,7 @@ struct SettingsView: View { .font(.system(size: 16, weight: .regular)) .frame(width: 26, height: 26) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.secondaryText) .contentShape(Rectangle()) .lithePointer() @@ -505,12 +628,7 @@ struct SettingsView: View { } .padding(.horizontal, 12) .frame(maxWidth: .infinity, minHeight: 46, maxHeight: 46) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsControlChrome(background: LitheTheme.settingsTextFieldBackground) HStack(spacing: 6) { Text("Default directory") @@ -530,7 +648,7 @@ struct SettingsView: View { } label: { Text("Restore Default") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .lithePointer() } @@ -959,14 +1077,7 @@ struct SettingsView: View { Text("Custom instructions") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $settings.commitMessageAI.customInstructions) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 92) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 92) } Text("Low effort and a small output limit are recommended for fast commit-message generation.") @@ -975,13 +1086,15 @@ struct SettingsView: View { } group("Pull request description generation") { - Picker("Description format", selection: $settings.commitMessageAI.pullRequestFormat) { - ForEach(PullRequestDescriptionFormat.allCases) { format in - Text(LocalizedStringKey(format.title)).tag(format) - } + row("Description format") { + LitheSettingsSelect( + selection: $settings.commitMessageAI.pullRequestFormat, + options: PullRequestDescriptionFormat.allCases, + width: 220, + accessibilityLabel: "Description format", + title: \PullRequestDescriptionFormat.title + ) } - .frame(maxWidth: 260, alignment: .leading) - .lithePointer() if settings.commitMessageAI.pullRequestFormat == .custom { HStack { @@ -998,15 +1111,7 @@ struct SettingsView: View { } TextEditor(text: $settings.commitMessageAI.pullRequestCustomTemplate) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 150) - .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 150) Text("Supported placeholders: {summary}, {changes}, {testing}, {risks}.") .font(LitheTheme.smallFont) @@ -1063,17 +1168,12 @@ struct SettingsView: View { } .padding(.horizontal, 10) .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) - .background(viewState.isFormatPickerPresented ? LitheTheme.inputBackground.opacity(0.9) : LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke( - viewState.isFormatPickerPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, - lineWidth: 1 - ) - } - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheSettingsControlChrome( + background: LitheTheme.settingsTextFieldBackground, + border: viewState.isFormatPickerPresented ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder + ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .popover(isPresented: $viewState.isFormatPickerPresented, arrowEdge: .bottom) { formatPickerPopover @@ -1182,7 +1282,7 @@ struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .litheRowHover( isActive: isSelected, cornerRadius: 5, @@ -1208,12 +1308,7 @@ struct SettingsView: View { .padding(.horizontal, 9) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsControlChrome(background: LitheTheme.settingsTextFieldBackground) .id(settings.commitMessageAI.format) .transition(.opacity.combined(with: .move(edge: .top))) .animation(formatPickerAnimation, value: settings.commitMessageAI.format) @@ -1393,26 +1488,28 @@ struct SettingsView: View { } private func group(_ title: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 13) { - Text(LocalizedStringKey(title)) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize() + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } content() } .font(.system(size: 12.5)) .foregroundStyle(LitheTheme.primaryText) - .padding(.vertical, 16) + .padding(.top, 16) .frame(maxWidth: .infinity, alignment: .leading) - .overlay(alignment: .bottom) { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - } } private func row(_ title: String, @ViewBuilder content: () -> Content) -> some View { - HStack { + HStack(spacing: 8) { Text(LocalizedStringKey(title)) - Spacer() + .fixedSize(horizontal: true, vertical: false) content() + Spacer(minLength: 0) } .frame(minHeight: 28) } @@ -1470,24 +1567,34 @@ struct SettingsView: View { private var footer: some View { HStack { Button("Restore Defaults") { settings.restoreDefaults() } - .buttonStyle(LitheSecondaryButtonStyle()) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 28)) Spacer() HStack(spacing: 10) { Button { closeSettings() } label: { Text("Cancel") .frame(minWidth: Self.footerActionLabelWidth) } - .buttonStyle(LitheSecondaryButtonStyle()) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 28)) .keyboardShortcut(.cancelAction) - Button { closeSettings() } label: { + .disabled(viewState.isApplyingPluginChanges) + Button { + Task { @MainActor in + if await viewState.applyPluginChanges(model.applyPluginEnabledChanges) { + closeSettings() + } + } + } label: { Text("OK") .frame(minWidth: Self.footerActionLabelWidth) } .buttonStyle(LithePrimaryButtonStyle( backgroundColor: LitheTheme.settingsPrimaryAction, - restingOpacity: 1 + restingOpacity: 1, + horizontalPadding: 10, + height: 28 )) .keyboardShortcut(.defaultAction) + .disabled(viewState.isApplyingPluginChanges) } } .padding(.horizontal, 16) diff --git a/macos/Sources/Lithe/Views/App/StableRollbackControl.swift b/macos/Sources/Lithe/Views/App/StableRollbackControl.swift index 17135a49b..88a49b2ae 100644 --- a/macos/Sources/Lithe/Views/App/StableRollbackControl.swift +++ b/macos/Sources/Lithe/Views/App/StableRollbackControl.swift @@ -10,7 +10,7 @@ struct StableRollbackControl: View { if compact, updateChecker.isPreview { Button("Return to Stable") { showingDetails = true } .font(LitheTheme.smallFont) - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .sheet(isPresented: $showingDetails) { VStack(alignment: .leading, spacing: 16) { Text("Return to Stable").font(.headline) diff --git a/macos/Sources/Lithe/Views/App/UpdateControl.swift b/macos/Sources/Lithe/Views/App/UpdateControl.swift index 9963f36ae..8447e4fc3 100644 --- a/macos/Sources/Lithe/Views/App/UpdateControl.swift +++ b/macos/Sources/Lithe/Views/App/UpdateControl.swift @@ -76,7 +76,7 @@ struct UpdateControl: View { } label: { Label(compact ? "Update failed" : "Retry update", systemImage: "exclamationmark.triangle") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.warning) .help(message) case .idle, .upToDate: @@ -85,7 +85,7 @@ struct UpdateControl: View { } label: { Label("Check for Updates", systemImage: "arrow.clockwise") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.secondaryText) } } diff --git a/macos/Sources/Lithe/Views/App/WelcomeView.swift b/macos/Sources/Lithe/Views/App/WelcomeView.swift index 47216eac3..0a411a3ca 100644 --- a/macos/Sources/Lithe/Views/App/WelcomeView.swift +++ b/macos/Sources/Lithe/Views/App/WelcomeView.swift @@ -82,7 +82,8 @@ struct WelcomeView: View { .frame(width: 28, height: 28) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: selectionColor) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) + .lithePointer() .foregroundStyle(mutedColor) .help("Settings") .accessibilityLabel("Settings") @@ -118,19 +119,19 @@ struct WelcomeView: View { Button("New Project") { model.chooseProject(title: "New Project", prompt: "Choose Folder") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) Button("Open") { model.chooseProject() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) Button("Clone Repository") { model.showCloneRepository() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) } .padding(.horizontal, 14) @@ -201,7 +202,8 @@ struct WelcomeView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) + .lithePointer() .disabled(!exists) Spacer(minLength: 0) diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index 911210f61..4d6b04a54 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -304,7 +304,7 @@ private struct LitheContextMenuRow: View { } .padding(.horizontal, settingsStyle ? 6 : 5) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(!item.isEnabled) .opacity(item.isEnabled ? 1 : 0.45) .onHover { hovering in diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 3e573460d..d45b08067 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -2,15 +2,38 @@ import AppKit import SwiftUI private enum SettingsSelectMetrics { + static let controlHeight: CGFloat = 28 + static let controlCornerRadius: CGFloat = 4 static let fontSize: CGFloat = 12.5 - static let checkmarkWidth: CGFloat = 14 - static let itemSpacing: CGFloat = 8 + static let popupCornerRadius: CGFloat = 8 + static let itemHeight: CGFloat = 24 static let itemHorizontalPadding: CGFloat = 8 - static let popupPadding: CGFloat = 5 + static let popupPadding: CGFloat = 6 static let screenMargin: CGFloat = 24 + static let maximumPopupHeight: CGFloat = 10 * itemHeight + 2 * popupPadding +} + +private struct LitheSettingsControlChrome: ViewModifier { + let background: Color + let border: Color + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + content + .background { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(background) + } + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(border, lineWidth: 1) + } + } } struct LitheSettingsSearchField: View { + @FocusState private var isFocused: Bool + private let externalFocus: FocusState.Binding? private let placeholder: LocalizedStringKey @Binding private var text: String private let onTextChanged: ((String) -> Void)? @@ -18,22 +41,23 @@ struct LitheSettingsSearchField: View { init( _ placeholder: LocalizedStringKey, text: Binding, + focus: FocusState.Binding? = nil, onTextChanged: ((String) -> Void)? = nil ) { self.placeholder = placeholder _text = text + externalFocus = focus self.onTextChanged = onTextChanged } var body: some View { HStack(spacing: 7) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.tertiaryText) + LitheIDEAIcon(resourcePath: "expui/general/search.svg", size: 16, fallbackSystemImage: "magnifyingglass", preservesOriginalColors: true) TextField(placeholder, text: $text) .textFieldStyle(.plain) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) + .focused(externalFocus ?? $isFocused) if !text.isEmpty { Button { @@ -43,19 +67,17 @@ struct LitheSettingsSearchField: View { .font(.system(size: 11)) .foregroundStyle(LitheTheme.tertiaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Clear search") } } .padding(.horizontal, 9) .frame(height: 28) - .background(Color.clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsControlChrome( + background: .clear, + border: (externalFocus?.wrappedValue ?? isFocused) ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder + ) .onChange(of: text) { value in onTextChanged?(value) } @@ -73,7 +95,8 @@ struct LitheSettingsSelect: View { private let isAvailable: (Value) -> Bool private let onUnavailableSelection: ((Value) -> Void)? @State private var isPresented = false - @State private var availablePopupWidth: CGFloat? + @State private var popupID = UUID() + @State private var popupAnchor = LitheSettingsSelectAnchorReference() init( selection: Binding, @@ -97,18 +120,15 @@ struct LitheSettingsSelect: View { var body: some View { Button { - if !isPresented { - // Capture the presenting screen before the popover can become the key window. - let screen = NSApp.keyWindow?.screen ?? NSScreen.main - availablePopupWidth = screen.map { - max(1, $0.visibleFrame.width - 2 * SettingsSelectMetrics.screenMargin) - } + if isPresented { + LitheSettingsSelectPopupPresenter.shared.dismiss(ownerID: popupID) + } else { + showPopup() } - isPresented.toggle() } label: { HStack(spacing: 8) { Text(LocalizedStringKey(title(selection))) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) .foregroundStyle(isAvailable(selection) ? LitheTheme.primaryText : LitheTheme.tertiaryText) .lineLimit(1) @@ -120,82 +140,354 @@ struct LitheSettingsSelect: View { .rotationEffect(.degrees(isPresented ? 180 : 0)) } .padding(.horizontal, 9) - .frame(width: width, height: 30, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.settingsControlBackground) + .frame(width: width, height: SettingsSelectMetrics.controlHeight, alignment: .leading) + .litheSettingsControlChrome( + background: LitheTheme.settingsSelectBackground, + border: isPresented ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder ) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(isPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, lineWidth: 1) - } + .background(LitheSettingsSelectAnchorView(reference: popupAnchor)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .accessibilityLabel(Text(LocalizedStringKey(accessibilityLabel))) - .popover(isPresented: $isPresented, arrowEdge: .bottom) { - VStack(spacing: 2) { - ForEach(options, id: \.self) { option in - Button { - if isAvailable(option) { - selection = option - } else { - onUnavailableSelection?(option) - } - isPresented = false - } label: { - HStack(spacing: SettingsSelectMetrics.itemSpacing) { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(LitheTheme.accent) - .frame(width: SettingsSelectMetrics.checkmarkWidth) - .opacity(selection == option ? 1 : 0) - - Text(LocalizedStringKey(title(option))) - .font(.system(size: SettingsSelectMetrics.fontSize)) - .foregroundStyle(isAvailable(option) ? LitheTheme.primaryText : LitheTheme.tertiaryText) - .lineLimit(expandsToFitOptions ? nil : 1) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.leading) - - Spacer(minLength: SettingsSelectMetrics.itemSpacing) - } - .padding(.horizontal, SettingsSelectMetrics.itemHorizontalPadding) - .padding(.vertical, expandsToFitOptions ? 4 : 0) - .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - .litheRowHover( - isActive: selection == option, - activeBackground: LitheTheme.subtleSelection.opacity(isAvailable(option) ? 1 : 0.35) - ) - .contentShape(Rectangle()) - } - .buttonStyle(LitheTreeRowButtonStyle()) - .lithePointer() - .help(isAvailable(option) ? "" : "Shell is not available at this path") - } + .accessibilityValue(Text(LocalizedStringKey(title(selection)))) + .onChange(of: options) { _ in + if isPresented { showPopup() } + } + .onDisappear { + LitheSettingsSelectPopupPresenter.shared.dismiss(ownerID: popupID) + } + } + + private func showPopup() { + guard let anchor = popupAnchor.view, let window = anchor.window else { return } + let anchorFrame = window.convertToScreen(anchor.convert(anchor.bounds, to: nil)) + let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? anchorFrame + let popupWidth = preferredPopupWidth(maximumWidth: max(1, visibleFrame.width - SettingsSelectMetrics.screenMargin)) + let state = LitheSettingsSelectPopupState( + selectedIndex: options.firstIndex(of: selection) ?? 0, + optionCount: options.count + ) { index in + let option = options[index] + if isAvailable(option) { + selection = option + } else { + onUnavailableSelection?(option) } - .padding(SettingsSelectMetrics.popupPadding) - .frame(width: preferredPopupWidth) - .lithePopupChrome(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + LitheSettingsSelectPopupPresenter.shared.dismiss(ownerID: popupID) + } + let content = LitheSettingsSelectPopupContent( + state: state, + options: options, + width: popupWidth, + title: title, + expandsToFitOptions: expandsToFitOptions, + isAvailable: isAvailable + ) + let measured = NSHostingView(rootView: content.rows.environment(\.locale, locale)) + let popupHeight = min( + measured.fittingSize.height, + SettingsSelectMetrics.maximumPopupHeight, + max(1, visibleFrame.height - SettingsSelectMetrics.screenMargin) + ) + let popup = content.environment(\.locale, locale) + LitheSettingsSelectPopupPresenter.shared.show( + ownerID: popupID, + content: AnyView(popup), + state: state, + anchorWindow: window, + anchorFrame: anchorFrame, + size: NSSize(width: popupWidth, height: popupHeight), + visibleFrame: visibleFrame, + appearance: window.effectiveAppearance + ) { + isPresented = false } + isPresented = true } - private var preferredPopupWidth: CGFloat { + private func preferredPopupWidth(maximumWidth: CGFloat) -> CGFloat { guard expandsToFitOptions else { return width } - let font = NSFont.systemFont(ofSize: SettingsSelectMetrics.fontSize) + let font = NSFont(name: "Inter-Regular", size: SettingsSelectMetrics.fontSize) + ?? NSFont.systemFont(ofSize: SettingsSelectMetrics.fontSize) let titleWidth = options.reduce(CGFloat.zero) { widest, option in let text = String(localized: String.LocalizationValue(title(option)), locale: locale) return max(widest, (text as NSString).size(withAttributes: [.font: font]).width) } - // Include the checkmark, both HStack gaps, trailing spacer, and both layers of padding. - let chromeWidth = SettingsSelectMetrics.checkmarkWidth - + 3 * SettingsSelectMetrics.itemSpacing - + 2 * SettingsSelectMetrics.itemHorizontalPadding + let chromeWidth = 2 * SettingsSelectMetrics.itemHorizontalPadding + 2 * SettingsSelectMetrics.popupPadding let contentWidth = max(width, ceil(titleWidth) + chromeWidth) - // Derive the width from current titles so discovery updates also resize an open popover. - return min(contentWidth, availablePopupWidth ?? width) + // Derive the width from current titles when discovery refreshes an open list. + return min(contentWidth, maximumWidth) + } +} + +private final class LitheSettingsSelectAnchorReference { + weak var view: NSView? +} + +private struct LitheSettingsSelectAnchorView: NSViewRepresentable { + let reference: LitheSettingsSelectAnchorReference + + func makeNSView(context: Context) -> NSView { + let view = LitheSettingsSelectAnchorNSView() + reference.view = view + return view + } + + func updateNSView(_ view: NSView, context: Context) { + reference.view = view + } +} + +private final class LitheSettingsSelectAnchorNSView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +@MainActor +private final class LitheSettingsSelectPopupState: ObservableObject { + let selectedIndex: Int + @Published var highlightedIndex: Int + @Published var keyboardScrollIndex: Int? + @Published var popupHeight: CGFloat = 0 + let optionCount: Int + let onChoose: (Int) -> Void + + init(selectedIndex: Int, optionCount: Int, onChoose: @escaping (Int) -> Void) { + self.selectedIndex = selectedIndex + highlightedIndex = selectedIndex + self.optionCount = optionCount + self.onChoose = onChoose + } + + func handleKey(_ event: NSEvent, dismiss: () -> Void) -> Bool { + switch event.keyCode { + case 125, 126: // Down / Up + guard optionCount > 0 else { return true } + highlightedIndex = (highlightedIndex + (event.keyCode == 125 ? 1 : optionCount - 1)) % optionCount + keyboardScrollIndex = highlightedIndex + case 36, 76: // Return / keypad Enter + guard optionCount > 0 else { return true } + onChoose(highlightedIndex) + case 53: // Escape + dismiss() + default: + return false + } + return true + } +} + +private struct LitheSettingsSelectPopupContent: View { + @ObservedObject var state: LitheSettingsSelectPopupState + let options: [Value] + let width: CGFloat + let title: (Value) -> String + let expandsToFitOptions: Bool + let isAvailable: (Value) -> Bool + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + rows + } + .onAppear { proxy.scrollTo(state.highlightedIndex) } + .onChange(of: state.keyboardScrollIndex) { index in + if let index { proxy.scrollTo(index) } + } + } + .scrollContentBackground(.hidden) + .frame(width: width, height: state.popupHeight) + .litheSettingsControlChrome( + background: LitheTheme.settingsPopupBackground, + border: LitheTheme.settingsPopupBorder, + cornerRadius: SettingsSelectMetrics.popupCornerRadius + ) + .clipShape(RoundedRectangle(cornerRadius: SettingsSelectMetrics.popupCornerRadius)) + } + + var rows: some View { + VStack(spacing: 0) { + ForEach(Array(options.enumerated()), id: \.offset) { index, option in + Button { + state.onChoose(index) + } label: { + HStack { + Text(LocalizedStringKey(title(option))) + .font(.system(size: SettingsSelectMetrics.fontSize)) + .foregroundStyle( + isAvailable(option) + ? (state.highlightedIndex == index ? LitheTheme.settingsSelectionText : LitheTheme.primaryText) + : LitheTheme.tertiaryText + ) + .lineLimit(expandsToFitOptions ? nil : 1) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) + } + .padding(.horizontal, SettingsSelectMetrics.itemHorizontalPadding) + .padding(.vertical, expandsToFitOptions ? 4 : 0) + .frame(maxWidth: .infinity, minHeight: SettingsSelectMetrics.itemHeight, alignment: .leading) + .litheRowHover( + isActive: state.highlightedIndex == index, + cornerRadius: SettingsSelectMetrics.controlCornerRadius, + activeBackground: LitheTheme.settingsSelection.opacity(isAvailable(option) ? 1 : 0.35) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .accessibilityAddTraits(state.selectedIndex == index ? .isSelected : []) + .onHover { hovering in + if hovering { state.highlightedIndex = index } + } + .id(index) + .help(isAvailable(option) ? "" : "Shell is not available at this path") + } + } + .padding(SettingsSelectMetrics.popupPadding) + .frame(width: width) + } +} + +struct LitheSettingsSelectPopupGeometry { + @MainActor + static func isAnchorClick(_ event: NSEvent?, anchorWindow: NSWindow?, anchorFrame: NSRect?) -> Bool { + guard let event, event.type == .leftMouseDown, + let anchorWindow, let anchorFrame, + event.windowNumber == anchorWindow.windowNumber else { return false } + return anchorFrame.contains(anchorWindow.convertPoint(toScreen: event.locationInWindow)) + } + + static func frame(anchor: NSRect, size: NSSize, visibleFrame: NSRect) -> NSRect { + let bounds = visibleFrame.insetBy(dx: 6, dy: 6) + let width = min(size.width, bounds.width) + let below = max(0, anchor.minY - bounds.minY - 2) + let above = max(0, bounds.maxY - anchor.maxY - 2) + let opensBelow = below >= size.height || below >= above + let height = min(size.height, opensBelow ? below : above) + let preferredY = opensBelow + ? anchor.minY - height - 2 + : anchor.maxY + 2 + return NSRect( + x: min(max(anchor.minX, bounds.minX), bounds.maxX - width), + y: min(max(preferredY, bounds.minY), bounds.maxY - height), + width: width, + height: height + ) + } +} + +@MainActor +private final class LitheSettingsSelectPopupPanel: NSPanel { + var handleKey: ((NSEvent) -> Bool)? + override var canBecomeKey: Bool { true } + + override func sendEvent(_ event: NSEvent) { + if event.type == .keyDown, handleKey?(event) == true { return } + super.sendEvent(event) + } +} + +@MainActor +private final class LitheSettingsSelectPopupPresenter: NSObject, NSWindowDelegate { + static let shared = LitheSettingsSelectPopupPresenter() + + private var panel: LitheSettingsSelectPopupPanel? + private weak var anchorWindow: NSWindow? + private var anchorFrame: NSRect? + private var ownerID: UUID? + private var onDismiss: (() -> Void)? + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + + func show( + ownerID: UUID, + content: AnyView, + state: LitheSettingsSelectPopupState, + anchorWindow: NSWindow, + anchorFrame: NSRect, + size: NSSize, + visibleFrame: NSRect, + appearance: NSAppearance, + onDismiss: @escaping () -> Void + ) { + dismiss() + let frame = LitheSettingsSelectPopupGeometry.frame( + anchor: anchorFrame, size: size, visibleFrame: visibleFrame + ) + state.popupHeight = frame.height + let panel = LitheSettingsSelectPopupPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.handleKey = { [weak self, weak state] event in + state?.handleKey(event) { self?.dismiss(ownerID: ownerID) } ?? false + } + panel.contentViewController = NSHostingController(rootView: content) + panel.appearance = appearance + panel.animationBehavior = .none + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.level = .popUpMenu + panel.isFloatingPanel = true + panel.hidesOnDeactivate = true + panel.collectionBehavior = [.transient, .fullScreenAuxiliary] + panel.delegate = self + self.panel = panel + self.anchorWindow = anchorWindow + self.anchorFrame = anchorFrame + self.ownerID = ownerID + self.onDismiss = onDismiss + installEventMonitors() + panel.orderFrontRegardless() + panel.makeKey() + } + + func dismiss(ownerID: UUID? = nil) { + guard ownerID == nil || ownerID == self.ownerID else { return } + removeEventMonitors() + let callback = onDismiss + onDismiss = nil + self.ownerID = nil + anchorWindow = nil + anchorFrame = nil + let closingPanel = panel + panel = nil + closingPanel?.delegate = nil + closingPanel?.orderOut(nil) + closingPanel?.close() + callback?() + } + + func windowDidResignKey(_ notification: Notification) { + if !LitheSettingsSelectPopupGeometry.isAnchorClick( + NSApp.currentEvent, anchorWindow: anchorWindow, anchorFrame: anchorFrame + ) { dismiss() } + } + + private func installEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + guard let self else { return event } + if event.window !== self.panel && !LitheSettingsSelectPopupGeometry.isAnchorClick( + event, anchorWindow: self.anchorWindow, anchorFrame: self.anchorFrame + ) { self.dismiss() } + return event + } + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + self?.dismiss() + } + } + + private func removeEventMonitors() { + if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) } + if let globalEventMonitor { NSEvent.removeMonitor(globalEventMonitor) } + localEventMonitor = nil + globalEventMonitor = nil } } @@ -225,29 +517,23 @@ struct LitheSettingsSegmentedControl: View { } label: { Text(LocalizedStringKey(title(option))) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(selection == option ? Color.white : LitheTheme.secondaryText) - .frame(maxWidth: .infinity, minHeight: 26) + .foregroundStyle(selection == option ? LitheTheme.settingsSelectionText : LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 24) .contentShape(Rectangle()) .litheRowHover( isActive: selection == option, - cornerRadius: LitheTheme.Metrics.cornerRadius, + cornerRadius: SettingsSelectMetrics.controlCornerRadius, activeBackground: LitheTheme.settingsSelection ) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() + .accessibilityValue(selection == option ? Text("Selected") : Text("Not selected")) } } .padding(2) - .frame(width: width, height: 30) - .background( - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.settingsControlBackground) - ) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .frame(width: width, height: SettingsSelectMetrics.controlHeight) + .litheSettingsControlChrome() } } @@ -274,16 +560,17 @@ struct LitheSettingsCheckbox: View { } label: { HStack(spacing: 8) { ZStack { - RoundedRectangle(cornerRadius: 4) - .fill(isOn ? LitheTheme.accent : LitheTheme.inputBackground) - RoundedRectangle(cornerRadius: 4) - .stroke(isOn ? LitheTheme.accent : LitheTheme.inputBorder, lineWidth: 1) Image(systemName: "checkmark") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color.white) .opacity(isOn ? 1 : 0) } .frame(width: 16, height: 16) + .litheSettingsControlChrome( + background: isOn ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBackground, + border: isOn ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder, + cornerRadius: 3 + ) if let title { Text(title) @@ -293,9 +580,11 @@ struct LitheSettingsCheckbox: View { } .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() - .accessibilityLabel(Text(accessibilityLabel)) + .accessibilityRepresentation { + Toggle(accessibilityLabel, isOn: $isOn) + } } } @@ -326,15 +615,15 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V var body: some View { HStack(spacing: 0) { Text(title(value)) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) .foregroundStyle(LitheTheme.primaryText) .monospacedDigit() .frame(maxWidth: .infinity, alignment: .trailing) .padding(.horizontal, 8) Rectangle() - .fill(LitheTheme.inputBorder) - .frame(width: 1, height: 20) + .fill(LitheTheme.settingsControlBorder) + .frame(width: 1, height: 18) stepButton(systemImage: "minus", isDisabled: value <= range.lowerBound) { value = max(range.lowerBound, value.advanced(by: -step)) @@ -344,16 +633,9 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V value = min(range.upperBound, value.advanced(by: step)) } } - .frame(width: width, height: 30) - .background( - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) - ) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .frame(width: width, height: SettingsSelectMetrics.controlHeight) + .litheSettingsControlChrome() + .clipShape(RoundedRectangle(cornerRadius: SettingsSelectMetrics.controlCornerRadius)) .accessibilityElement(children: .contain) .accessibilityLabel(Text(accessibilityLabel)) } @@ -367,11 +649,11 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V Image(systemName: systemImage) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(isDisabled ? LitheTheme.tertiaryText : LitheTheme.secondaryText) - .frame(width: 26, height: 28) + .frame(width: 26, height: 26) .contentShape(Rectangle()) .litheRowHover(cornerRadius: 0) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .disabled(isDisabled) .lithePointer() } @@ -379,27 +661,55 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V private struct LitheSettingsTextFieldModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled + @FocusState private var isFocused: Bool func body(content: Content) -> some View { content .textFieldStyle(.plain) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) + .focused($isFocused) .padding(.horizontal, 9) - .frame(height: 30) - .background( - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) + .frame(height: SettingsSelectMetrics.controlHeight) + .litheSettingsControlChrome( + background: LitheTheme.settingsTextFieldBackground, + border: isFocused ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder ) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } .opacity(isEnabled ? 1 : 0.55) } } +private struct LitheSettingsTextEditorModifier: ViewModifier { + @FocusState private var isFocused: Bool + let height: CGFloat + + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .font(.system(size: 12, design: .monospaced)) + .focused($isFocused) + .frame(height: height) + .padding(5) + .litheSettingsControlChrome( + background: LitheTheme.settingsTextFieldBackground, + border: isFocused ? LitheTheme.settingsControlAccent : LitheTheme.settingsControlBorder + ) + } +} + extension View { + func litheSettingsControlChrome( + background: Color = LitheTheme.settingsControlBackground, + border: Color = LitheTheme.settingsControlBorder, + cornerRadius: CGFloat = SettingsSelectMetrics.controlCornerRadius + ) -> some View { + modifier(LitheSettingsControlChrome(background: background, border: border, cornerRadius: cornerRadius)) + } + func litheSettingsTextField() -> some View { modifier(LitheSettingsTextFieldModifier()) } + + func litheSettingsTextEditor(height: CGFloat) -> some View { + modifier(LitheSettingsTextEditorModifier(height: height)) + } } diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift index 36c75d8b5..8eb67851b 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -24,6 +24,7 @@ struct LitheSplitPaneView: View { let clipsSizedPane: Bool let trackBackground: Color let showsIdleDivider: Bool + let highlightsOnHover: Bool /// Called with the final size when a drag ends. Hosts that persist the size /// write it here; the container then defers to `defaultSize` again so the /// persisted value is the single source of truth. @@ -45,6 +46,7 @@ struct LitheSplitPaneView: View { clipsSizedPane: Bool = false, trackBackground: Color = .clear, showsIdleDivider: Bool = true, + highlightsOnHover: Bool = true, onCommit: ((CGFloat) -> Void)? = nil, @ViewBuilder sized: () -> Sized, @ViewBuilder flexible: () -> Flexible @@ -58,6 +60,7 @@ struct LitheSplitPaneView: View { self.clipsSizedPane = clipsSizedPane self.trackBackground = trackBackground self.showsIdleDivider = showsIdleDivider + self.highlightsOnHover = highlightsOnHover self.onCommit = onCommit self.sized = sized() self.flexible = flexible() @@ -131,6 +134,7 @@ struct LitheSplitPaneView: View { axis: axis, trackBackground: trackBackground, showsIdleDivider: showsIdleDivider, + highlightsOnHover: highlightsOnHover, onDragStarted: { dragStart = size }, onDragChanged: { translation in draggedSize = resolved(from: translation) diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift index c3932833e..1aff54c29 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift @@ -179,7 +179,7 @@ private struct DatabaseDashboardView: View { .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 36) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(profiles.first(where: { $0.id == entry.profileID })?.kind.isSQLDatabase != true) .litheRowHover(cornerRadius: 0) } @@ -222,7 +222,7 @@ private struct DatabaseDashboardView: View { .stroke(LitheTheme.panelBorder, lineWidth: 1) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } .frame(maxWidth: .infinity) @@ -251,7 +251,7 @@ private struct DatabaseDashboardView: View { .frame(height: 48) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheRowHover(cornerRadius: 0) } @@ -278,7 +278,7 @@ private struct DatabaseDashboardView: View { .frame(height: 48) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) if profile.kind.isSQLDatabase { Button { onNewQuery(profile) } label: { @@ -288,7 +288,7 @@ private struct DatabaseDashboardView: View { .frame(width: 40, height: 40) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("New Query") .accessibilityLabel("New Query") @@ -360,7 +360,7 @@ private struct DatabaseDashboardView: View { .frame(height: 48) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(!isEnabled) .opacity(isEnabled ? 1 : 0.42) .background(LitheTheme.raised) @@ -448,7 +448,7 @@ private struct DatabaseHistoryView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } .listStyle(.inset) } @@ -540,7 +540,7 @@ struct DatabaseSQLWorkspaceView: View { .padding(.leading, 10) .frame(height: 31) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) Button { model.databaseFeature.closeSQLTab(tab.id) } label: { Image(systemName: "xmark") diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift index 713927a75..1c346ec5c 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift @@ -133,7 +133,7 @@ struct DatabaseSidebarView: View { .font(.system(size: 11)) .foregroundStyle(LitheTheme.tertiaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Clear search") } } @@ -420,7 +420,7 @@ struct DatabaseSidebarView: View { .frame(height: 31) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .lithePointer() @@ -486,7 +486,7 @@ struct DatabaseSidebarView: View { .foregroundStyle(LitheTheme.tertiaryText) .frame(width: 18, height: 30) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() Button { @@ -522,7 +522,7 @@ struct DatabaseSidebarView: View { .frame(height: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .lithePointer() @@ -572,7 +572,7 @@ struct DatabaseSidebarView: View { .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .lithePointer() @@ -659,7 +659,7 @@ struct DatabaseSidebarView: View { .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .lithePointer() @@ -751,7 +751,7 @@ struct DatabaseSidebarView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .lithePointer() @@ -1464,7 +1464,7 @@ private struct DatabaseDBXImportSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(candidate.isDuplicate) .background(isSelected ? LitheTheme.accent.opacity(0.08) : LitheTheme.inputBackground.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 6)) diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift b/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift index 0ff7fccd5..3674a9af3 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift @@ -162,7 +162,7 @@ struct RedisWorkspaceView: View { } .padding(.horizontal, 8).frame(height: 40).frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain).lithePointer() + .buttonStyle(.litheNoPress).lithePointer() .litheRowHover(isActive: feature.redisSelectedKey?.key == key.key, activeBackground: LitheTheme.subtleSelection) } } @@ -177,7 +177,7 @@ struct RedisWorkspaceView: View { .font(.system(size: 10.5, weight: .medium)) .frame(maxWidth: .infinity).padding(.vertical, 9) } - .buttonStyle(.plain).lithePointer() + .buttonStyle(.litheNoPress).lithePointer() .foregroundStyle(LitheTheme.accent) .disabled(feature.isLoading) } @@ -468,7 +468,7 @@ struct NacosWorkspaceView: View { } .padding(.horizontal, 9).frame(height: 42).frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain).lithePointer() + .buttonStyle(.litheNoPress).lithePointer() .litheRowHover(isActive: feature.nacosSelectedConfig?.dataId == config.dataId && feature.nacosSelectedConfig?.group == config.group, activeBackground: LitheTheme.subtleSelection) } }.padding(.vertical, 6) @@ -532,7 +532,7 @@ struct NacosWorkspaceView: View { } .padding(.horizontal, 9).frame(height: 42).frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain).lithePointer() + .buttonStyle(.litheNoPress).lithePointer() .litheRowHover(isActive: false, activeBackground: LitheTheme.subtleSelection) } }.padding(.vertical, 6) diff --git a/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift b/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift index de45a941b..003d5d765 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseTableView.swift @@ -179,7 +179,7 @@ struct DatabaseTableView: View { Button { model.databaseFeature.workspaceSection = .structure } label: { toolbarActionLabel("Table Properties", systemImage: "tablecells") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(model.databaseFeature.selectedProfile?.kind == .mongodb) toolbarDivider @@ -242,7 +242,7 @@ struct DatabaseTableView: View { Spacer() if !appliedFilters.isEmpty || !appliedSort.isEmpty { Button("Clear query") { clearQuery() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 10, weight: .medium)) .foregroundStyle(LitheTheme.secondaryText) } @@ -257,7 +257,7 @@ struct DatabaseTableView: View { HStack(spacing: 7) { Group { Button { refreshTable() } label: { Label("Refresh table data", systemImage: "arrow.clockwise") } - .buttonStyle(.plain).font(.system(size: 10.5, weight: .medium)) + .buttonStyle(.litheNoPress).font(.system(size: 10.5, weight: .medium)) Menu { ForEach(model.databaseFeature.columns, id: \.self) { column in Button(column) { jumpTargetColumn = column } @@ -280,13 +280,13 @@ struct DatabaseTableView: View { .accessibilityLabel("Row details") .disabled(selectedRows.count != 1) Button { insertedRows.append([:]) } label: { Label("Add Row", systemImage: "plus") } - .buttonStyle(.plain).font(.system(size: 10.5, weight: .medium)) + .buttonStyle(.litheNoPress).font(.system(size: 10.5, weight: .medium)) .disabled(model.databaseFeature.selectedProfile?.readOnly == true) Button { apply() } label: { Label("Apply", systemImage: "checkmark") } - .buttonStyle(.plain).font(.system(size: 10.5, weight: .medium)) + .buttonStyle(.litheNoPress).font(.system(size: 10.5, weight: .medium)) .disabled(!hasChanges || model.databaseFeature.isLoading || model.databaseFeature.selectedProfile?.readOnly == true) Button { discard() } label: { Label("Discard", systemImage: "arrow.uturn.backward") } - .buttonStyle(.plain).font(.system(size: 10.5, weight: .medium)) + .buttonStyle(.litheNoPress).font(.system(size: 10.5, weight: .medium)) .disabled(!hasChanges) } if !selectedRows.isEmpty { @@ -300,13 +300,13 @@ struct DatabaseTableView: View { Button { showsBatchUpdateSheet = true } label: { Label("Batch Edit", systemImage: "square.and.pencil") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 10.5, weight: .medium)) .disabled(model.databaseFeature.selectedProfile?.readOnly == true) Button { showsBatchDeleteConfirmation = true } label: { Label("Delete Selected", systemImage: "trash") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(LitheTheme.error) .disabled(model.databaseFeature.selectedProfile?.readOnly == true) @@ -337,7 +337,7 @@ struct DatabaseTableView: View { Button { filterConditions.append(.init(column: model.databaseFeature.columns.first ?? "")) } label: { Label("Add Condition", systemImage: "plus") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } ForEach($filterConditions) { $condition in HStack(spacing: 8) { @@ -345,7 +345,7 @@ struct DatabaseTableView: View { Image(systemName: condition.isEnabled ? "checkmark.circle.fill" : "circle") .foregroundStyle(condition.isEnabled ? LitheTheme.accent : LitheTheme.tertiaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(condition.isEnabled ? "Disable condition" : "Enable condition") Picker("Column", selection: $condition.column) { ForEach(model.databaseFeature.columns, id: \.self) { Text($0).tag($0) } @@ -362,7 +362,7 @@ struct DatabaseTableView: View { Button(role: .destructive) { filterConditions.removeAll { $0.id == condition.id } } label: { Image(systemName: "trash") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } } HStack { @@ -385,7 +385,7 @@ struct DatabaseTableView: View { Button { sortConditions.append(.init(column: model.databaseFeature.columns.first ?? "")) } label: { Label("Add Sort", systemImage: "plus") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } if sortConditions.isEmpty { Text("No sorting") @@ -406,11 +406,11 @@ struct DatabaseTableView: View { } .frame(width: 120) Button { moveSort(from: index, by: -1) } label: { Image(systemName: "arrow.up") } - .buttonStyle(.plain).help("Move up").disabled(index == 0) + .buttonStyle(.litheNoPress).help("Move up").disabled(index == 0) Button { moveSort(from: index, by: 1) } label: { Image(systemName: "arrow.down") } - .buttonStyle(.plain).help("Move down").disabled(index == sortConditions.count - 1) + .buttonStyle(.litheNoPress).help("Move down").disabled(index == sortConditions.count - 1) Button(role: .destructive) { sortConditions.remove(at: index) } label: { Image(systemName: "trash") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } } HStack { @@ -464,7 +464,7 @@ struct DatabaseTableView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } private func toolbarActionLabel( @@ -536,7 +536,7 @@ struct DatabaseTableView: View { .frame(width: selectionColumnWidth, height: 32) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(allRowsSelected ? "Deselect all rows on this page" : "Select all rows on this page") ForEach(model.databaseFeature.columns, id: \.self) { column in HStack(spacing: 4) { @@ -596,7 +596,7 @@ struct DatabaseTableView: View { .frame(width: selectionColumnWidth, height: 29) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .background(LitheTheme.toolHeader) .disabled(deletedRows.contains(index)) .litheContextMenu { rowContextMenu(index: index) } @@ -981,7 +981,7 @@ struct DatabaseOpenTableTabsView: View { Button(table) { Task { await model.databaseFeature.openTable(table) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 11, weight: .medium)) Button { let wasSelected = model.databaseFeature.selectedTable == table @@ -992,7 +992,7 @@ struct DatabaseOpenTableTabsView: View { Image(systemName: "xmark") .font(.system(size: 8.5, weight: .semibold)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Close table") } .padding(.horizontal, 9) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 71a7c5e83..a92d69e74 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -122,7 +122,7 @@ struct GenericDebugView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } Spacer(minLength: 0) @@ -192,7 +192,7 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.secondaryText) .frame(width: 16, height: 16) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Stop debug session") } @@ -487,7 +487,7 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.secondaryText.opacity(0.82)) .lineLimit(1) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Reveal stopped location in editor") .accessibilityLabel("Reveal stopped location in editor") } @@ -581,7 +581,7 @@ struct GenericDebugView: View { feature.smartStepInto(target) isSmartStepPickerPresented = false } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 11, design: .monospaced)) .padding(.horizontal, 8) .padding(.vertical, 4) @@ -624,7 +624,7 @@ struct GenericDebugView: View { .frame(minHeight: 27) .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } ForEach(feature.visibleStackFrameRows) { row in if let frame = row.frame { @@ -696,7 +696,7 @@ struct GenericDebugView: View { .frame(minHeight: 27) .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Show JDK, proxy, and framework frames") } } @@ -1124,13 +1124,13 @@ struct GenericDebugView: View { preservesOriginalColors: true ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(feature.state != .paused || feature.watches.isEmpty) .help("Refresh watches") Button { watchEditor = WatchEditorContext(watch: nil) } label: { Image(systemName: "plus") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Add watch") } .padding(.horizontal, 10) @@ -1436,7 +1436,7 @@ struct GenericDebugView: View { .frame(minHeight: 27) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(isLoading) .accessibilityLabel(isLoading ? "Loading debugger variables" : "Load more debugger variables") } @@ -1469,7 +1469,7 @@ struct GenericDebugView: View { .background(selected ? LitheTheme.selection : Color.clear) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } } @@ -1699,7 +1699,7 @@ struct DebugBreakpointManagerView: View { } label: { Image(systemName: "plus") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Add method breakpoint") .accessibilityLabel("Add method breakpoint") } @@ -1731,7 +1731,7 @@ struct DebugBreakpointManagerView: View { ) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") .accessibilityLabel(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint") Button { @@ -1755,7 +1755,7 @@ struct DebugBreakpointManagerView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel("Open \(breakpoint.title)") Menu { Button("Edit…") { editingBreakpoint = breakpoint } @@ -1804,7 +1804,7 @@ struct DebugBreakpointManagerView: View { .font(.system(size: 10)) .foregroundStyle(breakpoint.enabled ? LitheTheme.error : LitheTheme.secondaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel( breakpoint.enabled ? "Disable \(breakpoint.label) exception breakpoint" @@ -1824,7 +1824,7 @@ struct DebugBreakpointManagerView: View { Button { editingExceptionBreakpoint = breakpoint } label: { Image(systemName: "ellipsis") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Edit exception breakpoint") .accessibilityLabel("Edit \(breakpoint.label) exception breakpoint") } @@ -1850,7 +1850,7 @@ struct DebugBreakpointManagerView: View { : LitheTheme.secondaryText ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel( breakpoint.enabled ? "Disable \(breakpoint.name) method breakpoint" @@ -1872,7 +1872,7 @@ struct DebugBreakpointManagerView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel("Edit \(breakpoint.name) method breakpoint") Menu { Button("Edit…") { @@ -1909,7 +1909,7 @@ struct DebugBreakpointManagerView: View { preservesOriginalColors: true ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel( breakpoint.enabled ? "Disable \(breakpoint.label) field breakpoint" @@ -1927,7 +1927,7 @@ struct DebugBreakpointManagerView: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel("Edit \(breakpoint.label) field breakpoint") Menu { Button("Edit…") { editingDataBreakpoint = breakpoint } diff --git a/macos/Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift b/macos/Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift index 7af7c80c0..1e53e63b6 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift @@ -24,7 +24,7 @@ struct DiffCollapsedBandView: View { .background(LitheTheme.window) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Expand these lines") } diff --git a/macos/Sources/Lithe/Views/Diff/DiffReviewView.swift b/macos/Sources/Lithe/Views/Diff/DiffReviewView.swift index 17ab7058b..167d52bdf 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffReviewView.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffReviewView.swift @@ -159,7 +159,7 @@ struct DiffReviewView: View { systemImage: highlightsWords ? "checkmark.square.fill" : "square" ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(highlightsWords ? "Disable word-level highlights" : "Enable word-level highlights") @@ -172,7 +172,7 @@ struct DiffReviewView: View { systemImage: collapsesUnchangedRegions ? "checkmark.square.fill" : "square" ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help( collapsesUnchangedRegions diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 868eb453a..45493c10a 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -471,7 +471,7 @@ struct EditorAreaView: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 10) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .opacity(isActive || hoveredTabItem == tabItem ? 1 : 0) @@ -563,7 +563,7 @@ struct EditorAreaView: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 10) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .opacity(isActive || hoveredTabItem == tabItem ? 1 : 0) @@ -676,7 +676,7 @@ struct EditorAreaView: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 10) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .opacity(isActive || hoveredTabItem == .document(document.id) ? 1 : 0) @@ -1073,7 +1073,7 @@ struct EditorAreaView: View { .opacity(isSelected || isHovered ? 1 : 0.72) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(mode.title) .accessibilityLabel(mode.title) @@ -1133,7 +1133,7 @@ struct EditorAreaView: View { HStack { Spacer() Text(splitDocument.displayName).font(.system(size: 11)) - Button("Close split") { self.splitDocumentID = nil }.buttonStyle(.borderless) + Button("Close split") { self.splitDocumentID = nil }.buttonStyle(.litheNoPress) } .padding(.horizontal, 10).frame(height: 30) MonacoWorkbenchEditor(document: model.activeDocument ?? splitDocument, secondaryDocument: splitDocument) diff --git a/macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift b/macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift index 61ca2aa6d..e74842383 100644 --- a/macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift +++ b/macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift @@ -4,6 +4,8 @@ import SwiftUI struct HTMLPreviewView: View { @EnvironmentObject private var model: AppModel @ObservedObject var document: EditorDocument + @State private var browserError: String? + @State private var isOpeningBrowser = false @StateObject private var content: HTMLPreviewContent init(document: EditorDocument) { @@ -21,13 +23,22 @@ struct HTMLPreviewView: View { ) Button { - model.platformUI.open(document.url) + isOpeningBrowser = true + Task { @MainActor in + defer { isOpeningBrowser = false } + do { + try await model.platformUI.openHTMLInBrowser(document.url) + } catch { + browserError = error.localizedDescription + } + } } label: { Image(systemName: "arrow.up.right.square") .font(.system(size: 12, weight: .medium)) .frame(width: 24, height: 24) } - .buttonStyle(.plain) + .disabled(isOpeningBrowser) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.secondaryText) .background(LitheTheme.toolHeader.opacity(0.92)) .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -40,6 +51,14 @@ struct HTMLPreviewView: View { .padding(.top, 8) .padding(.trailing, 10) } + .alert("Could not open HTML in browser", isPresented: Binding( + get: { browserError != nil }, + set: { if !$0 { browserError = nil } } + )) { + Button("OK") { browserError = nil } + } message: { + Text(browserError ?? "") + } .background(LitheTheme.editor) .onAppear { content.observe(document) } .onChange(of: document.id) { _ in content.observe(document) } diff --git a/macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift b/macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift index cf81f5801..32f3307ce 100644 --- a/macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift +++ b/macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift @@ -55,7 +55,7 @@ struct MarkdownPreviewView: View { Button("Retry") { scheduleRender(immediate: true) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .font(.system(size: 12)) } .padding(16) diff --git a/macos/Sources/Lithe/Views/Git/BranchComparisonView.swift b/macos/Sources/Lithe/Views/Git/BranchComparisonView.swift index 3fd7c2fc4..e7f49d053 100644 --- a/macos/Sources/Lithe/Views/Git/BranchComparisonView.swift +++ b/macos/Sources/Lithe/Views/Git/BranchComparisonView.swift @@ -169,7 +169,7 @@ struct BranchComparisonView: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift index b8155927c..3bcf4b9ab 100644 --- a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift @@ -65,7 +65,7 @@ struct BranchSwitcherPopover: View { .font(.system(size: 12)) .foregroundStyle(LitheTheme.secondaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Clear search") } @@ -243,7 +243,7 @@ struct BranchSwitcherPopover: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -329,7 +329,7 @@ struct BranchSwitcherPopover: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 5, hoverBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -363,7 +363,7 @@ struct BranchSwitcherPopover: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 5, hoverBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index c710492da..dc3ea99d5 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -162,7 +162,7 @@ struct ChangesSidebarView: View { hoverBackground: LitheTheme.hoverBackground ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } Spacer() GitPatchToolbar(feature: feature) @@ -345,7 +345,7 @@ struct ChangesSidebarView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheContextMenu { [ @@ -403,7 +403,7 @@ struct ChangesSidebarView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheContextMenu { [ @@ -462,7 +462,7 @@ struct ChangesSidebarView: View { } label: { Label("Clear conflict filter", systemImage: "line.3.horizontal.decrease.circle") } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.warning) .lithePointer() @@ -511,7 +511,7 @@ struct ChangesSidebarView: View { .foregroundStyle(LitheTheme.warning) Text("No files match the conflict filter") Button("Show all changes") { feature.clearGitConflictFilter() } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .lithePointer() } .font(LitheTheme.uiFont) @@ -587,7 +587,7 @@ struct ChangesSidebarView: View { .frame(width: 10, height: 26) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(LocalizedStringKey(isExpanded ? "Collapse repository" : "Expand repository")) Button { @@ -603,7 +603,7 @@ struct ChangesSidebarView: View { .frame(width: 18, height: 26) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(feature.isCommitting || !repository.changes.contains(where: \.canToggleStaging)) .help(LocalizedStringKey( allChangesStaged(repository.changes) @@ -633,7 +633,7 @@ struct ChangesSidebarView: View { .frame(maxWidth: .infinity, minHeight: 26, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(repository.root.path) } .padding(.horizontal, 7) @@ -681,7 +681,7 @@ struct ChangesSidebarView: View { .frame(width: 10, height: 24) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(LocalizedStringKey(expanded.wrappedValue ? "Collapse section" : "Expand section")) Button { @@ -693,7 +693,7 @@ struct ChangesSidebarView: View { .frame(width: 18, height: 24) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(feature.isCommitting || !changes.contains(where: \.canToggleStaging)) .help(LocalizedStringKey(allChangesStaged(changes) ? "Unstage all files" : "Stage all files")) @@ -712,7 +712,7 @@ struct ChangesSidebarView: View { .frame(maxWidth: .infinity, minHeight: 24) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } .padding(.horizontal, 7) .padding(.leading, leadingInset) @@ -764,7 +764,7 @@ struct ChangesSidebarView: View { .frame(width: 28, height: changeRowHeight) .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .disabled(feature.isCommitting || !change.canToggleStaging) .help(LocalizedStringKey(change.canToggleStaging ? (isEffectivelyStaged(change) ? "Unstage file" : "Stage file") @@ -807,7 +807,7 @@ struct ChangesSidebarView: View { .frame(height: changeRowHeight) .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) } .padding(.leading, 30 + leadingInset) .padding(.trailing, 6) diff --git a/macos/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift b/macos/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift index 437491309..19ff74cba 100644 --- a/macos/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift +++ b/macos/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift @@ -152,7 +152,7 @@ struct GitCommitDiffReviewView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() Text("Read-only") diff --git a/macos/Sources/Lithe/Views/Git/GitConsoleEntryView.swift b/macos/Sources/Lithe/Views/Git/GitConsoleEntryView.swift index d4c7d7ea0..5fd9989ab 100644 --- a/macos/Sources/Lithe/Views/Git/GitConsoleEntryView.swift +++ b/macos/Sources/Lithe/Views/Git/GitConsoleEntryView.swift @@ -39,7 +39,7 @@ struct GitConsoleEntryView: View { if fragment.matches > 0 { Text(" · \(fragment.matches) matches") } } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.secondaryText) .accessibilityValue(isExpanded(fragment) ? Text("Expanded") : Text("Collapsed")) } diff --git a/macos/Sources/Lithe/Views/Git/GitConsoleView.swift b/macos/Sources/Lithe/Views/Git/GitConsoleView.swift index 22d9d9bfa..17b1a4aa1 100644 --- a/macos/Sources/Lithe/Views/Git/GitConsoleView.swift +++ b/macos/Sources/Lithe/Views/Git/GitConsoleView.swift @@ -77,7 +77,7 @@ struct GitConsoleView: View { .disabled(records.isEmpty).help("Copy complete console") Spacer(minLength: 0) } - .buttonStyle(.plain).litheIconButton() + .buttonStyle(.litheNoPress).litheIconButton() .foregroundStyle(LitheTheme.secondaryText) .padding(.top, 6).frame(width: 28) } diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 1794ae8c8..f2f2b3dd4 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -98,7 +98,7 @@ struct GitGraphView: View { } label: { Color.clear.frame(width: rect.width, height: rect.height).contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(title) .accessibilityLabel(title) .accessibilityIdentifier("git-graph-arrow-\(row.commit.hash)-\(element.id)") @@ -744,7 +744,7 @@ private struct GitGraphRowView: View, Equatable { .background(backgroundColor) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityAddTraits(isSelected ? .isSelected : []) .onHover { isHovered = $0 } .litheContextMenu { actions.contextMenuItems(for: row.commit) } diff --git a/macos/Sources/Lithe/Views/Git/GitHistoryRewriteView.swift b/macos/Sources/Lithe/Views/Git/GitHistoryRewriteView.swift index a4b460ff1..d9cdc5c9b 100644 --- a/macos/Sources/Lithe/Views/Git/GitHistoryRewriteView.swift +++ b/macos/Sources/Lithe/Views/Git/GitHistoryRewriteView.swift @@ -107,7 +107,7 @@ struct GitHistoryRewriteOutcomeView: View { } Spacer(minLength: 0) Button { editor.outcome = nil } label: { Image(systemName: "xmark") } - .buttonStyle(.plain).help("Dismiss history operation result").lithePointer() + .buttonStyle(.litheNoPress).help("Dismiss history operation result").lithePointer() } .padding(10) .background(LitheTheme.toolHeader) diff --git a/macos/Sources/Lithe/Views/Git/GitInteractiveRebaseStatusView.swift b/macos/Sources/Lithe/Views/Git/GitInteractiveRebaseStatusView.swift index 755858bfb..a8ca5e75f 100644 --- a/macos/Sources/Lithe/Views/Git/GitInteractiveRebaseStatusView.swift +++ b/macos/Sources/Lithe/Views/Git/GitInteractiveRebaseStatusView.swift @@ -39,7 +39,7 @@ private struct GitInteractiveRebaseSessionBanner: View { Spacer(minLength: 0) if !session.isActive { Button { editor.dismissSession() } label: { Image(systemName: "xmark") } - .buttonStyle(.plain).help("Dismiss rebase result").accessibilityLabel("Dismiss rebase result").lithePointer() + .buttonStyle(.litheNoPress).help("Dismiss rebase result").accessibilityLabel("Dismiss rebase result").lithePointer() } } Text("\(session.branch) · \(session.completedSteps) of \(session.steps.count) steps processed") diff --git a/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift b/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift index 043d87a34..a1fa67687 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogFilterPopover.swift @@ -647,7 +647,7 @@ struct GitLogFilterRowView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -867,7 +867,7 @@ struct GitLogBranchFilterPopover: View { .clipShape(RoundedRectangle(cornerRadius: 5)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 910aed0cc..df9ddc1ba 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -500,7 +500,7 @@ struct GitLogView: View { .frame(height: 27) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if showsCloseButton { @@ -513,7 +513,7 @@ struct GitLogView: View { .frame(width: 20, height: 27) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Close Git console") } @@ -795,7 +795,7 @@ struct GitLogView: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 4) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if !isCollapsed { @@ -868,7 +868,7 @@ struct GitLogView: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 4) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if expanded.wrappedValue { @@ -1037,7 +1037,7 @@ struct GitLogView: View { activeBackground: LitheTheme.subtleSelection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheContextMenu { var items: [LitheContextMenuItem] = [] @@ -1253,7 +1253,7 @@ struct GitLogView: View { .frame(height: 32) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } @@ -1611,7 +1611,7 @@ struct GitLogView: View { selection: feature.selectedGitReference?.shortName ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .overlay { GitLogInstantPopover(isPresented: $showsGitLogBranchFilterPopover) { @@ -1647,7 +1647,7 @@ struct GitLogView: View { } label: { gitLogFilterLabel(title: "User", selection: selectedGitLogAuthor?.displayName, localizeSelection: selectedGitLogAuthor == .currentUser) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .overlay { GitLogInstantPopover(isPresented: $showsGitLogAuthorFilterPopover) { @@ -1714,7 +1714,7 @@ struct GitLogView: View { selection: gitLogPathFilter.isEmpty ? nil : gitLogPathFilter ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .overlay { GitLogInstantPopover(isPresented: $showsGitLogPathPopover) { @@ -1800,7 +1800,7 @@ struct GitLogView: View { .frame(width: 14, height: 22) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(LocalizedStringKey(help)) } @@ -2497,7 +2497,7 @@ private struct GitConflictPathRow: View { .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Show Diff") @@ -2508,7 +2508,7 @@ private struct GitConflictPathRow: View { Image(systemName: "arrow.uturn.backward") .font(.system(size: 10, weight: .semibold)) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.warning) .lithePointer() .help("Discard this file and retry") @@ -2696,7 +2696,7 @@ struct GitPullStrategyDialog: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } @@ -3018,7 +3018,7 @@ private struct GitReferenceRowView: View, Equatable { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 4) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -3059,7 +3059,7 @@ private struct GitReferenceRowView: View, Equatable { activeBackground: LitheTheme.subtleSelection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheContextMenu { referenceMenuItems(for: reference) diff --git a/macos/Sources/Lithe/Views/Git/GitPatchView.swift b/macos/Sources/Lithe/Views/Git/GitPatchView.swift index d6dd631dc..c34213f20 100644 --- a/macos/Sources/Lithe/Views/Git/GitPatchView.swift +++ b/macos/Sources/Lithe/Views/Git/GitPatchView.swift @@ -24,14 +24,14 @@ struct GitPatchToolbar: View { guard let root = feature.gitRepositoryRoot else { return } feature.patchExchange.beginExport(at: root) } label: { Image(systemName: "doc.badge.arrow.up") } - .buttonStyle(.plain).help("Create Patch…").lithePointer() + .buttonStyle(.litheNoPress).help("Create Patch…").lithePointer() .accessibilityLabel("Create Patch") .disabled(feature.gitRepositoryRoot == nil) Button { guard let root = feature.gitRepositoryRoot else { return } feature.patchExchange.beginImport(at: root, surface: .changes) } label: { Image(systemName: "doc.badge.arrow.down") } - .buttonStyle(.plain).help("Apply Patch…").lithePointer() + .buttonStyle(.litheNoPress).help("Apply Patch…").lithePointer() .accessibilityLabel("Apply Patch") .disabled(feature.gitRepositoryRoot == nil) } diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 8881f0f59..543be53a8 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -371,7 +371,7 @@ struct GitWorktreesView: View { Image(systemName: "arrow.clockwise") .frame(width: 24, height: 24) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .disabled(feature.isPerformingWorktreeOperation) .help("Refresh worktrees") @@ -461,7 +461,7 @@ struct GitWorktreesView: View { Image(systemName: "doc.on.doc") .font(.system(size: 12)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Copy worktree path") Spacer() @@ -496,7 +496,7 @@ struct GitWorktreesView: View { .frame(height: 2) } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -547,7 +547,7 @@ struct GitWorktreesView: View { } label: { Image(systemName: "doc.on.doc") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } @@ -867,7 +867,7 @@ struct GitWorktreesView: View { Label("Copy worktree path", systemImage: "arrow.right") .font(Visual.bodyMedium) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.link) .lithePointer() } diff --git a/macos/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift b/macos/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift index 65f2652b5..e6a710510 100644 --- a/macos/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift +++ b/macos/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift @@ -293,7 +293,7 @@ struct GitHubPullRequestsSidebarView: View { Button { searchQuery = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.tertiaryText) } } @@ -555,7 +555,7 @@ struct GitHubPullRequestDetailView: View { .background(selectedSection == section ? LitheTheme.subtleSelection : .clear) .clipShape(RoundedRectangle(cornerRadius: 5)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } Spacer() @@ -859,7 +859,7 @@ private struct GitHubPullRequestRow: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheRowHover( isActive: isSelected, @@ -1028,7 +1028,7 @@ private struct GitHubFileRow: View { .frame(height: 38) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if isExpanded, let patch = file.patch { @@ -1081,7 +1081,7 @@ private struct GitHubCommentRow: View { Image(systemName: "arrow.up.right") .font(.system(size: 8, weight: .bold)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.tertiaryText) } } @@ -1905,7 +1905,7 @@ private struct GitHubBranchPicker: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .accessibilityLabel(label) .accessibilityValue(selection.isEmpty ? Text("Select branch") : Text(selection)) .popover(isPresented: $isPresented, arrowEdge: .bottom) { @@ -1993,7 +1993,7 @@ private struct GitHubBranchPicker: View { .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) } private var filteredBranches: [GitHubBranch] { diff --git a/macos/Sources/Lithe/Views/History/LocalHistoryView.swift b/macos/Sources/Lithe/Views/History/LocalHistoryView.swift index b7195b40e..e069e91c1 100644 --- a/macos/Sources/Lithe/Views/History/LocalHistoryView.swift +++ b/macos/Sources/Lithe/Views/History/LocalHistoryView.swift @@ -131,7 +131,7 @@ struct LocalHistoryView: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift b/macos/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift index 372d54942..f3e6409f7 100644 --- a/macos/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift +++ b/macos/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift @@ -134,7 +134,7 @@ struct ProjectLocalHistoryView: View { ) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift index af1d92992..d1564b18b 100644 --- a/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift +++ b/macos/Sources/Lithe/Views/Language/JavaProblemsView.swift @@ -146,7 +146,7 @@ struct ProblemsView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(diagnostic.detailText) } diff --git a/macos/Sources/Lithe/Views/Language/JavaReferencesView.swift b/macos/Sources/Lithe/Views/Language/JavaReferencesView.swift index 56a2a5d1f..17c107b1d 100644 --- a/macos/Sources/Lithe/Views/Language/JavaReferencesView.swift +++ b/macos/Sources/Lithe/Views/Language/JavaReferencesView.swift @@ -59,7 +59,7 @@ struct LanguageReferencesView: View { .frame(height: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } @@ -124,7 +124,7 @@ struct LanguageImplementationChooserView: View { .frame(height: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index cd7211a3e..d5550aee1 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -8,7 +8,6 @@ struct LSPControlCenterView: View { var body: some View { VStack(spacing: 0) { - header ScrollView { VStack(alignment: .leading, spacing: 16) { projectSummary @@ -23,7 +22,8 @@ struct LSPControlCenterView: View { degradedCatalogNotice } } - .padding(20) + .padding(.horizontal, 16) + .padding(.bottom, 20) .frame(maxWidth: .infinity, alignment: .leading) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) @@ -32,18 +32,6 @@ struct LSPControlCenterView: View { .background(LitheTheme.settingsSurface) } - private var header: some View { - HStack { - Text(usesChinese ? "语言支持" : "Language Support") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Spacer() - } - .padding(.horizontal, 16) - .frame(height: 42) - .background(LitheTheme.settingsSurface) - } - private var projectSummary: some View { VStack(alignment: .leading, spacing: 5) { Text(model.projectName) diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift index 32d32a9c1..87f7758e5 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift @@ -192,7 +192,7 @@ struct LanguageServerSetupView: View { Button(copy.useAutomatic) { clearOverride() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(LitheTheme.accent) .disabled(tools.customExecutablePath(for: selectedProviderID) == nil) diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index ab54ff04b..b3dc4ce9e 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -228,7 +228,7 @@ struct LanguageTestsView: View { .frame(height: 25) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .litheRowHover(isActive: false, cornerRadius: 5, activeBackground: LitheTheme.subtleSelection) if !isCollapsed { @@ -263,7 +263,7 @@ struct LanguageTestsView: View { activeBackground: LitheTheme.selection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -449,7 +449,7 @@ struct LanguageTestsView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 3) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(failure.fileURL == nil) } } diff --git a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index a35931cb1..e1881c768 100644 --- a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -53,7 +53,7 @@ struct RunConfigurationEditorView: View { options = RunOptions() environmentText = "" } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) Spacer() @@ -266,7 +266,7 @@ struct RunConfigurationEditorView: View { onClose() model.showSettings(category: .project) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Run/MavenBuildOutputView.swift b/macos/Sources/Lithe/Views/Run/MavenBuildOutputView.swift index b1ea88aa6..bfa8de429 100644 --- a/macos/Sources/Lithe/Views/Run/MavenBuildOutputView.swift +++ b/macos/Sources/Lithe/Views/Run/MavenBuildOutputView.swift @@ -110,7 +110,7 @@ struct MavenBuildOutputView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Run/MavenView.swift b/macos/Sources/Lithe/Views/Run/MavenView.swift index a46f09c63..964fa9764 100644 --- a/macos/Sources/Lithe/Views/Run/MavenView.swift +++ b/macos/Sources/Lithe/Views/Run/MavenView.swift @@ -21,6 +21,13 @@ struct MavenView: View { if let error = feature.configurationSaveError { configurationErrorBanner(error) } + if let error = feature.javaConfigurationError { + configurationErrorBanner(String( + format: String(localized: "The Java language server did not take the Maven configuration: %@"), + error + )) + } + MavenResolutionProblemsSection(diagnosticsStore: model.editorDiagnosticsStore) if let error = feature.reloadError { configurationErrorBanner(error) } @@ -185,7 +192,7 @@ struct MavenView: View { Button(feature.isReloading ? String(localized: "Reloading Maven...") : String(localized: "Reload")) { Task { await model.reloadMavenProject(rescan: feature.isProjectReloadRequired) } } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .disabled(feature.isReloading) } .padding(.horizontal, 10) @@ -363,7 +370,7 @@ struct MavenView: View { Button(dependencyLocalization.text("Cancel")) { feature.cancelDependencies(for: modulePath) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) } .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) @@ -380,7 +387,7 @@ struct MavenView: View { Button(dependencyLocalization.text("Retry")) { feature.loadDependencies(for: modulePath) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) } .padding(.horizontal, 4) .padding(.vertical, 3) @@ -394,7 +401,7 @@ struct MavenView: View { Button(dependencyLocalization.text("Retry")) { feature.loadDependencies(for: modulePath) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) } .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.warning) @@ -479,7 +486,7 @@ struct MavenView: View { .frame(minHeight: 28) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .padding(.leading, 16) .help(dependencyLocalization.text("Open module pom.xml")) @@ -550,7 +557,7 @@ struct MavenView: View { Image(systemName: "plus") .frame(width: 18, height: 20) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Add profile") .popover(isPresented: $isAddProfilePresented, arrowEdge: .trailing) { VStack(alignment: .leading, spacing: 10) { @@ -575,7 +582,7 @@ struct MavenView: View { Image(systemName: "arrow.uturn.backward") .frame(width: 18, height: 20) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Restore default profiles") Spacer(minLength: 0) } @@ -615,7 +622,7 @@ struct MavenView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .simultaneousGesture(TapGesture(count: 2).onEnded { guard !model.isMavenOperationBusy else { return } @@ -655,7 +662,7 @@ struct MavenView: View { .frame(width: 14, height: 24) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() Button(action: onLabelAction) { @@ -685,7 +692,7 @@ struct MavenView: View { .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } .litheContextMenu(items: { @@ -885,3 +892,59 @@ private enum MavenToolbarAction: Hashable { case collapse case settings } + +/// Lists the Maven problems JDT LS reports on workspace `pom.xml` files. +/// Observes the diagnostics store directly so publish storms do not rebuild +/// the whole Maven tool window. +private struct MavenResolutionProblemsSection: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var diagnosticsStore: EditorDiagnosticsStore + + var body: some View { + let problems = MavenResolutionProblems.problems( + workspaceURL: model.workspaceURL, + diagnosticsByURL: diagnosticsStore.diagnosticsByURL + ) + if !problems.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Label( + String( + format: String(localized: "Maven could not resolve this project (%lld problems)"), + problems.count + ), + systemImage: "exclamationmark.triangle.fill" + ) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.error) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(problems) { problem in + Button { + model.openDiagnostic(problem) + } label: { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(problem.locationTitle) + .foregroundStyle(LitheTheme.secondaryText) + Text(problem.message) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(2) + Spacer(minLength: 0) + } + .font(.system(size: 11)) + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(problem.message) + } + } + } + .frame(maxHeight: 120) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.error.opacity(0.06)) + } + } +} diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 0a4122c47..ceada26bf 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -279,7 +279,7 @@ struct RunView: View { Button(action: runCheckedConfigurations) { Label("Run selected", systemImage: "play.fill") } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.success) .help("Run checked configurations without restarting running services") .disabled(feature.configurationStatus != .ready || feature.isLoadingProject) @@ -541,7 +541,7 @@ struct RunView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } .font(.system(size: 11.5)) @@ -611,7 +611,7 @@ struct RunView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(isCollapsed ? "Expand" : "Collapse") .accessibilityValue(isCollapsed ? "Collapsed" : "Expanded") @@ -630,7 +630,7 @@ struct RunView: View { .frame(width: 18, height: 24) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .disabled(entries.isEmpty || feature.isLoadingProject) .help("Select configurations for batch actions") @@ -796,7 +796,7 @@ struct RunView: View { if contentTab == tab { Rectangle().fill(LitheTheme.accent).frame(height: 2) } } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .accessibilityAddTraits(contentTab == tab ? .isSelected : []) } @@ -1058,7 +1058,7 @@ struct RunView: View { Text("localhost:" + portText) .underline() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .help("Open \(serviceURL.absoluteString) in browser") .accessibilityLabel("Open service on port " + portText) @@ -1073,7 +1073,7 @@ struct RunView: View { Button(action: onPin) { Image(systemName: isPinned ? "pin.fill" : "pin") } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(width: 20, height: 24) .contentShape(Rectangle()) .lithePointer() diff --git a/macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift b/macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift index 2cf781fcd..1e79463c1 100644 --- a/macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift +++ b/macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift @@ -55,7 +55,7 @@ struct SpringEndpointsView: View { .frame(maxWidth: .infinity, minHeight: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift b/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift index b5e0e44cf..957e99bd0 100644 --- a/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift +++ b/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift @@ -374,7 +374,7 @@ private struct ProjectReplaceOptionButton: View { .background(isOn || isHovering ? LitheTheme.subtleSelection : .clear) .clipShape(RoundedRectangle(cornerRadius: 3)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(!isEnabled) .opacity(isEnabled ? 1 : 0.45) .onHover { isHovering = $0 } @@ -417,7 +417,7 @@ private struct ProjectReplaceCheckboxStyle: ToggleStyle { configuration.label } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } } diff --git a/macos/Sources/Lithe/Views/Search/ProjectReplacementSourcePreview.swift b/macos/Sources/Lithe/Views/Search/ProjectReplacementSourcePreview.swift index 5c1e82fb8..5f7ec5d8a 100644 --- a/macos/Sources/Lithe/Views/Search/ProjectReplacementSourcePreview.swift +++ b/macos/Sources/Lithe/Views/Search/ProjectReplacementSourcePreview.swift @@ -80,7 +80,7 @@ private struct ProjectReplacementDocumentEditor: View { } } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(saveTask != nil || !document.isDirty || document.isReadOnly) } .font(.system(size: 12)) diff --git a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift index 97da329b5..fc2c63c5a 100644 --- a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift @@ -232,7 +232,7 @@ struct SearchEverywhereView: View { } } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .contentShape(Rectangle()) } @@ -406,7 +406,7 @@ struct SearchEverywhereView: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() Button { @@ -414,7 +414,7 @@ struct SearchEverywhereView: View { } label: { LitheIcon(kind: .folder, size: 13) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Show in Finder") } @@ -490,7 +490,7 @@ struct SearchEverywhereView: View { .background(index == selectedIndex ? LitheTheme.selection : .clear) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } diff --git a/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift b/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift index 8b2b0169f..1d3d26ea2 100644 --- a/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift @@ -112,7 +112,7 @@ struct SearchSidebarView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheContextMenu { [ diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index affdfb183..5c300a41e 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -178,7 +178,7 @@ struct TerminalView: View { .frame(width: 22, height: 22) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help("Close \(feature.terminalTitle(for: session))") } .background(isActive ? LitheTheme.subtleSelection : .clear) diff --git a/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift b/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift index f875e2d89..3bf0fbd93 100644 --- a/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift +++ b/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift @@ -328,7 +328,7 @@ struct OutputTextView: View { .clipShape(Capsule()) .overlay(Capsule().stroke(LitheTheme.panelBorder, lineWidth: 1)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.primaryText) .disabled(output.isEmpty) @@ -349,7 +349,7 @@ struct OutputTextView: View { .clipShape(Capsule()) .overlay(Capsule().stroke(LitheTheme.panelBorder, lineWidth: 1)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.primaryText) } diff --git a/macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift b/macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift index 16102fb8a..ea6b147d7 100644 --- a/macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift +++ b/macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift @@ -39,7 +39,7 @@ private struct ProjectPreparationContent: View { } .font(.system(size: 11)) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(chinese ? "查看项目准备详情" : "View project preparation details") .popover(isPresented: $showingDetails, arrowEdge: compact ? .bottom : .top) { VStack(alignment: .leading, spacing: 12) { diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 835fff7e8..9b5e77495 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -17,6 +17,7 @@ struct SplitHandleView: View { let axis: LitheSplitAxis let trackBackground: Color let showsIdleDivider: Bool + let highlightsOnHover: Bool let onDragStarted: () -> Void let onDragChanged: (CGFloat) -> Void let onDragEnded: (CGFloat) -> Void @@ -30,6 +31,7 @@ struct SplitHandleView: View { axis: LitheSplitAxis, trackBackground: Color = .clear, showsIdleDivider: Bool = true, + highlightsOnHover: Bool = true, onDragStarted: @escaping () -> Void, onDragChanged: @escaping (CGFloat) -> Void, onDragEnded: @escaping (CGFloat) -> Void @@ -37,6 +39,7 @@ struct SplitHandleView: View { self.axis = axis self.trackBackground = trackBackground self.showsIdleDivider = showsIdleDivider + self.highlightsOnHover = highlightsOnHover self.onDragStarted = onDragStarted self.onDragChanged = onDragChanged self.onDragEnded = onDragEnded @@ -64,6 +67,7 @@ struct SplitHandleView: View { SplitHandleInteraction( axis: axis, onHoverChanged: { isInside in + guard highlightsOnHover else { return } guard isInside != isHovering else { return } isHovering = isInside }, @@ -114,8 +118,8 @@ struct SplitHandleView: View { @ViewBuilder private var dividerLine: some View { if showsIdleDivider { - let color = isDragging ? LitheTheme.primaryText - : (isHovering ? LitheTheme.secondaryText : LitheTheme.divider) + let color = isDragging && highlightsOnHover ? LitheTheme.primaryText + : (isHovering && highlightsOnHover ? LitheTheme.secondaryText : LitheTheme.divider) if axis == .horizontal { Rectangle() .fill(color) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 7c35c8537..ba2b4fda7 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -14,7 +14,7 @@ struct EditorCaretPositionLabel: View { Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") .monospacedDigit() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Go to Line…") } @@ -39,7 +39,7 @@ struct MemoryUsageStatusView: View { Image(systemName: "memorychip") } } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help( Text( diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 0b14bbfd0..c7cb80198 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -237,7 +237,6 @@ struct WorkbenchView: View { @State private var pendingTopBarPushReference: GitReference? @State private var pendingTopBarDeleteReference: GitReference? @State private var isProjectSwitcherPresented = false - @State private var isPluginPanelPresented = false @State private var isNotificationCenterPresented = false @State private var didRestoreLayout = false @State private var hoveredProjectTabID: UUID? @@ -535,7 +534,7 @@ struct WorkbenchView: View { .font(.system(size: 10, weight: .semibold)) .foregroundStyle(LitheTheme.tertiaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(width: 16, height: 16) .contentShape(Rectangle()) .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil) @@ -685,7 +684,7 @@ struct WorkbenchView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .accessibilityIdentifier("project-tab-\(projectModel.id.uuidString)") @@ -766,7 +765,8 @@ struct WorkbenchView: View { activeBackground: LitheTheme.subtleSelection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) + .lithePointer() .accessibilityIdentifier("project-switcher-\(model.id.uuidString)") .anchorPreference( key: ProjectSwitcherButtonBoundsPreferenceKey.self, @@ -802,7 +802,8 @@ struct WorkbenchView: View { activeBackground: LitheTheme.subtleSelection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) + .lithePointer() .anchorPreference( key: BranchSwitcherButtonBoundsPreferenceKey.self, value: .bounds @@ -1078,7 +1079,7 @@ struct WorkbenchView: View { .frame(width: 28, height: 28) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(model.runFeatureIfActive?.isSelectedConfigurationRunning == true ? "Rerun selected configuration" : "Run selected configuration") .accessibilityLabel(model.runFeatureIfActive?.isSelectedConfigurationRunning == true ? "Rerun selected configuration" : "Run selected configuration") @@ -1100,7 +1101,7 @@ struct WorkbenchView: View { .frame(width: 28, height: 28) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration") .accessibilityLabel(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration") @@ -1124,7 +1125,7 @@ struct WorkbenchView: View { .frame(width: 28, height: 28) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Stop active execution") .accessibilityLabel("Stop active execution") @@ -1163,7 +1164,7 @@ struct WorkbenchView: View { .frame(height: 30) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .frame(minWidth: 160, maxWidth: 190, alignment: .leading) .frame(height: 30) .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) @@ -1218,7 +1219,7 @@ struct WorkbenchView: View { .contentShape(Rectangle()) .litheRowHover(isActive: isSelected, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .help(configuration.name) .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -1244,7 +1245,7 @@ struct WorkbenchView: View { activeBackground: LitheTheme.subtleSelection ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .help("Change workbench background") @@ -1287,7 +1288,7 @@ struct WorkbenchView: View { .frame(width: ActivityBarMetrics.slotWidth, height: ActivityBarMetrics.slotHeight) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(!destination.isAvailable) .foregroundStyle(model.workbenchFeature.selectedSidebar == destination ? LitheTheme.toolWindowSelectedText : LitheTheme.toolWindowButtonText) .workbenchHoverHelp( @@ -1375,7 +1376,7 @@ struct WorkbenchView: View { .frame(width: ActivityBarMetrics.slotWidth, height: ActivityBarMetrics.slotHeight) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle( isNotificationCenterPresented ? LitheTheme.toolWindowSelectedText : unreadNotificationCount > 0 ? LitheTheme.primaryText @@ -1392,8 +1393,9 @@ struct WorkbenchView: View { ideaAssetPath: "expui/nodes/plugin.svg", help: "Plugins", tooltipPlacement: .leading, - isSelected: isPluginPanelPresented, - action: { isPluginPanelPresented.toggle() } + isSelected: model.workbenchFeature.isSettingsPresented + && model.requestedSettingsCategory == .plugins, + action: { model.showSettings(category: .plugins) } ) ForEach(model.rightSidebarContributions) { contribution in @@ -1492,7 +1494,7 @@ struct WorkbenchView: View { .frame(width: ActivityBarMetrics.slotWidth, height: ActivityBarMetrics.slotHeight) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(isSelected ? LitheTheme.toolWindowSelectedText : LitheTheme.toolWindowButtonText) .workbenchHoverHelp(Text(LocalizedStringKey(help)), placement: tooltipPlacement) .accessibilityLabel(Text(LocalizedStringKey(help))) @@ -1546,10 +1548,7 @@ struct WorkbenchView: View { }, editor: { Group { - if isPluginPanelPresented { - PluginManagementView() - .environmentObject(model) - } else if model.workbenchFeature.selectedSidebar == .pullRequests { + if model.workbenchFeature.selectedSidebar == .pullRequests { if LitheFeatureAvailability.githubPullRequests { GitHubPullRequestDetailView() } else { @@ -1727,7 +1726,7 @@ struct WorkbenchView: View { } .foregroundStyle(isEmphasized ? LitheTheme.primaryText : LitheTheme.secondaryText) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help(LocalizedStringKey(title)) } @@ -1871,7 +1870,7 @@ private struct WorkbenchNotificationCenterView: View { Button("Clear All") { model.clearNotifications() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .font(.system(size: 11.5, weight: .medium)) .foregroundStyle( model.notifications.isEmpty @@ -2464,7 +2463,7 @@ struct WorkbenchBackgroundPicker: View { Button("Remove") { model.workbenchBackgroundFeature.clear() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .lithePointer() } @@ -2521,7 +2520,7 @@ struct WorkbenchBackgroundPicker: View { } .frame(width: 100) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .accessibilityLabel(Text(LocalizedStringKey(preset.title))) } diff --git a/macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift b/macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift index b670a5619..c50099dc1 100644 --- a/macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift @@ -162,7 +162,7 @@ private struct DependencyServiceSection: View { .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() Button { @@ -173,7 +173,7 @@ private struct DependencyServiceSection: View { .foregroundStyle(LitheTheme.secondaryText) .frame(width: 28, height: 28) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .help("Configure dependency search paths") .accessibilityIdentifier("dependency-path-settings-\(service.id)") @@ -214,7 +214,7 @@ private struct DependencyServiceSection: View { .foregroundStyle(LitheTheme.secondaryText) .lineLimit(3) Button("Retry") { loadDependencies() } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .foregroundStyle(LitheTheme.accent) .lithePointer() } @@ -334,7 +334,7 @@ private struct DependencyTreeNodeView: View { .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() if isExpanded { @@ -366,7 +366,7 @@ private struct DependencyTreeNodeView: View { Group { if case .virtualDocument(let uri) = node.source { Button { onOpenVirtual(uri) } label: { pathRowContent } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } else { pathRowContent @@ -535,7 +535,7 @@ private struct DependencyPathConfigurationEditor: View { } label: { LitheSystemIcon(systemImage: "arrow.uturn.backward") } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .help("Restore path") } } @@ -609,7 +609,7 @@ private struct DependencyPathConfigurationEditor: View { } label: { LitheSystemIcon(systemImage: "minus.circle") } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .help("Remove path") } } @@ -763,14 +763,14 @@ private struct DependencyFolderBrowser: View { LitheSystemIcon(systemImage: "arrow.up") .frame(width: 24, height: 24) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .disabled(folderURL.path == "/") .help("Parent folder") Button { folderURL = workspaceURL.standardizedFileURL } label: { LitheSystemIcon(systemImage: "house") .frame(width: 24, height: 24) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .help("Workspace folder") Text(folderURL.path) .font(.system(size: 10.5, design: .monospaced)) @@ -878,7 +878,7 @@ private struct DependencyFolderBrowser: View { LitheSystemIcon(systemImage: "chevron.right") .frame(width: 24, height: 24) } - .buttonStyle(.borderless) + .buttonStyle(.litheNoPress) .help("Open folder") } } diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 69778b677..aeea8f06f 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -499,7 +499,7 @@ private struct FileNodeRow: View { animation: nil ) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) .litheContextMenu( @@ -547,7 +547,7 @@ private struct FileNodeRow: View { animation: nil ) } - .buttonStyle(LitheTreeRowButtonStyle()) + .buttonStyle(.litheNoPress) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) .litheContextMenu( diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift index 34b090eb1..c68ff0b5d 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift @@ -100,7 +100,7 @@ struct ProjectSwitcherPopover: View { .contentShape(Rectangle()) .litheRowHover(cornerRadius: 5) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() } @@ -117,7 +117,7 @@ struct ProjectSwitcherPopover: View { isCurrent: isCurrent ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .lithePointer() .litheRowHover( isActive: isCurrent, @@ -140,7 +140,7 @@ struct ProjectSwitcherPopover: View { isCurrent: false ) } - .buttonStyle(.plain) + .buttonStyle(.litheNoPress) .disabled(!exists) .lithePointer() .litheRowHover(cornerRadius: 5) diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 2137725f3..a4673ce94 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -1,6 +1,25 @@ import Foundation import LitheModuleAPI +/// What a running Java session was asked to do with a Maven configuration. +/// +/// Every step runs asynchronously inside JDT LS; resolution problems arrive +/// later as `pom.xml` diagnostics. +package struct LanguageServerMavenConfigurationUpdate: Decodable, Equatable, Sendable { + /// New settings documents were sent, so JDT LS re-imports every Maven project. + package let settingsChanged: Bool + /// Settings were unchanged, so a forced project update was requested. + package let projectsReloaded: Bool + /// Changed profiles are being applied. + package let profilesUpdating: Bool + + package init(settingsChanged: Bool, projectsReloaded: Bool, profilesUpdating: Bool) { + self.settingsChanged = settingsChanged + self.projectsReloaded = projectsReloaded + self.profilesUpdating = profilesUpdating + } +} + package struct LanguageServerRuntimeFailure: Error, Equatable, Sendable { package let code: String package let message: String @@ -343,6 +362,13 @@ package protocol LanguageServerRuntimeCore: Sendable { func stopLanguageServer(sessionID: String) /// Retries a failed Maven profile task without restarting the server. func retryMavenProfiles(sessionID: String) -> Result + /// Sends a changed Maven context to a running Java session, or with + /// `reloadProjects` forces JDT LS to re-resolve its Maven projects. + func updateMavenConfiguration( + sessionID: String, + context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result func syncLanguageServerDocument( sessionID: String, fileURL: URL, @@ -386,6 +412,19 @@ package protocol LanguageServerRuntimeCore: Sendable { package extension LanguageServerRuntimeCore { func retryMavenProfiles(sessionID _: String) -> Result { .success(()) } + /// Runtimes without in-place Maven updates report that they cannot take + /// one, so callers fall back to restarting the session. + func updateMavenConfiguration( + sessionID _: String, + context _: MavenLaunchContext, + reloadProjects _: Bool + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "unsupported", + message: "This language runtime cannot update Maven settings in place." + )) + } + func startLanguageServer( providerID: String, executableURL: URL, diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 5e1ba35c8..b7a0abf8b 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -848,6 +848,12 @@ package protocol LanguageServerSession: AnyObject { mavenContext: MavenLaunchContext? ) throws func retryMavenProfiles() + /// Hands a changed Maven context to the running server. A failure means the + /// session cannot take it and should be restarted. + func updateMavenConfiguration( + _ context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result func synchronize(fileURL: URL, text: String, languageID: String) throws func notifyWorkspaceFilesChanged(_ changes: [LanguageServerWorkspaceFileChange]) throws func closeDocument(_ fileURL: URL) @@ -961,6 +967,16 @@ package extension LanguageServerSession { } func retryMavenProfiles() {} + func updateMavenConfiguration( + _: MavenLaunchContext, + reloadProjects _: Bool + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "unsupported", + message: "This language session cannot update Maven settings in place." + )) + } + func start( rootURL: URL, workspaceFingerprint: String?, diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 6cb06e670..92cfa70b0 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -34,6 +34,11 @@ package final class MavenFeatureModel: ObservableObject { package var mavenExecutablePath: String? { service.mavenExecutablePath } package var javaHomePath: String? { service.javaHomePath } package var configurationSaveError: String? { service.configurationSaveError } + package var javaConfigurationError: String? { service.javaConfigurationError } + /// Connects Maven configuration changes to the running Java language session. + package func configureJavaConfigurationSync(_ apply: @escaping @MainActor (URL) throws -> Bool) { + service.applyConfigurationToJava = apply + } package var isReloadRequired: Bool { service.isReloadRequired } package var isProjectReloadRequired: Bool { service.isProjectReloadRequired } package var isReloading: Bool { service.isReloading } diff --git a/macos/Sources/LitheExecutionModule/Services/MavenService.swift b/macos/Sources/LitheExecutionModule/Services/MavenService.swift index 61b2f54c9..b8de9064f 100644 --- a/macos/Sources/LitheExecutionModule/Services/MavenService.swift +++ b/macos/Sources/LitheExecutionModule/Services/MavenService.swift @@ -45,6 +45,9 @@ package final class MavenService: ObservableObject { @Published package private(set) var mavenExecutablePath: String? @Published package private(set) var javaHomePath: String? @Published package private(set) var configurationSaveError: String? + /// Why the running Java language session did not take the latest Maven + /// configuration. The reload action restarts that session. + @Published package private(set) var javaConfigurationError: String? @Published package private(set) var isReloadRequired = false @Published package private(set) var isProjectReloadRequired = false @Published package private(set) var isReloading = false @@ -52,6 +55,10 @@ package final class MavenService: ObservableObject { private var reloadRevision = 0 private var reloadTask: Task? package var onProjectReloaded: (@MainActor (URL, MavenProject) -> Void)? + /// Hands the current configuration to the workspace's running Java language + /// session. Returns whether a running session took it and throws when one + /// could not. JDT LS re-resolves in place, so no reload is needed (#970). + package var applyConfigurationToJava: (@MainActor (URL) throws -> Bool)? @Published package private(set) var dependencyStates: [String: MavenDependencyLoadState] = [:] package var isLoadingProject: Bool { @@ -385,6 +392,7 @@ package final class MavenService: ObservableObject { package func acknowledgeReload() { guard !isProjectReloadRequired else { return } isReloadRequired = false + javaConfigurationError = nil refreshConfigurationFingerprint(establishBaseline: true) } @@ -451,6 +459,7 @@ package final class MavenService: ObservableObject { self.invalidateDependencies() self.isProjectReloadRequired = false self.isReloadRequired = false + self.javaConfigurationError = nil self.projectState = .ready self.onProjectReloaded?(root, candidate.0) } catch { @@ -934,10 +943,24 @@ package final class MavenService: ObservableObject { private func configurationDidChange() { reloadRevision += 1 invalidateDependencies() - isReloadRequired = isProjectReloadRequired || configurationFingerprint != nil configurationSaveError = nil persistConfiguration() - refreshConfigurationFingerprint() + guard let workspaceURL, let applyConfigurationToJava else { + isReloadRequired = isProjectReloadRequired || configurationFingerprint != nil + refreshConfigurationFingerprint() + return + } + do { + // A running session took the change, or the next start reads it; + // either way JDT LS holds this configuration from now on. + _ = try applyConfigurationToJava(workspaceURL) + javaConfigurationError = nil + isReloadRequired = isProjectReloadRequired + refreshConfigurationFingerprint(establishBaseline: true) + } catch { + javaConfigurationError = error.localizedDescription + isReloadRequired = true + } } private func refreshConfigurationFingerprint(establishBaseline: Bool = false) { diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index c3289e540..050480227 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -530,6 +530,29 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Hands a changed Maven context to the running server. A failure means the + /// session cannot take it and the caller should restart the session. + package func updateMavenConfiguration( + _ context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result { + guard let sessionID, isRunning else { + return .failure(LanguageServerRuntimeFailure( + code: "sessionStopped", + message: "The Java language session is no longer running." + )) + } + let result = core.updateMavenConfiguration( + sessionID: sessionID, + context: context, + reloadProjects: reloadProjects + ) + if case .failure(let failure) = result { + onLog?(.warning, "Maven configuration update failed", failure.userMessage, nil) + } + return result + } + // MARK: - Requests private func request( diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 8f8d9a1cf..5a85a2937 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -396,9 +396,54 @@ package final class LanguageToolingSessionManager: ObservableObject, return languageServerOperationIDs[providerID] ?? operationID } - /// Reloads only Java and waits for project import, preserving other providers. - /// Cancellation terminates only the session created by this reload. + /// Outcome of handing the workspace's Maven context to its Java session. + package enum JavaMavenConfigurationOutcome: Equatable { + /// The running session took the change and re-resolves in place. + case updated(LanguageServerMavenConfigurationUpdate) + /// No Java session runs for this root; the next start reads the context. + case noSession + /// A session runs but could not take the change and needs a restart. + case failed(LanguageServerRuntimeFailure) + } + + /// Sends the workspace's current Maven context to the running Java session. + /// + /// JDT LS re-resolves on its own: changed settings make it force-update every + /// Maven project, and `reloadProjects` re-resolves even when nothing changed. + /// A restart would reuse the workspace state and skip every project whose + /// `pom.xml` did not change, keeping unresolved artifacts missing (#970). + @discardableResult + package func updateJavaMavenConfiguration( + rootURL: URL, + reloadProjects: Bool + ) -> JavaMavenConfigurationOutcome { + let root = rootURL.standardizedFileURL + // The workspace preference is rechecked like every other entry point; + // a disabled provider reports no session, so a reload fails where a + // start would. + guard isLanguageServerEnabled("java", root), + let session = languageServers["java"], + session.isRunning, + languageServerRoots["java"] == root, + let descriptor = catalog.descriptors.first(where: { $0.id == "java" }), + let context = mavenContextProvider(descriptor, root) else { + return .noSession + } + switch session.updateMavenConfiguration(context, reloadProjects: reloadProjects) { + case .success(let update): return .updated(update) + case .failure(let failure): return .failed(failure) + } + } + + /// Reloads Java Maven projects and waits for readiness, preserving other + /// providers. A running session re-resolves in place; a restart is only the + /// recovery for a session that cannot take the update, or the way to start + /// one. Cancellation terminates only a session created by this reload. package func reloadJavaWorkspace(rootURL: URL) async throws { + if case .updated = updateJavaMavenConfiguration(rootURL: rootURL, reloadProjects: true) { + try await waitUntilLanguageServerReady(providerID: "java", rootURL: rootURL) + return + } stopLanguageServer(providerID: "java") let operationID = try startLanguageServer(providerID: "java", rootURL: rootURL) do { diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 63bb2db64..f579620f3 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -211,6 +211,7 @@ public enum BuiltInPluginCatalog { public enum OfficialPluginCatalog { private static let goLanguageID = "go" private static let phpLanguageID = "php" + public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") public static let manifests: [PluginManifest] = [ PluginManifest( @@ -268,7 +269,7 @@ public enum OfficialPluginCatalog { )] ), PluginManifest( - id: PluginID("dev.lithe.plugin.php-support"), + id: phpPluginID, displayName: "PHP Support", version: BuiltInPluginCatalog.hostVersion, hostCompatibility: PluginHostCompatibility( diff --git a/macos/Sources/LitheRustCore/bridge.c b/macos/Sources/LitheRustCore/bridge.c index 9893e6600..bdc136ae9 100644 --- a/macos/Sources/LitheRustCore/bridge.c +++ b/macos/Sources/LitheRustCore/bridge.c @@ -25,6 +25,15 @@ __attribute__((weak)) void lithe_core_free_string(char *value) { (void)value; } +__attribute__((weak)) int32_t lithe_core_is_plain_text(const uint8_t *bytes, size_t length) { + (void)bytes; (void)length; + return -1; +} + +int32_t lithe_bridge_is_plain_text(const uint8_t *bytes, size_t length) { + return lithe_core_is_plain_text(bytes, length); +} + const char *lithe_bridge_version(void) { return lithe_core_version(); } diff --git a/macos/Sources/LitheRustCore/include/lithe_bridge.h b/macos/Sources/LitheRustCore/include/lithe_bridge.h index cb9b08203..7fb68a78b 100644 --- a/macos/Sources/LitheRustCore/include/lithe_bridge.h +++ b/macos/Sources/LitheRustCore/include/lithe_bridge.h @@ -2,12 +2,15 @@ #define LITHE_BRIDGE_H #include +#include #ifdef __cplusplus extern "C" { #endif const char *lithe_bridge_version(void); +/* Borrowed UTF-8 slice: 1 text, 0 binary controls, -1 invalid input. */ +int32_t lithe_bridge_is_plain_text(const uint8_t *bytes, size_t length); int32_t lithe_bridge_git_askpass(const char *prompt); char *lithe_bridge_execute_json(const char *request); char *lithe_bridge_execute_json_with_events(const char *request, void (*callback)(const char *, void *), void *context); diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 351508ce5..0b9000641 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -365,6 +365,45 @@ struct ExecutionModuleTests { #expect(service.isProjectReloadRequired) } + @Test + func mavenSettingsGoStraightToTheRunningJavaSession() async throws { + // #970: a settings change only marked a reload, and the reload restarted + // JDT LS on unchanged workspace state. The running session now takes it. + let (service, root) = await makeReloadService() + defer { service.reset() } + var appliedRoots: [URL] = [] + service.applyConfigurationToJava = { workspace in + appliedRoots.append(workspace) + return true + } + + service.updateLocalConfiguration( + settingsPath: "/maven/conf/settings.xml", + localRepositoryPath: "/repository", + mavenExecutablePath: nil, + javaHomePath: nil + ) + + #expect(appliedRoots.map(\.standardizedFileURL.path) == [root.standardizedFileURL.path]) + #expect(!service.isReloadRequired) + #expect(service.javaConfigurationError == nil) + #expect(service.launchContext?.localRepositoryPath == "/repository") + } + + @Test + func aJavaSessionThatRejectsMavenSettingsOffersTheReload() async throws { + let (service, _) = await makeReloadService() + defer { service.reset() } + service.applyConfigurationToJava = { _ in throw ReloadTestError.failed } + + service.setSkipTests(true) + + #expect(service.isReloadRequired) + #expect(service.javaConfigurationError != nil) + service.acknowledgeReload() + #expect(service.javaConfigurationError == nil) + } + @Test func mavenConfigurationOnlyReloadDoesNotScanPom() async throws { let (service, root) = await makeReloadService() diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index cce0b78db..70024fa51 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -115,6 +115,111 @@ struct LanguageIntelligenceModuleTests { } } + @Test + func mavenReloadUpdatesARunningJavaSessionInPlace() async throws { + // #970: restarting JDT LS reused its workspace state and skipped every + // project whose pom.xml had not changed, so missing artifacts stayed + // missing. A running session must take a forced update instead. + let root = URL(fileURLWithPath: "/workspace/java-reload-in-place", isDirectory: true) + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider( + for: root.appendingPathComponent("Main.java") + )) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)] + ) + let context = MavenLaunchContext( + reactorPath: ".", profiles: [], settingsPath: "/maven/conf/settings.xml", + localRepositoryPath: "/repository", skipTests: false, + mavenExecutablePath: "/maven/bin/mvn", javaHomePath: nil + ) + manager.configureMavenContextProvider { _, _ in context } + defer { manager.stopLanguageServer(providerID: "java") } + try manager.startLanguageServer(providerID: "java", rootURL: root) + session.publish(.ready) + session.mavenUpdateResult = .success(LanguageServerMavenConfigurationUpdate( + settingsChanged: false, projectsReloaded: true, profilesUpdating: false + )) + + try await manager.reloadJavaWorkspace(rootURL: root) + + #expect(session.startCallCount == 1) + #expect(session.stopCallCount == 0) + #expect(session.mavenUpdates.count == 1) + #expect(session.mavenUpdates.first?.context == context) + #expect(session.mavenUpdates.first?.reloadProjects == true) + } + + @Test + func mavenReloadRestartsASessionThatCannotTakeTheUpdate() async throws { + let root = URL(fileURLWithPath: "/workspace/java-reload-restart", isDirectory: true) + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider( + for: root.appendingPathComponent("Main.java") + )) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)] + ) + manager.configureMavenContextProvider { _, _ in + MavenLaunchContext( + reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, + mavenExecutablePath: nil, javaHomePath: nil + ) + } + try manager.startLanguageServer(providerID: "java", rootURL: root) + session.publish(.ready) + + let task = Task { try await manager.reloadJavaWorkspace(rootURL: root) } + defer { task.cancel(); manager.stopLanguageServer(providerID: "java") } + // The failed update, stop, and restart run synchronously on the main + // actor before the reload suspends for readiness; a bounded number of + // yields lets the task reach that point. + for _ in 0..<100 where session.startCallCount < 2 { await Task.yield() } + try #require(session.startCallCount == 2) + session.publish(.ready) + try await task.value + + #expect(session.mavenUpdates.count == 1) + #expect(session.stopCallCount == 1) + #expect(session.startCallCount == 2) + } + + @Test + func mavenSettingsReachOnlyARunningJavaSessionOfTheSameWorkspace() throws { + let root = URL(fileURLWithPath: "/workspace/java-settings", isDirectory: true) + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider( + for: root.appendingPathComponent("Main.java") + )) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)] + ) + manager.configureMavenContextProvider { _, _ in + MavenLaunchContext( + reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, + mavenExecutablePath: nil, javaHomePath: nil + ) + } + defer { manager.stopLanguageServer(providerID: "java") } + + #expect(manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) == .noSession) + try manager.startLanguageServer(providerID: "java", rootURL: root) + let update = LanguageServerMavenConfigurationUpdate( + settingsChanged: true, projectsReloaded: false, profilesUpdating: false + ) + session.mavenUpdateResult = .success(update) + + #expect(manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) == .updated(update)) + #expect(manager.updateJavaMavenConfiguration( + rootURL: URL(fileURLWithPath: "/workspace/other", isDirectory: true), + reloadProjects: false + ) == .noSession) + #expect(session.mavenUpdates.map(\.reloadProjects) == [false]) + } + @Test func disabledModuleDoesNotConstructFactoryOrServiceGraph() async throws { let recorder = Recorder() @@ -1253,6 +1358,10 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { private(set) var startCallCount = 0 var startError: Error? private(set) var executedCommands: [LanguageServerCommand] = [] + /// Maven updates handed to the running session, in order. + private(set) var mavenUpdates: [(context: MavenLaunchContext, reloadProjects: Bool)] = [] + var mavenUpdateResult: Result = + .failure(LanguageServerRuntimeFailure(code: "unsupported", message: "unsupported")) private var startWaiters: [UUID: CheckedContinuation] = [:] private var startTimeoutTasks: [UUID: Task] = [:] private var executeWaiters: [UUID: ( @@ -1290,6 +1399,14 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { onStateChange?(state) } + func updateMavenConfiguration( + _ context: MavenLaunchContext, + reloadProjects: Bool + ) -> Result { + mavenUpdates.append((context, reloadProjects)) + return mavenUpdateResult + } + func publishPreparation(_ snapshot: ProjectPreparationSnapshot) { onProjectPreparation?(snapshot) } diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index d49e6ca59..4856d363f 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -203,16 +203,11 @@ struct AppLocalizationTests { } @Test - func simplifiedChineseResourcesCoverPluginLanguageGrouping() throws { + func simplifiedChineseResourcesCoverPHPPluginManagement() throws { let translations = try simplifiedChineseTranslations() - #expect(translations["More Language Support"] == "扩展更多语言") - #expect( - translations["%lld languages · %lld enabled"] - == "%lld 种语言 · 已启用 %lld 个" - ) - #expect(translations["Expanded"] == "已展开") - #expect(translations["Collapsed"] == "已收起") + #expect(translations["PHP Support"] == "PHP 支持") + #expect(translations["Installed (%lld of %lld enabled)"] == "已安装(%lld / %lld 个已启用)") } @Test diff --git a/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift b/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift new file mode 100644 index 000000000..a7b248613 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct HTMLBrowserOpenerTests { + @Test func htmlUsesBrowserAssociationInsteadOfFileAssociation() async throws { + let file = URL(fileURLWithPath: "/fixture/中文 folder/page #1.HTML") + let browser = URL(fileURLWithPath: "/fixture/Browser.app") + var opened = false + let opener = MacHTMLBrowserOpener( + applicationForURL: { probe in + #expect(probe.scheme == "https") + #expect(!probe.isFileURL) + return browser + }, + openURLs: { urls, application in + #expect(urls == [file]) + #expect(application == browser) + opened = true + }, + isRegularFile: { _ in true } + ) + try await opener.open(file) + #expect(opened) + } + + @Test func missingBrowserAndLaunchFailuresAreReported() async { + let file = URL(fileURLWithPath: "/fixture/page.html") + let missing = MacHTMLBrowserOpener( + applicationForURL: { _ in nil }, + openURLs: { _, _ in Issue.record("Must not fall back to the HTML association") }, + isRegularFile: { _ in true } + ) + await #expect(throws: (any Error).self) { try await missing.open(file) } + let failing = MacHTMLBrowserOpener( + applicationForURL: { _ in URL(fileURLWithPath: "/fixture/Browser.app") }, + openURLs: { _, _ in throw CocoaError(.fileReadNoSuchFile) }, + isRegularFile: { _ in true } + ) + await #expect(throws: CocoaError.self) { try await failing.open(file) } + } + + @Test func rejectsNonHTMLRemoteAndMissingFiles() async { + let opener = MacHTMLBrowserOpener( + applicationForURL: { _ in + Issue.record("Invalid input must not reach Launch Services") + return nil + }, + isRegularFile: { _ in false } + ) + for url in [URL(fileURLWithPath: "/fixture/tool.sh"), URL(string: "https://example.invalid/page.html")!, URL(fileURLWithPath: "/fixture/missing.html")] { + await #expect(throws: CocoaError.self) { try await opener.open(url) } + } + } +} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 8dbe23b07..74791fa93 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -237,6 +237,81 @@ struct LitheCoreLogicTests { #expect(sessions.requestCloseActiveSessionCallCount == 0) } + @Test + @MainActor + func settingsStayBoundToOpeningSessionUntilAnotherSessionReopensThem() async throws { + let store = MutableKeyValueStore() + let settings = AppSettings(store: store) + settings.projectOpenBehavior = .newWindow + var presentedWindowIDs: [UUID] = [] + let manager = ProjectSessionManager( + settings: settings, + modelFactory: { + AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode, + javaMavenOperations: NoProjectJavaOperations() + ).services + ) + }, + projectWindowPresenter: { presentedWindowIDs.append($0) } + ) + + let primaryID = manager.activeSessionID(in: .primary) + manager.openStartupProject(URL(fileURLWithPath: "/tmp/lithe-settings-primary")) + manager.requestOpenProject( + URL(fileURLWithPath: "/tmp/lithe-settings-dedicated"), + from: primaryID + ) + let dedicatedWindowID = try #require(presentedWindowIDs.first) + let dedicatedID = manager.activeSessionID(in: .dedicated(dedicatedWindowID)) + + manager.bindSettings(to: primaryID) + let firstBindingID = manager.settingsBindingID + manager.noteWindowBecameKey(.dedicated(dedicatedWindowID)) + #expect(manager.activeSessionID == dedicatedID) + #expect(manager.settingsModel?.id == primaryID) + + let firstDraft = SettingsViewState(initialCategory: .plugins) + let secondDraft = SettingsViewState(initialCategory: .plugins) + let pluginID = OfficialPluginCatalog.phpPluginID + firstDraft.pendingPluginEnabledStates[pluginID] = true + let applied = await firstDraft.applyPluginChanges { _ in + await withCheckedContinuation { continuation in + manager.bindSettings(to: dedicatedID) + secondDraft.pendingPluginEnabledStates[pluginID] = false + continuation.resume(returning: [pluginID]) + } + } + #expect(applied) + var didCloseSettings = false + manager.closeSettingsIfCurrent(for: primaryID, bindingID: firstBindingID) { + didCloseSettings = true + } + #expect(!didCloseSettings) + #expect(manager.settingsModel?.id == dedicatedID) + #expect(secondDraft.pendingPluginEnabledStates[pluginID] == false) + + manager.releaseSettings(for: primaryID) + #expect(manager.settingsModel?.id == dedicatedID) + let secondBindingID = manager.settingsBindingID + manager.closeSettingsIfCurrent(for: dedicatedID, bindingID: secondBindingID) { + didCloseSettings = true + } + #expect(didCloseSettings) + didCloseSettings = false + manager.bindSettings(to: dedicatedID) + manager.closeSettingsIfCurrent(for: dedicatedID, bindingID: secondBindingID) { + didCloseSettings = true + } + #expect(!didCloseSettings) + manager.releaseSettings(for: dedicatedID) + #expect(manager.settingsModel == nil) + } + @Test @MainActor func openingAProjectInANewWindowCreatesADedicatedSession() throws { diff --git a/macos/Tests/LitheTests/PluginManagementPresentationTests.swift b/macos/Tests/LitheTests/PluginManagementPresentationTests.swift index 2bd625566..8225db7d7 100644 --- a/macos/Tests/LitheTests/PluginManagementPresentationTests.swift +++ b/macos/Tests/LitheTests/PluginManagementPresentationTests.swift @@ -5,30 +5,51 @@ import LitheModuleAPI @Suite("Plugin management presentation") struct PluginManagementPresentationTests { @Test - func languagePluginsAreGroupedSeparatelyFromStandalonePlugins() throws { + func onlyOfficialPHPPluginAppearsInSettings() throws { + let phpManifest = try #require( + OfficialPluginCatalog.manifests.first { $0.id == OfficialPluginCatalog.phpPluginID } + ) + let goManifest = try #require( + OfficialPluginCatalog.manifests.first { $0.id != OfficialPluginCatalog.phpPluginID } + ) let pythonManifest = try #require( BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "python" } ) - let rustManifest = try #require( - BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "rust" } - ) let content = PluginManagementListContent(plugins: [ + snapshot(phpManifest), + snapshot(goManifest), snapshot(pythonManifest), - snapshot(rustManifest) ]) - #expect(content.standalonePlugins.isEmpty) - #expect(content.languageExtensions.map(\.id) == [pythonManifest.id, rustManifest.id]) + #expect(content.plugins.map(\.id) == [phpManifest.id]) } @Test - func everyBundledLanguagePluginUsesTheLanguageExtensionGroup() { - let content = PluginManagementListContent( - plugins: BundledLanguagePluginCatalog.manifests.map(snapshot) - ) + func cleanInstallShowsPHPInstallEntry() { + let content = PluginManagementListContent(plugins: []) + + #expect(content.plugins.isEmpty) + #expect(content.availablePHPManifest?.id == OfficialPluginCatalog.phpPluginID) + } + + @MainActor + @Test + func settingsOKKeepsFailedPluginChangesForRetry() async { + let state = SettingsViewState(initialCategory: .plugins) + let pluginID = OfficialPluginCatalog.phpPluginID + state.pendingPluginEnabledStates[pluginID] = true + + let shouldCloseAfterFailure = await state.applyPluginChanges { changes in + #expect(changes == [pluginID: true]) + return [] + } + #expect(!shouldCloseAfterFailure) + #expect(state.pendingPluginEnabledStates[pluginID] == true) + #expect(!state.isApplyingPluginChanges) - #expect(content.standalonePlugins.isEmpty) - #expect(content.languageExtensions.count == BundledLanguagePluginCatalog.manifests.count) + let shouldCloseAfterRetry = await state.applyPluginChanges { _ in [pluginID] } + #expect(shouldCloseAfterRetry) + #expect(state.pendingPluginEnabledStates.isEmpty) } private func snapshot(_ manifest: PluginManifest) -> PluginManagementSnapshot { diff --git a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift index 4bd1056ff..ee6688916 100644 --- a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift +++ b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -4,7 +4,42 @@ import Testing @testable import Lithe @MainActor +@Suite(.serialized) struct SettingsAppearanceContainerTests { + @Test + func settingsInputRolesUseDistinctIDEAStyleSurfaces() throws { + for (appearanceName, expected) in [ + (NSAppearance.Name.darkAqua, [0x2B2D30, 0x393B40]), + (.aqua, [0xFFFFFF, 0xFFFFFF]) + ] { + let appearance = try #require(NSAppearance(named: appearanceName)) + var colors: [NSColor?] = [] + appearance.performAsCurrentDrawingAppearance { + colors = [LitheTheme.settingsControlBackground, LitheTheme.settingsSelectBackground] + .map { NSColor($0).usingColorSpace(.sRGB) } + } + for (color, hex) in zip(colors, expected) { + let color = try #require(color) + #expect(abs(color.redComponent - CGFloat((hex >> 16) & 0xff) / 255) < 0.005) + #expect(abs(color.greenComponent - CGFloat((hex >> 8) & 0xff) / 255) < 0.005) + #expect(abs(color.blueComponent - CGFloat(hex & 0xff) / 255) < 0.005) + } + } + } + + @Test + func settingsSurfaceMatchesWorkbenchLightAndDarkColors() throws { + for appearanceName in [NSAppearance.Name.darkAqua, .aqua] { + let appearance = try #require(NSAppearance(named: appearanceName)) + let isDark = appearanceName == .darkAqua + let actual = try #require( + LitheTheme.settingsSurfaceNSColor(for: appearance).usingColorSpace(.sRGB) + ) + let expected = try #require(LitheTheme.nsColor(.sidebar, isDark: isDark).usingColorSpace(.sRGB)) + #expect(actual == expected) + } + } + @Test func changingAppearanceKeepsTheContentIdentity() { let recorder = SettingsContentIdentityRecorder() @@ -49,6 +84,49 @@ struct SettingsAppearanceContainerTests { #expect(reopened !== window) } + @Test + func settingsWindowFollowsItsOwningWorkbenchWindow() { + let (firstOwner, _) = makeWindow(theme: .dark) + let (secondOwner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + firstOwner.close() + secondOwner.close() + } + + SettingsWindowChrome.ownerWindow = firstOwner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === firstOwner) + #expect(settingsWindow.level == .normal) + + SettingsWindowChrome.ownerWindow = secondOwner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === secondOwner) + #expect(firstOwner.childWindows?.contains(settingsWindow) != true) + } + + @Test + func applyingPluginChangesBlocksNativeWindowClose() { + let (window, _) = makeWindow(theme: .dark) + defer { window.close() } + + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark, closeEnabled: false) + window.performClose(nil) + #expect(window.isVisible) + + // Reconfiguring ownership or appearance must not reenable closing mid-apply. + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark) + #expect(window.standardWindowButton(.closeButton)?.isEnabled == false) + + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark, closeEnabled: true) + window.performClose(nil) + #expect(!window.isVisible) + } + private func makeWindow(theme: AppThemePreference) -> (NSWindow, NSHostingView) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 240), diff --git a/macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift b/macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift new file mode 100644 index 000000000..e797b1b44 --- /dev/null +++ b/macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift @@ -0,0 +1,146 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@Suite("Settings select popup") +struct SettingsSelectPopupGeometryTests { + @MainActor + @Test + func popupClosesOnItsTriggerAndBlankSpaceButSwitchesToAnotherSelect() async throws { + let host = NSHostingView(rootView: HStack(spacing: 20) { + LitheSettingsSelect( + selection: .constant("First"), options: ["First", "Another"], + width: 180, accessibilityLabel: "First select", title: { $0 } + ) + LitheSettingsSelect( + selection: .constant("Second"), options: ["Second", "Another"], + width: 180, accessibilityLabel: "Second select", title: { $0 } + ) + Spacer() + }.frame(width: 450, height: 120, alignment: .topLeading)) + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 450, height: 120), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + host.layoutSubtreeIfNeeded() + func popup() -> NSPanel? { + NSApp.windows.compactMap { $0 as? NSPanel }.first { + $0.isVisible && NSStringFromClass(type(of: $0)).hasSuffix("LitheSettingsSelectPopupPanel") + } + } + + func click(_ point: NSPoint) throws { + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent( + with: type, location: host.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0 + )) + NSApp.sendEvent(event) + } + } + defer { + if popup() != nil { try? click(NSPoint(x: 420, y: 80)) } + window.orderOut(nil) + window.close() + } + + func waitForPopup(_ condition: @escaping (NSPanel?) -> Bool) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline { + if condition(popup()) { return true } + await Task.yield() + } + return condition(popup()) + } + + try click(NSPoint(x: 80, y: 14)) + #expect(await waitForPopup { $0 != nil }) + let firstFrame = try #require(popup()?.frame) + try click(NSPoint(x: 80, y: 14)) + #expect(await waitForPopup { $0 == nil }, "Clicking the open select must close it") + + try click(NSPoint(x: 80, y: 14)) + #expect(await waitForPopup { $0 != nil }) + try click(NSPoint(x: 280, y: 14)) + #expect(await waitForPopup { ($0?.frame.minX ?? 0) > firstFrame.minX + 100 }, + "The second select must replace the first popup") + + try click(NSPoint(x: 420, y: 80)) + #expect(await waitForPopup { $0 == nil }, "Blank space must close the popup") + } + + @MainActor + @Test + func onlyLeftClickOnOriginalControlDefersDismissal() throws { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 400, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + let anchor = window.convertToScreen(NSRect(x: 20, y: 200, width: 190, height: 28)) + + func click(at point: NSPoint, type: NSEvent.EventType = .leftMouseDown) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: type, location: point, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, + clickCount: 1, pressure: 1 + )) + } + + #expect(LitheSettingsSelectPopupGeometry.isAnchorClick( + try click(at: NSPoint(x: 100, y: 214)), anchorWindow: window, anchorFrame: anchor + )) + #expect(!LitheSettingsSelectPopupGeometry.isAnchorClick( + try click(at: NSPoint(x: 260, y: 214)), anchorWindow: window, anchorFrame: anchor + )) + #expect(!LitheSettingsSelectPopupGeometry.isAnchorClick( + try click(at: NSPoint(x: 300, y: 50)), anchorWindow: window, anchorFrame: anchor + )) + #expect(!LitheSettingsSelectPopupGeometry.isAnchorClick( + try click(at: NSPoint(x: 100, y: 214), type: .rightMouseDown), + anchorWindow: window, anchorFrame: anchor + )) + } + + @Test + func opensBelowTheControlWithoutAnArrowGap() { + let anchor = CGRect(x: 100, y: 500, width: 190, height: 28) + let frame = LitheSettingsSelectPopupGeometry.frame( + anchor: anchor, + size: CGSize(width: 190, height: 90), + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 800) + ) + #expect(frame == CGRect(x: 100, y: 408, width: 190, height: 90)) + } + + @Test + func flipsAboveAndClampsToTheVisibleScreen() { + let visible = CGRect(x: 0, y: 0, width: 1000, height: 800) + let frame = LitheSettingsSelectPopupGeometry.frame( + anchor: CGRect(x: 950, y: 30, width: 190, height: 28), + size: CGSize(width: 190, height: 90), + visibleFrame: visible + ) + #expect(frame == CGRect(x: 804, y: 60, width: 190, height: 90)) + #expect(visible.contains(frame)) + } + + @Test + func shrinksToAvailableSpaceWithoutCoveringTheControl() { + let anchor = CGRect(x: 100, y: 246, width: 190, height: 28) + let frame = LitheSettingsSelectPopupGeometry.frame( + anchor: anchor, + size: CGSize(width: 190, height: 252), + visibleFrame: CGRect(x: 0, y: 0, width: 800, height: 500) + ) + #expect(frame == CGRect(x: 100, y: 6, width: 190, height: 238)) + #expect(!frame.intersects(anchor)) + } +} diff --git a/macos/Tests/LitheTests/SplitHandleViewTests.swift b/macos/Tests/LitheTests/SplitHandleViewTests.swift index fef75c220..7692e2ba9 100644 --- a/macos/Tests/LitheTests/SplitHandleViewTests.swift +++ b/macos/Tests/LitheTests/SplitHandleViewTests.swift @@ -5,6 +5,16 @@ import SwiftUI @Suite("Split handle updates") struct SplitHandleViewTests { + @MainActor + @Test + func settingsPluginDividersHaveUsableTravelAtMinimumWindowWidth() { + let availableTravel = 900 - SettingsView.categoryMinimumWidth + - SplitHandleView.thickness - SettingsView.contentMinimumWidth + #expect(availableTravel >= 150) + #expect(SettingsView.contentMinimumWidth >= PluginManagementView.minimumWidth) + #expect(PluginManagementView.listMinimumWidth < 320) + } + @Test func pendingDragUpdatesCoalesceToNewestTranslation() { var buffer = FrameCoalescedDragUpdateBuffer() diff --git a/macos/Tests/LitheTests/TextContentPolicyTests.swift b/macos/Tests/LitheTests/TextContentPolicyTests.swift new file mode 100644 index 000000000..0da3a5181 --- /dev/null +++ b/macos/Tests/LitheTests/TextContentPolicyTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Shared document text classification") +struct TextContentPolicyTests { + private struct Fixture: Decodable { + struct Case: Decodable { + let name: String + let text: String + let isPlainText: Bool + } + let cases: [Case] + } + + @Test func decodedTextMatchesSharedContract() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/editor/text-content-v1.json"))) + for item in fixture.cases { + #expect(WorkspaceTextFilePolicy.isPlainText(item.text) == item.isPlainText, "\(item.name)") + } + } +} diff --git a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift index 995f51434..230574c14 100644 --- a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift +++ b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift @@ -33,6 +33,18 @@ struct WorkbenchFeatureModelTests { #expect(model.isSettingsPresented) } + @Test + func pluginsOpenInSettingsWhileMavenRemainsVisible() { + let model = WorkbenchFeatureModel() + model.setVisibility(.maven, isVisible: true) + + model.presentSettings(category: .plugins) + + #expect(model.isSettingsPresented) + #expect(model.requestedSettingsCategory == .plugins) + #expect(model.isVisible(.maven)) + } + @Test func mavenNavigationDoesNotReplaceBottomTools() { let model = WorkbenchFeatureModel() diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h index a3f8fcccf..9ed77a993 100644 --- a/rust/lithe-core/include/lithe_core.h +++ b/rust/lithe-core/include/lithe_core.h @@ -2,12 +2,15 @@ #define LITHE_CORE_PUBLIC_H #include +#include #ifdef __cplusplus extern "C" { #endif const char *lithe_core_version(void); +/* Borrowed UTF-8 slice: 1 text, 0 binary controls, -1 invalid input. */ +int32_t lithe_core_is_plain_text(const uint8_t *bytes, size_t length); int32_t lithe_core_git_askpass(const char *prompt); char *lithe_core_execute_json(const char *request); char *lithe_core_execute_json_with_events(const char *request, void (*callback)(const char *, void *), void *context); diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 9437af85e..7f110c886 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -21,10 +21,11 @@ use crate::lsp::languages::java_tests::{java_test_items_command, normalize_java_ use crate::lsp::languages::jdt::{ adapt_initialization_options, adapt_start, import_progress, initialized_notification, is_structured_import_notification, is_virtual_source_uri, jdt_java_runtimes, - maven_profile_fingerprint, maven_profile_update_requests, normalize_location, readiness_signal, - virtual_source_content, virtual_source_resolve_params, waits_for_service_ready, - workspace_configuration, JdtDirectLaunchResources, JdtJavaRuntime, JdtMavenConfiguration, - JdtReadinessSignal, JdtSettings, JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, + maven_profile_fingerprint, maven_profile_update_requests, normalize_location, + project_update_notification, readiness_signal, settings_notification, virtual_source_content, + virtual_source_resolve_params, waits_for_service_ready, workspace_configuration, + JdtDirectLaunchResources, JdtJavaRuntime, JdtMavenConfiguration, JdtReadinessSignal, + JdtSettings, JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, }; use crate::lsp::languages::jdt::{MavenProfileProjectResult, MavenProfileTaskStatus}; use crate::lsp::languages::jdt_build::{ @@ -557,6 +558,14 @@ struct SessionState { maven_profile_queue: VecDeque, maven_profile_deadline: Option, maven_profile_applied_fingerprint: Option, + /// Fingerprint of the configuration the running profile task applies. A + /// configuration replaced while the task runs is applied by a follow-up + /// task instead of being recorded as applied. + maven_profile_running_fingerprint: Option, + /// A newer profile selection waiting for the previous batch to drain. + maven_profile_update_pending: bool, + /// Explicit reload retained until JDT LS has imported its projects. + maven_project_reload_pending: bool, // Request IDs retain their owning task generation until a terminal response // arrives, including responses to advisory cancellation after a timeout. maven_profile_generation: u64, @@ -573,7 +582,16 @@ struct SessionState { struct RuntimeSession { id: String, provider_id: String, - jdt_maven_configuration: Option, + /// Maven import settings JDT LS currently holds. Replaced as a whole when + /// the workspace's Maven configuration changes; never held across another + /// lock. + jdt_maven_configuration: Mutex>>, + /// Inputs for rebuilding `jdt_maven_configuration`; `None` when the session + /// started without a Maven context. + maven_inputs: Option, + /// Serializes configuration preparation and publication without blocking + /// protocol processing on filesystem I/O. Never acquired by protocol workers. + maven_update_order: Mutex<()>, /// JDKs JDT LS may bind projects to, one per execution environment. jdt_java_runtimes: Vec, /// Workspace URI the server was initialized with; scopes workspace-wide @@ -639,15 +657,186 @@ pub fn retry_maven_profiles(request: SessionRequest) -> Result<(), CoreError> { session.retry_maven_profiles() } +/// Replaces the Maven configuration of a running Java session. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMavenConfigurationRequest { + pub session_id: String, + /// The workspace's current Maven context, as a Maven launch would use it. + pub maven_context: crate::project::MavenLaunchContextRequest, + /// Force JDT LS to re-resolve every Maven project even when the settings + /// themselves did not change. Set by the explicit reload action. + #[serde(default)] + pub reload_projects: bool, +} + +/// What an update asked JDT LS to do. Each step runs asynchronously inside +/// JDT LS; resolution problems arrive later as `pom.xml` diagnostics. +#[derive(Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMavenConfigurationResponse { + /// New settings documents were sent; JDT LS re-imports every Maven + /// project because of them. + pub settings_changed: bool, + /// Settings were unchanged, so a forced project update was requested. + pub projects_reloaded: bool, + /// Profile application was started for changed profiles. + pub profiles_updating: bool, +} + +/// Sends a changed Maven configuration to a running Java session. +/// +/// JDT LS receives the change through its own mechanisms instead of a restart: +/// changed settings documents through `workspace/didChangeConfiguration`, +/// which makes it force-update every Maven project, and an explicit reload +/// through `java/projectConfigurationsUpdate`. A restart reuses the workspace +/// state and skips projects whose `pom.xml` did not change, so artifacts that +/// failed to resolve under the old settings would stay missing. +pub fn update_maven_configuration( + request: UpdateMavenConfigurationRequest, +) -> Result { + engine() + .session(&request.session_id)? + .update_maven_configuration(request.maven_context, request.reload_projects) +} + impl RuntimeSession { + /// The Maven import settings JDT LS currently holds. + fn maven_configuration(&self) -> Option> { + self.jdt_maven_configuration + .lock() + .map(|configuration| configuration.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + /// Java settings Lithe owns for this session, as sent to JDT LS. - fn jdt_settings(&self) -> JdtSettings<'_> { + fn jdt_settings<'a>(&'a self, maven: Option<&'a JdtMavenConfiguration>) -> JdtSettings<'a> { JdtSettings { - maven: self.jdt_maven_configuration.as_ref(), + maven, java_runtimes: &self.jdt_java_runtimes, } } + fn update_maven_configuration( + &self, + context: crate::project::MavenLaunchContextRequest, + reload_projects: bool, + ) -> Result { + let Some(inputs) = self + .maven_inputs + .as_ref() + .filter(|_| self.provider_id == "java") + else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Maven configuration updates require a Java language session started for a Maven project.", + )); + }; + let _update_order = self.maven_update_order.lock().map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven configuration update lock failed.", + ) + })?; + // Disk I/O holds only the update serializer, never a protocol/state lock. + let (next, warnings) = inputs.configuration(context)?; + let next = Arc::new(next); + + let outbound_order = self.lock_outbound_order()?; + let (initialized, ready) = { + let state = self.lock_state()?; + if matches!( + state.lifecycle, + LspLifecycleState::Stopping + | LspLifecycleState::Stopped + | LspLifecycleState::Failed + ) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Java language session is no longer running.", + )); + } + // Under `outbound_order`, an initialized client has already sent + // the `initialized` settings notification. Before that point the + // pending notification reads the configuration stored below. + ( + state.client.initialized, + state.lifecycle == LspLifecycleState::Ready, + ) + }; + let previous = { + let mut current = self + .jdt_maven_configuration + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + current.replace(next.clone()) + }; + let settings_changed = previous.as_ref().is_none_or(|previous| { + previous.settings_path != next.settings_path + || previous.global_settings_path != next.global_settings_path + }); + let profiles_changed = previous + .as_ref() + .is_none_or(|previous| previous.profiles != next.profiles); + + { + let mut state = self.lock_state()?; + state.maven_profile_update_pending |= profiles_changed; + state.maven_project_reload_pending |= reload_projects && !ready; + } + let mut messages = Vec::new(); + let mut projects_reloaded = false; + if initialized { + let notification = if settings_changed { + Some(settings_notification(self.jdt_settings(Some(&next)))) + } else if reload_projects && ready { + projects_reloaded = true; + project_update_notification(&next) + } else { + None + }; + if let Some(notification) = notification { + messages.push( + json!({ + "jsonrpc": "2.0", + "method": notification.method, + "params": notification.params + }) + .to_string(), + ); + } + } + // Before readiness, the profile task starts with the stored + // configuration once the import publishes `ServiceReady`. + let mut profiles_updating = false; + if ready && profiles_changed { + let (requests, pending) = self.maven_profile_requests()?; + messages.extend(requests); + profiles_updating = pending; + } + self.send_messages_or_fail(&outbound_order, messages, "mavenConfigurationUpdate")?; + drop(outbound_order); + + for detail in warnings { + self.log( + "warn", + "Maven settings were passed to the Java language service unchanged", + Some(detail), + ); + } + let response = UpdateMavenConfigurationResponse { + settings_changed: initialized && settings_changed, + projects_reloaded, + profiles_updating, + }; + self.log( + "info", + "Java language service received the updated Maven configuration", + Some(json!(response).to_string()), + ); + Ok(response) + } + fn retry_maven_profiles(&self) -> Result<(), CoreError> { let outbound_order = self.lock_outbound_order()?; let state = self.lock_state()?; @@ -851,79 +1040,68 @@ fn engine() -> &'static LspEngine { ENGINE.get_or_init(LspEngine::new) } -/// Bare user-level settings used when Maven Settings overrides the local -/// repository without naming a settings file of its own. -const EMPTY_MAVEN_SETTINGS: &str = r#" - - -"#; - -/// Outcome of preparing the user-level Maven settings JDT LS reads. -#[derive(Debug, Default)] -struct MaterializedMavenSettings { - /// Generated document to send as `userSettings`, when one was produced. - path: Option, - /// Why the local repository override could not be applied, when it could - /// not be. Reported to the session log rather than failing startup. - warning: Option, +/// Inputs a Java session keeps so it can rebuild its Maven import settings +/// when the workspace's Maven configuration changes after startup. +#[derive(Debug)] +struct SessionMavenInputs { + workspace_root: PathBuf, + /// Content-addressed settings copies inside this session's `-data` directory. + settings_directory: Option, + /// Maven's default user settings, used when none are configured. + default_user_settings: Option, } -/// Writes the user-level Maven settings JDT LS reads when Maven Settings -/// overrides the local repository. -/// -/// JDT LS exposes no preference for the repository location, so the override -/// has to travel inside a settings document. Deriving that document from the -/// configured settings file keeps its mirrors, servers, and proxies, and Maven -/// still merges the installation's global settings underneath it. -/// -/// An unreadable configured settings file degrades to no override instead of -/// failing the session. The repository is one optional field, while failing -/// here would leave the workspace without completion, navigation, or -/// diagnostics for every Java file. -fn materialized_maven_settings( - data_root: &Path, - configuration: &crate::project::MavenJdtConfiguration, -) -> Result { - let Some(local_repository) = configuration.local_repository_path.as_deref() else { - return Ok(MaterializedMavenSettings::default()); - }; - let source = match configuration.settings_path.as_deref() { - Some(path) => match std::fs::read_to_string(path) { - Ok(source) => source, - Err(error) => { - return Ok(MaterializedMavenSettings { - path: None, - warning: Some(format!( - "Could not read the configured Maven settings.xml, so the local repository override was not applied: {error}" - )), - }) - } - }, - None => EMPTY_MAVEN_SETTINGS.to_string(), - }; - let document = crate::project::settings_with_local_repository(&source, local_repository)?; - let directory = data_root.join("maven"); - std::fs::create_dir_all(&directory).map_err(|error| { - CoreError::new( - ErrorCode::ProcessStartFailed, - "Could not create the Maven settings directory.", - ) - .with_details(error.to_string()) - })?; - let path = directory.join("settings.xml"); - std::fs::write(&path, document).map_err(|error| { - CoreError::new( - ErrorCode::ProcessStartFailed, - "Could not write the generated Maven settings.", - ) - .with_details(error.to_string()) - })?; - Ok(MaterializedMavenSettings { - path: Some(path.to_string_lossy().into_owned()), - warning: None, - }) +impl SessionMavenInputs { + /// Validates `context` and produces the settings JDT LS receives, plus + /// warnings for documents that degraded to their original paths. + fn configuration( + &self, + context: crate::project::MavenLaunchContextRequest, + ) -> Result<(JdtMavenConfiguration, Vec), CoreError> { + let working_directory = self.workspace_root.to_string_lossy().into_owned(); + let configuration = crate::project::jdt_configuration(&working_directory, context)?; + let project_uris = configuration + .project_paths + .iter() + .map(|path| { + let directory = if path == "." { + self.workspace_root.clone() + } else { + self.workspace_root.join(path) + }; + url::Url::from_directory_path(directory) + .map(|url| url.to_string()) + .map_err(|_| { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven project path cannot be represented as a URI", + ) + }) + }) + .collect::, _>>()?; + let materialized = match &self.settings_directory { + Some(directory) => crate::lsp::languages::jdt_maven_settings::materialize( + directory, + &configuration, + self.default_user_settings.as_deref(), + )?, + None => crate::lsp::languages::jdt_maven_settings::MaterializedMavenSettings { + user_settings_path: configuration.settings_path.clone(), + global_settings_path: configuration.global_settings_path.clone(), + warnings: Vec::new(), + }, + }; + Ok(( + JdtMavenConfiguration { + settings_path: materialized.user_settings_path, + global_settings_path: materialized.global_settings_path, + profiles: configuration.profiles, + project_uris, + source_paths: configuration.source_paths, + }, + materialized.warnings, + )) + } } impl LspEngine { @@ -962,46 +1140,6 @@ impl LspEngine { .as_deref() .map(PathBuf::from) .unwrap_or_else(|| std::env::temp_dir().join("lithe-lsp")); - let mut maven_settings_warning = None; - let jdt_maven_configuration = request - .maven_context - .clone() - .map(|context| { - crate::project::jdt_configuration(&request.working_directory, context).and_then( - |configuration| { - let project_uris = configuration - .project_paths - .iter() - .map(|path| { - let directory = if path == "." { - workspace_root.clone() - } else { - workspace_root.join(path) - }; - url::Url::from_directory_path(directory) - .map(|url| url.to_string()) - .map_err(|_| { - CoreError::new( - ErrorCode::InvalidRequest, - "Maven project path cannot be represented as a URI", - ) - }) - }) - .collect::, _>>()?; - let materialized = materialized_maven_settings(&data_root, &configuration)?; - maven_settings_warning = materialized.warning; - let settings_path = materialized.path.or(configuration.settings_path); - Ok(JdtMavenConfiguration { - settings_path, - global_settings_path: configuration.global_settings_path, - profiles: configuration.profiles, - project_uris, - source_paths: configuration.source_paths, - }) - }, - ) - }) - .transpose()?; let selected_java_executable = request .runtime_executable_path .as_deref() @@ -1083,6 +1221,28 @@ impl LspEngine { .with_details(error.to_string()) })?; } + // Built after the state directory exists and after any reset of it, + // because the settings copies JDT LS reads live inside that directory. + let maven_inputs = request.maven_context.as_ref().map(|_| SessionMavenInputs { + workspace_root: workspace_root.clone(), + settings_directory: adaptation + .data_directory + .as_deref() + .map(crate::lsp::languages::jdt_maven_settings::settings_directory), + default_user_settings: + crate::lsp::languages::jdt_maven_settings::default_user_settings_path( + &request.environment, + ), + }); + let mut maven_settings_warnings = Vec::new(); + let jdt_maven_configuration = match (&maven_inputs, request.maven_context.clone()) { + (Some(inputs), Some(context)) => { + let (configuration, warnings) = inputs.configuration(context)?; + maven_settings_warnings = warnings; + Some(Arc::new(configuration)) + } + _ => None, + }; let process = self.launcher.launch(LspProcessSpec { executable: adaptation @@ -1112,7 +1272,7 @@ impl LspEngine { request.initialization_options, &java_extension_bundle_paths, JdtSettings { - maven: jdt_maven_configuration.as_ref(), + maven: jdt_maven_configuration.as_deref(), java_runtimes: &jdt_java_runtimes, }, ), @@ -1134,7 +1294,9 @@ impl LspEngine { let session = Arc::new(RuntimeSession { id: session_id.clone(), provider_id: request.provider_id, - jdt_maven_configuration, + jdt_maven_configuration: Mutex::new(jdt_maven_configuration), + maven_inputs, + maven_update_order: Mutex::new(()), jdt_java_runtimes, root_uri: request.root_uri, outbound_order: Mutex::new(()), @@ -1173,6 +1335,9 @@ impl LspEngine { maven_profile_queue: VecDeque::new(), maven_profile_deadline: None, maven_profile_applied_fingerprint: None, + maven_profile_running_fingerprint: None, + maven_profile_update_pending: false, + maven_project_reload_pending: false, maven_profile_generation: 0, maven_profile_request_generations: BTreeMap::new(), java_builds: JavaBuildCoordinator::default(), @@ -1209,10 +1374,10 @@ impl LspEngine { ), ); } - if let Some(detail) = maven_settings_warning { + for detail in maven_settings_warnings { session.log( "warn", - "Maven local repository override was not applied", + "Maven settings were passed to the Java language service unchanged", Some(detail), ); } @@ -2592,9 +2757,12 @@ impl RuntimeSession { }; let task_status = state.maven_profile_status; push_maven_profile_task_event(self, &mut state, task_status); + let applied = state.maven_profile_running_fingerprint.take(); + // A newer configuration must run even when this batch failed. + apply_maven_context |= applied + != maven_profile_fingerprint(self.maven_configuration().as_deref()); if state.maven_profile_status == MavenProfileTaskStatus::Succeeded { - state.maven_profile_applied_fingerprint = - maven_profile_fingerprint(self.jdt_maven_configuration.as_ref()); + state.maven_profile_applied_fingerprint = applied; } let profile_log_level = if state.maven_profile_status == MavenProfileTaskStatus::Succeeded { @@ -2947,9 +3115,10 @@ impl RuntimeSession { return Ok(()); } if flush_documents { - if let Some(notification) = - initialized_notification(&self.provider_id, self.jdt_settings()) - { + if let Some(notification) = initialized_notification( + &self.provider_id, + self.jdt_settings(self.maven_configuration().as_deref()), + ) { outbound.push( json!({ "jsonrpc": "2.0", @@ -2982,6 +3151,27 @@ impl RuntimeSession { } return Ok(()); } + if service_ready { + let reload = { + let mut state = self.lock_state()?; + std::mem::take(&mut state.maven_project_reload_pending) + }; + if reload { + if let Some(notification) = self + .maven_configuration() + .as_deref() + .and_then(project_update_notification) + { + outbound.push( + json!({ + "jsonrpc": "2.0", "method": notification.method, + "params": notification.params + }) + .to_string(), + ); + } + } + } if apply_maven_context { let (profile_requests, profiles_pending) = self.maven_profile_requests()?; if profiles_pending { @@ -3049,8 +3239,12 @@ impl RuntimeSession { .map(ToString::to_string), }) .collect(); - let Some(values) = workspace_configuration(&self.provider_id, &items, self.jdt_settings()) - else { + let maven = self.maven_configuration(); + let Some(values) = workspace_configuration( + &self.provider_id, + &items, + self.jdt_settings(maven.as_deref()), + ) else { return Ok(None); }; Ok(Some( @@ -3070,15 +3264,18 @@ impl RuntimeSession { } fn maven_profile_requests(&self) -> Result<(Vec, bool), CoreError> { - let fingerprint = maven_profile_fingerprint(self.jdt_maven_configuration.as_ref()); - let requests = maven_profile_update_requests(self.jdt_maven_configuration.as_ref()); + let maven = self.maven_configuration(); + let fingerprint = maven_profile_fingerprint(maven.as_deref()); + let requests = maven_profile_update_requests(maven.as_deref()); + let mut state = self.lock_state()?; if requests.is_empty() { + state.maven_profile_update_pending = false; return Ok((Vec::new(), false)); } - let mut state = self.lock_state()?; if state.maven_profile_applied_fingerprint.as_ref() == fingerprint.as_ref() && state.maven_profile_status == MavenProfileTaskStatus::Succeeded { + state.maven_profile_update_pending = false; return Ok((Vec::new(), false)); } if state @@ -3094,6 +3291,8 @@ impl RuntimeSession { // absolute safety limit rather than the short request timeout. let deadline = now + state.service_ready_absolute_timeout; state.maven_profile_deadline = Some(deadline); + state.maven_profile_running_fingerprint = fingerprint; + state.maven_profile_update_pending = false; let project_count = requests.len(); const MAX_IN_FLIGHT: usize = 8; let requests = requests; @@ -3383,13 +3582,41 @@ impl RuntimeSession { fn pump_outbound_maintenance(&self) { if !self.lock_state().is_ok_and(|state| { - state.java_builds.has_queued() || !state.deadline_cancellations.is_empty() + state.java_builds.has_queued() + || !state.deadline_cancellations.is_empty() + || (state.maven_profile_update_pending + && state.lifecycle == LspLifecycleState::Ready + && !state + .pending + .values() + .any(|pending| pending.kind == PendingKind::JdtMavenProfiles)) }) { return; } let Ok(outbound_order) = self.outbound_order.try_lock() else { return; }; + // Timed-out requests retain their slots until terminal replies arrive. + // Only then may a newer selection start; never automatically retry the + // same failed configuration. + let update_profiles = self.lock_state().is_ok_and(|state| { + state.maven_profile_update_pending && state.lifecycle == LspLifecycleState::Ready + }); + let profile_messages = if update_profiles { + match self.maven_profile_requests() { + Ok((messages, _)) => messages, + Err(error) => { + self.log( + "warn", + "Could not start the pending Maven profile update", + Some(error.message), + ); + return; + } + } + } else { + Vec::new() + }; let Ok(mut state) = self.lock_state() else { return; }; @@ -3400,6 +3627,7 @@ impl RuntimeSession { .to_string() }) .collect(); + messages.extend(profile_messages); messages.extend(self.dispatch_java_build_locked(&mut state, Instant::now())); if messages.is_empty() { return; @@ -4832,6 +5060,8 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.deadline_cancellations.clear(); state.maintenance_write_deadline = None; state.pending_workspace_file_changes.clear(); + state.maven_profile_update_pending = false; + state.maven_project_reload_pending = false; state.initialize_deadline = None; state.shutdown_deadline = None; push_features_event(session, state, Vec::new()); @@ -4893,123 +5123,6 @@ mod real_jdt_tests; mod tests { use super::super::scripted::ScriptedServer; use super::*; - - /// Builds an isolated data root for tests that materialize Maven settings. - fn maven_settings_root(label: &str) -> PathBuf { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be valid") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "lithe-maven-{label}-{}-{nonce}", - std::process::id() - )); - std::fs::create_dir_all(&root).expect("data root should be creatable"); - root - } - - fn maven_jdt_configuration( - settings_path: Option, - local_repository_path: Option, - ) -> crate::project::MavenJdtConfiguration { - crate::project::MavenJdtConfiguration { - profiles: Vec::new(), - settings_path, - global_settings_path: None, - local_repository_path, - project_paths: Vec::new(), - source_paths: Vec::new(), - } - } - - #[test] - fn a_repository_override_generates_user_settings_that_keep_configured_mirrors() { - // JDT LS has no repository preference, so the override travels inside a - // settings document. Regenerating that document must not drop the - // mirrors the workspace already resolves through. - let root = maven_settings_root("repository-override"); - let configured = root.join("configured-settings.xml"); - std::fs::write( - &configured, - r#"/oldaliyunmaven"#, - ) - .expect("configured settings should be writable"); - let configuration = maven_jdt_configuration( - Some(configured.to_string_lossy().into_owned()), - Some("/opt/repository".to_string()), - ); - - let materialized = materialized_maven_settings(&root, &configuration) - .expect("settings should be materialized"); - let generated = materialized - .path - .expect("an override must produce a settings document"); - assert_eq!(materialized.warning, None); - - let document = std::fs::read_to_string(&generated).expect("generated file should exist"); - assert!(document.contains("/opt/repository")); - assert!(document.contains("aliyunmaven")); - assert!(!document.contains("/old")); - // The configured file stays untouched; only the generated copy changes. - let original = std::fs::read_to_string(&configured).expect("source should still exist"); - assert!(original.contains("/old")); - std::fs::remove_dir_all(&root).expect("data root should be removable"); - } - - #[test] - fn a_repository_override_without_configured_settings_generates_a_minimal_document() { - let root = maven_settings_root("repository-only"); - let configuration = maven_jdt_configuration(None, Some("/opt/repository".to_string())); - - let generated = materialized_maven_settings(&root, &configuration) - .expect("settings should be materialized") - .path - .expect("an override must produce a settings document"); - - let document = std::fs::read_to_string(generated).expect("generated file should exist"); - assert!(document.contains("/opt/repository")); - std::fs::remove_dir_all(&root).expect("data root should be removable"); - } - - #[test] - fn no_repository_override_leaves_the_configured_settings_in_place() { - let root = maven_settings_root("no-override"); - let configuration = maven_jdt_configuration(Some("/local/settings.xml".to_string()), None); - - let materialized = - materialized_maven_settings(&root, &configuration).expect("settings should resolve"); - assert_eq!(materialized.path, None); - assert_eq!(materialized.warning, None); - std::fs::remove_dir_all(&root).expect("data root should be removable"); - } - - #[test] - fn an_unreadable_settings_file_warns_instead_of_failing_the_java_session() { - // Losing the repository override costs one optional setting. Failing - // here would leave every Java file without completion, navigation, and - // diagnostics because of a stale path in Maven Settings. - let root = maven_settings_root("unreadable-settings"); - let configuration = maven_jdt_configuration( - Some( - root.join("deleted-settings.xml") - .to_string_lossy() - .into_owned(), - ), - Some("/opt/repository".to_string()), - ); - - let materialized = materialized_maven_settings(&root, &configuration) - .expect("an unreadable settings file must not fail startup"); - - // The configured path is kept, which is what JDT LS received before the - // override existed. - assert_eq!(materialized.path, None); - assert!(materialized - .warning - .is_some_and(|warning| warning.contains("local repository override"))); - std::fs::remove_dir_all(&root).expect("data root should be removable"); - } - /// The capabilities every test needs to reach `Ready` with a usable feature /// surface. Individual tests narrow or extend this. fn ready_capabilities() -> Value { @@ -5127,6 +5240,29 @@ mod tests { } } + impl TemporaryMavenWorkspace { + /// The fixture's Maven context with a real settings file and the given + /// local repository override. + fn context_with_settings( + &self, + settings: &str, + local_repository: Option<&str>, + ) -> crate::project::MavenLaunchContextRequest { + let settings_path = self.root.join("settings.xml"); + std::fs::write(&settings_path, settings).expect("settings fixture should be writable"); + crate::project::MavenLaunchContextRequest { + version: 1, + reactor_path: "reactor".to_string(), + profiles: vec!["dev".to_string()], + settings_path: Some(settings_path.to_string_lossy().into_owned()), + local_repository_path: local_repository.map(ToString::to_string), + skip_tests: true, + maven_executable_path: None, + java_home_path: None, + } + } + } + impl Drop for TemporaryMavenWorkspace { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); @@ -5663,6 +5799,411 @@ mod tests { assert_eq!(harness.snapshot().state, LspLifecycleState::Ready); } + /// Messages the scripted server received for `method`, in order. + fn notifications(harness: &Harness, method: &str) -> Vec { + harness + .server + .messages() + .into_iter() + .filter(|message| message.get("method").and_then(Value::as_str) == Some(method)) + .collect() + } + + fn user_settings(notification: &Value) -> String { + notification["params"]["settings"]["java"]["configuration"]["maven"]["userSettings"] + .as_str() + .expect("userSettings should be a path") + .to_string() + } + + #[test] + fn a_maven_settings_change_reaches_the_running_java_session() { + // Regression for #970: saving Maven settings only marked a reload, and + // a restart reused the workspace state without re-resolving. The change + // must reach the live session as a new settings path, which is what + // makes JDT LS force-update every Maven project. + let workspace = TemporaryMavenWorkspace::recursive("maven-update-settings"); + let initial = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(initial.clone()); + }); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let before = notifications(&harness, "workspace/didChangeConfiguration"); + assert_eq!(before.len(), 1); + let profile_updates = notifications(&harness, "workspace/executeCommand").len(); + + let mut changed = initial.clone(); + changed.local_repository_path = Some("/fixture/repository".to_string()); + let response = harness + .session() + .update_maven_configuration(changed, false) + .expect("a running Java session should accept the update"); + + assert_eq!( + response, + UpdateMavenConfigurationResponse { + settings_changed: true, + projects_reloaded: false, + profiles_updating: false, + } + ); + let after = notifications(&harness, "workspace/didChangeConfiguration"); + assert_eq!(after.len(), 2); + let (old_path, new_path) = (user_settings(&before[0]), user_settings(&after[1])); + assert_ne!( + old_path, new_path, + "a changed document must arrive under a new path" + ); + let document = std::fs::read_to_string(&new_path).expect("the new copy should exist"); + assert!(document.contains("/fixture/repository")); + assert_eq!( + notifications(&harness, "workspace/executeCommand").len(), + profile_updates, + "unchanged profiles must not restart the profile task" + ); + assert!(notifications(&harness, "java/projectConfigurationsUpdate").is_empty()); + } + + #[test] + fn an_explicit_maven_reload_forces_a_project_update_when_settings_are_unchanged() { + // JDT LS skips re-importing projects whose pom.xml did not change, so + // reloading must use its forced "update project" notification. + let workspace = TemporaryMavenWorkspace::recursive("maven-update-reload"); + let context = workspace.context_with_settings("", Some("/fixture/repository")); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(context.clone()); + }); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + + let quiet = harness + .session() + .update_maven_configuration(context.clone(), false) + .expect("an unchanged update should be accepted"); + let reload = harness + .session() + .update_maven_configuration(context, true) + .expect("a reload should be accepted"); + + assert!(!quiet.settings_changed && !quiet.projects_reloaded); + assert!(!reload.settings_changed && reload.projects_reloaded); + assert_eq!( + notifications(&harness, "workspace/didChangeConfiguration").len(), + 1 + ); + let updates = notifications(&harness, "java/projectConfigurationsUpdate"); + assert_eq!( + updates.len(), + 1, + "only the explicit reload forces an update" + ); + let identifiers = updates[0]["params"]["identifiers"] + .as_array() + .expect("identifiers should be an array"); + let suffixes = ["reactor/", "reactor/module-a/", "reactor/module-a/nested/"]; + assert_eq!(identifiers.len(), suffixes.len()); + for (identifier, suffix) in identifiers.iter().zip(suffixes) { + let uri = identifier["uri"].as_str().expect("identifier URI"); + assert!(uri.ends_with(suffix), "unexpected project URI {uri}"); + } + } + + #[test] + fn a_maven_update_before_initialized_is_sent_with_the_initial_settings() { + // Before the handshake completes JDT LS cannot take a configuration + // change; the pending post-initialize notification carries it instead. + let workspace = TemporaryMavenWorkspace::recursive("maven-update-early"); + let initial = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(initial.clone()); + }); + let mut changed = initial; + changed.local_repository_path = Some("/fixture/early".to_string()); + + let response = harness + .session() + .update_maven_configuration(changed, true) + .expect("an initializing session should accept the update"); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + + assert!(!response.settings_changed && !response.projects_reloaded); + let sent = notifications(&harness, "workspace/didChangeConfiguration"); + assert_eq!(sent.len(), 1); + let document = + std::fs::read_to_string(user_settings(&sent[0])).expect("the copy should exist"); + assert!(document.contains("/fixture/early")); + assert_eq!( + notifications(&harness, "java/projectConfigurationsUpdate").len(), + 1 + ); + } + + #[test] + fn unchanged_settings_reload_before_initialize_waits_for_service_ready() { + let workspace = TemporaryMavenWorkspace::recursive("maven-reload-before-ready"); + let context = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(context.clone()); + }); + let session = harness.session(); + session + .update_maven_configuration(context.clone(), true) + .unwrap(); + harness.server.complete_initialize(ready_capabilities()); + assert!(harness + .server + .await_notification("workspace/didChangeConfiguration")); + // Reloads coalesce, including another request during project import. + session.update_maven_configuration(context, true).unwrap(); + assert!(notifications(&harness, "java/projectConfigurationsUpdate").is_empty()); + session + .handle_server_message( + json!({ + "jsonrpc": "2.0", "method": "language/status", + "params": { "type": "ServiceReady" } + }) + .to_string(), + ) + .unwrap(); + harness.await_state(LspLifecycleState::Ready); + assert_eq!( + notifications(&harness, "java/projectConfigurationsUpdate").len(), + 1 + ); + session + .handle_server_message( + json!({ + "jsonrpc": "2.0", "method": "language/status", + "params": { "type": "ServiceReady" } + }) + .to_string(), + ) + .unwrap(); + assert_eq!( + notifications(&harness, "java/projectConfigurationsUpdate").len(), + 1 + ); + } + + #[test] + fn changed_maven_profiles_restart_the_profile_task_on_a_ready_session() { + let workspace = TemporaryMavenWorkspace::recursive("maven-update-profiles"); + let initial = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(initial.clone()); + }); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + for index in 0..3 { + let id = harness + .server + .await_request_at("workspace/executeCommand", index) + .expect("the initial profile task should update every project"); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": id, "result": null })); + } + harness.await_event(|event| { + event.maven_profile_task == Some(MavenProfileTaskStatus::Succeeded) + }); + + let mut changed = initial; + changed.profiles = vec!["prod".to_string()]; + let response = harness + .session() + .update_maven_configuration(changed, false) + .expect("a profile change should be accepted"); + + assert!(response.profiles_updating); + assert!(!response.settings_changed); + let updates = notifications(&harness, "workspace/executeCommand"); + assert_eq!(updates.len(), 6); + assert_eq!( + updates[5]["params"]["arguments"][1]["org.eclipse.m2e.core.selectedProfiles"], + "prod" + ); + } + + #[test] + fn a_profile_change_during_a_running_profile_task_is_applied_afterwards() { + // The running task applies the old profiles. Recording the new + // configuration as applied when it finishes would silently drop the + // user's change, so a follow-up task must apply it. + let workspace = TemporaryMavenWorkspace::recursive("maven-update-profiles-running"); + let initial = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(initial.clone()); + }); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let first_batch: Vec<_> = (0..3) + .map(|index| { + harness + .server + .await_request_at("workspace/executeCommand", index) + .expect("the initial profile task should update every project") + }) + .collect(); + + let mut changed = initial; + changed.profiles = vec!["prod".to_string()]; + let response = harness + .session() + .update_maven_configuration(changed, false) + .expect("a profile change should be accepted while the task runs"); + assert!(response.profiles_updating); + assert_eq!(notifications(&harness, "workspace/executeCommand").len(), 3); + + for id in first_batch { + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": id, "result": null })); + } + let follow_up = harness + .server + .await_request_at("workspace/executeCommand", 5) + .expect("the changed profiles should be applied by a follow-up task"); + let request = harness + .server + .messages() + .into_iter() + .find(|message| message["id"] == follow_up) + .expect("the follow-up request should be recorded"); + assert_eq!( + request["params"]["arguments"][1]["org.eclipse.m2e.core.selectedProfiles"], + "prod" + ); + } + + #[test] + fn a_new_profile_selection_runs_after_a_failed_batch() { + profile_selection_after_unsuccessful_batch(false); + } + + #[test] + fn a_new_profile_selection_waits_for_timed_out_requests_to_drain() { + profile_selection_after_unsuccessful_batch(true); + } + + fn profile_selection_after_unsuccessful_batch(timeout: bool) { + let workspace = TemporaryMavenWorkspace::recursive("maven-profile-failure-followup"); + let context = workspace.context_with_settings("", None); + let mut harness = Harness::start(|request| { + workspace.configure(request); + request.maven_context = Some(context.clone()); + }); + harness + .server + .complete_java_initialize(ready_capabilities()); + harness.await_state(LspLifecycleState::Ready); + let session = harness.session(); + let first_batch: Vec<_> = (0..3) + .map(|index| { + harness + .server + .await_request_at("workspace/executeCommand", index) + .unwrap() + }) + .collect(); + let mut next = context; + next.profiles = vec!["prod".to_string()]; + assert!( + session + .update_maven_configuration(next, false) + .unwrap() + .profiles_updating + ); + if timeout { + // Advance the owned deadlines directly; no wall-clock wait or overlap. + { + let mut state = session.lock_state().unwrap(); + for pending in state.pending.values_mut() { + if pending.kind == PendingKind::JdtMavenProfiles { + pending.deadline = Instant::now(); + } + } + } + session.expire_deadlines(); + } + for (index, id) in first_batch.into_iter().enumerate() { + session + .handle_server_message( + json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32603, "message": "old profile failed" } + }) + .to_string(), + ) + .unwrap(); + session.pump_outbound_maintenance(); + if index < 2 { + assert_eq!(notifications(&harness, "workspace/executeCommand").len(), 3); + } + } + let second_batch: Vec<_> = (3..6) + .map(|index| { + harness + .server + .await_request_at("workspace/executeCommand", index) + .unwrap() + }) + .collect(); + for request in notifications(&harness, "workspace/executeCommand") + .iter() + .skip(3) + { + assert_eq!( + request["params"]["arguments"][1]["org.eclipse.m2e.core.selectedProfiles"], + "prod" + ); + } + // A failure of the latest selection is terminal, not an infinite retry loop. + for id in second_batch { + session + .handle_server_message( + json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32603, "message": "new profile failed" } + }) + .to_string(), + ) + .unwrap(); + } + session.pump_outbound_maintenance(); + assert_eq!(notifications(&harness, "workspace/executeCommand").len(), 6); + } + + #[test] + fn a_session_without_a_maven_context_rejects_maven_updates() { + let workspace = TemporaryMavenWorkspace::recursive("maven-update-reject"); + let harness = Harness::ready(); + + let error = harness + .session() + .update_maven_configuration(workspace.context_with_settings("", None), true) + .expect_err("a session without Maven import settings cannot be updated"); + + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + #[test] fn maven_profile_log_redacts_absolute_project_paths() { let redacted = redacted_project_uri("file:///Users/alice/workspace/module-a/"); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index c56cd1739..087353347 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -20,6 +20,8 @@ const JDT_URI_SCHEME: &str = "jdt"; const JDTLS_DATA_DIRECTORY: &str = "jdtls"; const JAVA_DECOMPILE_COMMAND: &str = "java.decompile"; const DID_CHANGE_CONFIGURATION_METHOD: &str = "workspace/didChangeConfiguration"; +/// JDT LS extension that force-updates the listed projects' build configuration. +const PROJECT_CONFIGURATIONS_UPDATE_METHOD: &str = "java/projectConfigurationsUpdate"; const LANGUAGE_STATUS_METHOD: &str = "language/status"; const WORK_DONE_PROGRESS_METHOD: &str = "$/progress"; const SERVICE_READY_STATUS: &str = "ServiceReady"; @@ -460,11 +462,43 @@ pub(crate) fn initialized_notification( provider_id: &str, settings: JdtSettings<'_>, ) -> Option { - is_java_provider(provider_id).then(|| ProviderNotification { + is_java_provider(provider_id).then(|| settings_notification(settings)) +} + +/// `workspace/didChangeConfiguration` carrying every Java setting Lithe owns. +/// +/// When the Maven settings paths differ from the ones JDT LS holds, JDT LS +/// reloads them and force-updates every Maven project itself +/// (`StandardPreferenceManager.update`), including an import still in progress. +pub(crate) fn settings_notification(settings: JdtSettings<'_>) -> ProviderNotification { + ProviderNotification { method: DID_CHANGE_CONFIGURATION_METHOD.to_string(), params: json!({ "settings": java_settings(settings) }), + } +} + +/// Forces JDT LS to re-resolve the given Maven projects even though neither +/// their `pom.xml` nor the Maven settings changed. +/// +/// A JDT LS restart does not do this: its importer skips projects whose build +/// file is older than the saved workspace state, so dependencies that failed to +/// resolve stay missing. `java/projectConfigurationsUpdate` is the upstream +/// "update project" action and ignores that check. +pub(crate) fn project_update_notification( + configuration: &JdtMavenConfiguration, +) -> Option { + let mut seen = std::collections::BTreeSet::new(); + let identifiers = configuration + .project_uris + .iter() + .filter(|uri| seen.insert(uri.as_str())) + .map(|uri| json!({ "uri": uri })) + .collect::>(); + (!identifiers.is_empty()).then(|| ProviderNotification { + method: PROJECT_CONFIGURATIONS_UPDATE_METHOD.to_string(), + params: json!({ "identifiers": identifiers }), }) } diff --git a/rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs b/rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs new file mode 100644 index 000000000..269ea0cbb --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs @@ -0,0 +1,556 @@ +//! Content-addressed copies of the Maven settings documents JDT LS reads. +//! +//! JDT LS decides whether Maven settings changed by comparing the settings +//! *paths* it receives (`StandardPreferenceManager.update`). Only a changed path +//! makes it reload the settings and force-update every Maven project. A file +//! edited in place, or a generated file rewritten under a fixed name, therefore +//! leaves the running import on the old mirrors and local repository. +//! +//! Every settings document Lithe hands to JDT LS is copied into the session's +//! `-data` directory under a name derived from its contents. A content change is +//! then always a path change, which lets the upstream change detection do the +//! reload instead of Lithe re-implementing it. The copies live and die with the +//! JDT LS workspace state that uses them, and never touch the user's files or +//! the installed product. + +use crate::project::MavenJdtConfiguration; +use crate::protocol::{CoreError, ErrorCode}; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Directory inside the JDT LS `-data` directory that holds the copies. +const SETTINGS_DIRECTORY: &[&str] = &[".lithe", "maven"]; +/// File-name prefix of the user-level copy (`java.configuration.maven.userSettings`). +const USER_SETTINGS_PREFIX: &str = "user-settings-"; +/// File-name prefix of the installation-level copy (`java.configuration.maven.globalSettings`). +const GLOBAL_SETTINGS_PREFIX: &str = "global-settings-"; +/// Hex digits of the content digest kept in a file name. +const DIGEST_LENGTH: usize = 16; + +/// Bare user-level settings used when Maven Settings overrides the local +/// repository without naming a settings file of its own. +const EMPTY_MAVEN_SETTINGS: &str = r#" + + +"#; + +/// Settings paths to send to JDT LS for one Maven configuration. +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) struct MaterializedMavenSettings { + /// Path sent as `userSettings`, or `None` to leave JDT LS on Maven's default. + pub user_settings_path: Option, + /// Path sent as `globalSettings`, or `None` when no installation is known. + pub global_settings_path: Option, + /// Problems that degraded a document to its original path. They are + /// reported to the session log rather than failing startup, because a + /// stale settings path must not cost every Java file its language service. + pub warnings: Vec, +} + +/// Directory holding the settings copies for one JDT LS `-data` directory. +pub(crate) fn settings_directory(data_directory: &Path) -> PathBuf { + SETTINGS_DIRECTORY + .iter() + .fold(data_directory.to_path_buf(), |path, part| path.join(part)) +} + +/// Maven's default user-level settings file, `${user.home}/.m2/settings.xml`. +/// +/// Maven reads it whenever no settings file is configured, so JDT LS must see +/// the same document through a content-addressed copy. The home directory +/// comes from the language server's launch environment first, because that is +/// the environment Maven inside JDT LS observes. +pub(crate) fn default_user_settings_path( + environment: &std::collections::BTreeMap, +) -> Option { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let home = environment + .get(variable) + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os(variable).map(PathBuf::from))?; + Some(home.join(".m2").join("settings.xml")) +} + +/// Writes the content-addressed settings copies for `configuration` into +/// `directory`. Copies remain valid until the owning JDT workspace is removed: +/// sending a notification does not acknowledge that JDT LS has read the file. +/// +/// The user-level source is the configured settings file, else Maven's default +/// user settings when they exist, else an empty document when only a local +/// repository override is configured. JDT LS has no repository preference, so +/// that override is written into the user-level copy. +pub(crate) fn materialize( + directory: &Path, + configuration: &MavenJdtConfiguration, + default_user_settings: Option<&Path>, +) -> Result { + let mut warnings = Vec::new(); + + let user_source = match configuration.settings_path.as_deref() { + Some(path) => Some(SettingsSource::Configured(path.to_string())), + None => default_user_settings + .filter(|path| path.is_file()) + .map(|path| SettingsSource::Configured(path.to_string_lossy().into_owned())) + .or_else(|| { + configuration + .local_repository_path + .as_ref() + .map(|_| SettingsSource::Empty) + }), + }; + let user_settings_path = match user_source { + None => None, + Some(source) => match source.read() { + Ok(document) => { + let document = match configuration.local_repository_path.as_deref() { + Some(repository) => { + crate::project::settings_with_local_repository(&document, repository)? + } + None => document, + }; + let path = write_copy(directory, USER_SETTINGS_PREFIX, &document)?; + Some(path.to_string_lossy().into_owned()) + } + Err((path, error)) => { + warnings.push(format!( + "Could not read the Maven settings file {path}, so the Java language service reads it directly and ignores the local repository override: {error}" + )); + Some(path) + } + }, + }; + + let global_settings_path = match configuration.global_settings_path.as_deref() { + None => None, + Some(path) => match std::fs::read_to_string(path) { + Ok(document) => { + let copy = write_copy(directory, GLOBAL_SETTINGS_PREFIX, &document)?; + Some(copy.to_string_lossy().into_owned()) + } + Err(error) => { + warnings.push(format!( + "Could not read the Maven installation settings file {path}, so the Java language service reads it directly: {error}" + )); + Some(path.to_string()) + } + }, + }; + + Ok(MaterializedMavenSettings { + user_settings_path, + global_settings_path, + warnings, + }) +} + +/// Where the user-level document comes from. +enum SettingsSource { + /// A settings file on disk, configured or Maven's default. + Configured(String), + /// No file exists, but a local repository override needs a document. + Empty, +} + +impl SettingsSource { + fn read(&self) -> Result { + match self { + Self::Configured(path) => { + std::fs::read_to_string(path).map_err(|error| (path.clone(), error)) + } + Self::Empty => Ok(EMPTY_MAVEN_SETTINGS.to_string()), + } + } +} + +/// Writes `document` under a name derived from its contents. +/// +/// An existing file with that name already holds the same bytes, so it is +/// reused. New files are written beside the target and renamed into place so +/// JDT LS never reads a partially written document. +fn write_copy(directory: &Path, prefix: &str, document: &str) -> Result { + let digest = Sha256::digest(document.as_bytes()); + let name = format!("{prefix}{}.xml", &hex(&digest)[..DIGEST_LENGTH]); + let target = directory.join(name); + if target.is_file() { + return Ok(target); + } + std::fs::create_dir_all(directory).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not create the Maven settings directory.", + ) + .with_details(error.to_string()) + })?; + static NEXT_COPY: AtomicU64 = AtomicU64::new(0); + // Exclusive creation also avoids truncating a stale staging file after PID reuse. + let (staging, mut file) = loop { + let nonce = NEXT_COPY.fetch_add(1, Ordering::Relaxed); + let path = directory.join(format!(".{prefix}{}-{nonce}.tmp", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(file) => break (path, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not create the generated Maven settings.", + ) + .with_details(error.to_string())) + } + } + }; + file.write_all(document.as_bytes()) + .and_then(|()| { + drop(file); + std::fs::rename(&staging, &target).or_else(|error| { + // Windows can reject replacing a copy another writer just published. + // Only accept that result when the full content matches. + if std::fs::read(&target).is_ok_and(|bytes| bytes == document.as_bytes()) { + std::fs::remove_file(&staging) + } else { + Err(error) + } + }) + }) + .map_err(|error| { + let _ = std::fs::remove_file(&staging); + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not write the generated Maven settings.", + ) + .with_details(error.to_string()) + })?; + Ok(target) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + value.push(DIGITS[(byte >> 4) as usize] as char); + value.push(DIGITS[(byte & 0x0f) as usize] as char); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Temporary directory removed when the test ends, pass or fail. + struct Scratch(PathBuf); + + impl Scratch { + fn new(label: &str) -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-maven-settings-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("scratch directory should be creatable"); + Self(root) + } + + fn write(&self, name: &str, contents: &str) -> String { + let path = self.0.join(name); + std::fs::write(&path, contents).expect("fixture should be writable"); + path.to_string_lossy().into_owned() + } + + fn copies(&self) -> PathBuf { + settings_directory(&self.0.join("data")) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn configuration( + settings_path: Option, + global_settings_path: Option, + local_repository_path: Option<&str>, + ) -> MavenJdtConfiguration { + MavenJdtConfiguration { + profiles: Vec::new(), + settings_path, + global_settings_path, + local_repository_path: local_repository_path.map(ToString::to_string), + project_paths: Vec::new(), + source_paths: Vec::new(), + } + } + + fn read(path: &Option) -> String { + std::fs::read_to_string(path.as_deref().expect("a copy should be produced")) + .expect("copy should exist") + } + + #[test] + fn a_repository_override_keeps_the_configured_mirrors() { + // JDT LS has no repository preference, so the override travels inside a + // settings document. The copy must not drop the mirrors the workspace + // already resolves through. + let scratch = Scratch::new("repository-override"); + let configured = scratch.write( + "settings.xml", + r#"/oldaliyunmaven"#, + ); + + let materialized = materialize( + &scratch.copies(), + &configuration(Some(configured.clone()), None, Some("/opt/repository")), + None, + ) + .expect("settings should be materialized"); + + let document = read(&materialized.user_settings_path); + assert!(document.contains("/opt/repository")); + assert!(document.contains("aliyunmaven")); + assert!(!document.contains("/old")); + assert!(materialized.warnings.is_empty()); + let original = std::fs::read_to_string(configured).expect("source should still exist"); + assert!(original.contains("/old")); + } + + #[test] + fn a_repository_override_without_settings_uses_a_minimal_document() { + let scratch = Scratch::new("repository-only"); + + let materialized = materialize( + &scratch.copies(), + &configuration(None, None, Some("/opt/repository")), + None, + ) + .expect("settings should be materialized"); + + assert!(read(&materialized.user_settings_path) + .contains("/opt/repository")); + } + + #[test] + fn configured_settings_without_an_override_are_still_copied() { + // Passing the user's own path would let an in-place edit go unnoticed: + // JDT LS compares paths, so the copy is what makes the edit visible. + let scratch = Scratch::new("no-override"); + let configured = scratch.write("settings.xml", ""); + + let materialized = materialize( + &scratch.copies(), + &configuration(Some(configured.clone()), None, None), + None, + ) + .expect("settings should be materialized"); + + let copy = materialized.user_settings_path.clone().expect("copy"); + assert_ne!(copy, configured); + assert!(Path::new(©).starts_with(scratch.copies())); + assert_eq!( + read(&materialized.user_settings_path), + "" + ); + } + + #[test] + fn editing_a_settings_file_changes_the_path_jdt_ls_receives() { + // Regression for #970: a generated file under a fixed name made every + // later settings change invisible to JDT LS's path comparison. + let scratch = Scratch::new("content-change"); + let configured = scratch.write("settings.xml", ""); + let first = materialize( + &scratch.copies(), + &configuration(Some(configured.clone()), None, Some("/first")), + None, + ) + .expect("first materialization"); + let unchanged = materialize( + &scratch.copies(), + &configuration(Some(configured.clone()), None, Some("/first")), + None, + ) + .expect("repeated materialization"); + let repository_changed = materialize( + &scratch.copies(), + &configuration(Some(configured.clone()), None, Some("/second")), + None, + ) + .expect("repository change"); + scratch.write( + "settings.xml", + "", + ); + let file_edited = materialize( + &scratch.copies(), + &configuration(Some(configured), None, Some("/second")), + None, + ) + .expect("file edit"); + + assert_eq!(first.user_settings_path, unchanged.user_settings_path); + assert_ne!( + first.user_settings_path, + repository_changed.user_settings_path + ); + assert_ne!( + repository_changed.user_settings_path, + file_edited.user_settings_path + ); + } + + #[test] + fn installation_settings_are_copied_by_content() { + let scratch = Scratch::new("global"); + let global = scratch.write("global.xml", ""); + + let first = materialize( + &scratch.copies(), + &configuration(None, Some(global.clone()), None), + None, + ) + .expect("global settings"); + assert_eq!( + read(&first.global_settings_path), + "" + ); + assert_eq!(first.user_settings_path, None); + scratch.write("global.xml", ""); + let edited = materialize( + &scratch.copies(), + &configuration(None, Some(global), None), + None, + ) + .expect("edited global settings"); + + assert_ne!(first.global_settings_path, edited.global_settings_path); + assert_eq!( + read(&edited.global_settings_path), + "" + ); + } + + #[test] + fn maven_default_user_settings_are_used_when_none_are_configured() { + let scratch = Scratch::new("default-user"); + let default = + PathBuf::from(scratch.write("default.xml", "")); + + let materialized = materialize( + &scratch.copies(), + &configuration(None, None, None), + Some(&default), + ) + .expect("default settings"); + assert_eq!( + read(&materialized.user_settings_path), + "" + ); + let absent = materialize( + &scratch.copies(), + &configuration(None, None, None), + Some(&scratch.0.join("missing.xml")), + ) + .expect("absent default settings"); + + // Without a document JDT LS keeps Maven's own default, as the CLI does. + assert_eq!(absent.user_settings_path, None); + } + + #[test] + fn copies_remain_readable_until_the_workspace_is_removed() { + let scratch = Scratch::new("prune"); + let first = materialize( + &scratch.copies(), + &configuration(None, None, Some("/first")), + None, + ) + .expect("first"); + let second = materialize( + &scratch.copies(), + &configuration(None, None, Some("/second")), + None, + ) + .expect("second"); + + assert!(read(&first.user_settings_path).contains("/first")); + assert!(Path::new(second.user_settings_path.as_deref().unwrap()).exists()); + } + + #[test] + fn an_unowned_staging_file_is_never_truncated_or_removed() { + let scratch = Scratch::new("staging-owner"); + let copies = scratch.copies(); + std::fs::create_dir_all(&copies).unwrap(); + // The old PID-only name could belong to another update in this process. + let other = copies.join(format!(".{USER_SETTINGS_PREFIX}{}.tmp", std::process::id())); + std::fs::write(&other, "another writer's settings").unwrap(); + let result = materialize(&copies, &configuration(None, None, Some("/new")), None).unwrap(); + assert!(read(&result.user_settings_path).contains("/new")); + assert_eq!( + std::fs::read_to_string(other).unwrap(), + "another writer's settings" + ); + } + + #[test] + fn an_unreadable_settings_file_warns_instead_of_failing_the_java_session() { + // Losing the repository override costs one optional setting. Failing + // here would leave every Java file without completion, navigation, and + // diagnostics because of a stale path in Maven Settings. + let scratch = Scratch::new("unreadable"); + let missing = scratch.0.join("deleted.xml").to_string_lossy().into_owned(); + + let materialized = materialize( + &scratch.copies(), + &configuration( + Some(missing.clone()), + Some(missing.clone()), + Some("/opt/repository"), + ), + None, + ) + .expect("an unreadable settings file must not fail startup"); + + assert_eq!( + materialized.user_settings_path.as_deref(), + Some(missing.as_str()) + ); + assert_eq!( + materialized.global_settings_path.as_deref(), + Some(missing.as_str()) + ); + assert_eq!(materialized.warnings.len(), 2); + assert!(materialized.warnings[0].contains("local repository override")); + } + + #[test] + fn the_default_user_settings_follow_the_launch_environment() { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let environment = + std::collections::BTreeMap::from([(variable.to_string(), "/fixture/home".to_string())]); + + assert_eq!( + default_user_settings_path(&environment), + Some( + PathBuf::from("/fixture/home") + .join(".m2") + .join("settings.xml") + ) + ); + } +} diff --git a/rust/lithe-core/src/lsp/languages/mod.rs b/rust/lithe-core/src/lsp/languages/mod.rs index 98fa89720..33b674161 100644 --- a/rust/lithe-core/src/lsp/languages/mod.rs +++ b/rust/lithe-core/src/lsp/languages/mod.rs @@ -10,6 +10,7 @@ mod java_workspace; pub(crate) mod jdt; pub(crate) mod jdt_build; mod jdt_configuration; +pub(crate) mod jdt_maven_settings; pub(crate) mod jdt_navigation; pub(crate) mod jdt_progress; mod jdt_project_metadata; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 8df12504c..310dcf165 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -80,6 +80,8 @@ pub enum CoreCommand { FileWrite, /// Reduces one shared document persistence event (`document.lifecycle`). DocumentLifecycle, + /// Classifies decoded Unicode text independently of its filename (`document.classifyText`). + DocumentClassifyText, /// Records one local-history snapshot (`history.record`). HistoryRecord, /// Lists retained local-history metadata (`history.entries`). @@ -176,6 +178,9 @@ pub enum CoreCommand { LspStopServer, /// Retries Maven profile application for an existing Java session. LspRetryMavenProfiles, + /// Sends a changed Maven configuration to a running Java session + /// (`lsp.updateMavenConfiguration`). + LspUpdateMavenConfiguration, /// Opens or updates a synchronized document (`lsp.syncDocument`). LspSyncDocument, /// Publishes external workspace file changes (`lsp.workspaceFilesChanged`). @@ -353,6 +358,7 @@ impl CoreCommand { "file.read" => Some(Self::FileRead), "file.write" => Some(Self::FileWrite), "document.lifecycle" => Some(Self::DocumentLifecycle), + "document.classifyText" => Some(Self::DocumentClassifyText), "history.record" => Some(Self::HistoryRecord), "history.entries" => Some(Self::HistoryEntries), "history.content" => Some(Self::HistoryContent), @@ -400,6 +406,7 @@ impl CoreCommand { "java.jdtWorkspaceFingerprint" => Some(Self::JavaJdtWorkspaceFingerprint), "lsp.stopServer" => Some(Self::LspStopServer), "lsp.retryMavenProfiles" => Some(Self::LspRetryMavenProfiles), + "lsp.updateMavenConfiguration" => Some(Self::LspUpdateMavenConfiguration), "lsp.syncDocument" => Some(Self::LspSyncDocument), "lsp.workspaceFilesChanged" => Some(Self::LspWorkspaceFilesChanged), "lsp.closeDocument" => Some(Self::LspCloseDocument), @@ -488,6 +495,7 @@ mod tests { "lsp.startServer", "lsp.jdtWorkspaceKey", "lsp.stopServer", + "lsp.updateMavenConfiguration", "lsp.syncDocument", "lsp.closeDocument", "lsp.request", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 95d2bc233..732ad8b88 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -403,6 +403,27 @@ fn execute(request: &str) -> CoreResponse { ), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::DocumentClassifyText => { + match parsed + .payload + .get("text") + .and_then(serde_json::Value::as_str) + { + Some(text) => CoreResponse::success( + id, + serde_json::json!({ + "isPlainText": project::is_plain_text(text) + }), + ), + None => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Decoded document text is required", + ), + ), + } + } CoreCommand::DocumentLifecycle => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1213,6 +1234,27 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspUpdateMavenConfiguration => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Maven configuration update request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::update_maven_configuration) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Maven configuration update response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspSyncDocument => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/runtime/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs index 7530abe92..43d5b5ead 100644 --- a/rust/lithe-core/src/runtime/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -27,6 +27,26 @@ pub extern "C" fn lithe_core_version() -> *const c_char { VERSION.as_ptr().cast() } +/// Classifies a borrowed UTF-8 document without copying it into a JSON envelope. +/// Returns 1 for plain text, 0 for binary control characters, and -1 for invalid input. +/// An empty slice is plain text; embedded NUL is inspected rather than terminating the input. +/// +/// # Safety +/// `bytes` must point to `length` readable bytes for this call, or be null when length is zero. +#[no_mangle] +pub unsafe extern "C" fn lithe_core_is_plain_text(bytes: *const u8, length: usize) -> i32 { + if length == 0 { + return 1; + } + if bytes.is_null() || length > isize::MAX as usize { + return -1; + } + match std::str::from_utf8(std::slice::from_raw_parts(bytes, length)) { + Ok(text) => i32::from(crate::project::is_plain_text(text)), + Err(_) => -1, + } +} + /// Executes one JSON request through the stable C ABI. /// /// The returned string is owned by the caller and must be released exactly @@ -245,3 +265,50 @@ pub unsafe extern "C" fn lithe_agent_close(handle: *mut std::ffi::c_void) { } handle.handle.close(); } + +#[cfg(test)] +mod text_content_tests { + use super::lithe_core_is_plain_text; + + #[test] + fn decoded_text_contract_matches_c_abi_and_json() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/text-content-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let text = case["text"].as_str().unwrap(); + let expected = case["isPlainText"].as_bool().unwrap(); + assert_eq!( + unsafe { lithe_core_is_plain_text(text.as_ptr(), text.len()) }, + i32::from(expected), + "{}", + case["name"] + ); + let request = serde_json::json!({ "command": "document.classifyText", "payload": { "text": text } }); + let response: serde_json::Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!( + response["data"]["isPlainText"], expected, + "{}", + case["name"] + ); + } + } + + #[test] + fn invalid_utf8_is_not_silently_replaced() { + let bytes = [0xff, 0xfe]; + assert_eq!( + unsafe { lithe_core_is_plain_text(bytes.as_ptr(), bytes.len()) }, + -1 + ); + assert_eq!(unsafe { lithe_core_is_plain_text(std::ptr::null(), 1) }, -1); + assert_eq!(unsafe { lithe_core_is_plain_text(std::ptr::null(), 0) }, 1); + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + r#"{"command":"document.classifyText","payload":{"text":1}}"#, + )) + .unwrap(); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index bc1b3e0c1..ce304109a 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -366,6 +366,8 @@ async function main() { const selected = options.resources.length > 0 ? options.resources.map((identifier) => { + // Runtime snapshots (including credential-bearing JDT Maven settings) + // take this rejection route, never a content-hash reuse validator. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-macos.sh b/scripts/test-macos.sh index a79a1293f..689f6f7e6 100755 --- a/scripts/test-macos.sh +++ b/scripts/test-macos.sh @@ -14,20 +14,20 @@ SWIFT_ARGS=( -Xcc "$ROOT_DIR/scripts/MacOS13SDKCompatibility.h" ) -# Real process-backed integration tests need the same Rust Core static library -# that is force-loaded into the application and the bridge verification binary. -# Keep it opt-in so the normal unit-test build remains lightweight and does not -# change its existing linkage behavior. -if [[ "${LITHE_RUN_JAVA_DEBUG_INTEGRATION:-0}" == "1" \ - || "${LITHE_RUN_JAVA_TEST_DEBUG_INTEGRATION:-0}" == "1" \ - || "${LITHE_RUN_GOPLS_INTEGRATION:-0}" == "1" \ - || "${LITHE_RUN_PHP_INTEGRATION:-0}" == "1" ]]; then - case "$(uname -m)" in - arm64) RUST_TARGET="aarch64-apple-darwin" ;; - x86_64) RUST_TARGET="x86_64-apple-darwin" ;; - *) print -u2 -- "Unsupported host architecture for Rust Core integration tests: $(uname -m)"; exit 1 ;; - esac - RUST_LIBRARY="$(scripts/build-rust-core.sh --debug --target "$RUST_TARGET")" +# Text classification is a production Core dependency, including in the file +# policy unit tests. Link the same implementation instead of testing a Swift copy. +case "$(uname -m)" in + arm64) RUST_TARGET="aarch64-apple-darwin" ;; + x86_64) RUST_TARGET="x86_64-apple-darwin" ;; + *) print -u2 -- "Unsupported host architecture: $(uname -m)"; exit 1 ;; +esac +RUST_LIBRARY="$(scripts/build-rust-core.sh --debug --target "$RUST_TARGET")" +# Integration callers may already pass this exact archive explicitly. +core_link_supplied=0 +for argument in "$@"; do + if [[ "$argument" == "$RUST_LIBRARY" ]]; then core_link_supplied=1; fi +done +if (( ! core_link_supplied )); then SWIFT_ARGS+=(-Xlinker -force_load -Xlinker "$RUST_LIBRARY") fi diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 1cc8a1cff..99c6474d6 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -77,6 +77,11 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /agent-history-metadata.*lithe\.agent-history\.v1.*cannot be reused/); }); + await test("JDT Maven settings snapshots cannot be copied between worktrees", { timeout: 15000 }, () => { + const refused = reuse(["--resource", "jdt-maven-settings"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /jdt-maven-settings.*cannot be reused/); + }); await testFailedBackupPreservesDestination(); await fs.mkdir(path.join(sourceRoot, "third_party", "jdtls"), { recursive: true }); await fs.writeFile( diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index 09505d0d4..3c58ec583 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -84,8 +84,8 @@ python3 scripts/test-git-execution.py --application "$BINARY" node --input-type=module -e 'import { writeTestReportArtifacts } from "./.agents/skills/write-stable-tests/scripts/generate-test-report.mjs"; writeTestReportArtifacts(".artifacts/test-stability/git-execution-integration.json");' # Exercise the macOS journal through a real non-Git-feature entry point, and -# the Maven dependency-tree file through the linked bridge. The ordinary Swift -# unit lane does not link Core, so these integrations are explicit. +# the Maven dependency-tree file through the linked bridge. These integrations +# remain explicit even though the ordinary Swift lane also links Core. LITHE_RUN_GIT_EXECUTION_INTEGRATION=1 \ ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ --suite-timeout-seconds 900 \ diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index c1052e95d..4e322d4c0 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -28,6 +28,13 @@ } ], "excludedResources": [ + { + "id": "jdt-maven-settings", + "reusable": false, + "locations": ["/jdtls//.lithe/maven/"], + "identity": "Workspace-owned Maven settings snapshots; content hashes identify documents, not a reusable version/platform/architecture/toolchain build stamp", + "reason": "Mutable JDT session state may contain credentials and must remain readable until workspace cache eviction or index rebuilding. Never copy or share it across worktrees at any stage." + }, { "id": "agent-cli-runtime", "reusable": false, diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d0aaccbe1..561285bf4 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -52,6 +52,14 @@ accepts every character. Both products preserve the raw-byte identity of the last acknowledged disk snapshot and reject a save when another process changed those bytes. +### External HTML preview + +Both products open an existing local `.html` / `.htm` document in the system's +configured web browser, independently of the file type's default editor. The +browser reads the saved file; this action does not save buffers or start a web +server. File URLs preserve Unicode and reserved characters. Browser discovery +and launch belong to platform adapters, and launch failures are shown to the user. + ## Module Lifecycle Contract The macOS reference product implements the built-in manifest in @@ -506,3 +514,19 @@ Only a user click launches a validated plan through the host Run service after s the workspace. Workers never own the native process handles; the host tracks both extension ID and workspace ID, stops pending and active runs on disable/close, and rejects stale discovery results. Remote/WSL projects do not use these local plans. + +## Text content and language selection + +Native adapters decode document bytes using the existing encoding catalog. +Decoded content is classified by Core's `document.classifyText` policy, also +available through its borrowed UTF-8 C ABI. File extensions and installed +language contributions must not exempt text from control-character validation +or cause Unicode text to be classified as binary. Windows opening and session +restoration share one content loader; read failures remain actionable errors. +Explicit image, database, PDF, and binary-format viewers retain their host routing. + +Both Monaco hosts use the bundled INI tokenizer for `.properties`. Tokenization +is presentation only: no grammar or language server is required to open plain +text. Register bundled contributions in `frontend/editor`, not in a separate +platform-specific tokenizer. Text fixtures are shared under +`shared/fixtures/editor/text-content-v1.json`. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 66182893a..d27ea92a4 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -347,6 +347,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `java.jdtWorkspaceFingerprint` | Reduce platform build-file observations to the portable JDT LS workspace fingerprint | | `java.jdtCacheRetention` | Select expired inactive JDT LS workspace-state keys from platform metadata | | `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | +| `lsp.updateMavenConfiguration` | Send a changed Maven context to a running Java session, or force JDT LS to re-resolve its Maven projects | | `lsp.syncDocument` | Open a document or apply a full-text or incremental `didChange` with monotonic versions | | `lsp.workspaceFilesChanged` | Publish normalized created, changed, or deleted workspace files to one session | | `lsp.closeDocument` | Close a document and clear its diagnostics | @@ -1386,13 +1387,45 @@ initialize, post-initialize readiness, request, Java project build (`javaBuildTimeoutMilliseconds`), and shutdown deadlines. Java callers may also provide the versioned `mavenContext` accepted by `maven.launchPlan`. Core validates its reactor and recursively declared modules, -publishes `settingsPath` through -`java.configuration.maven.userSettings`, and, after `ServiceReady`, sends one +publishes the user-level settings through +`java.configuration.maven.userSettings` and the selected installation's +`conf/settings.xml` through `java.configuration.maven.globalSettings`, and, +after `ServiceReady`, sends one `java.project.updateSettings` command per Maven project with `org.eclipse.m2e.core.selectedProfiles`. Maven Java, test, and generated source roots are normalized to workspace-relative `java.project.sourcePaths` during the same configuration flow, so JDT LS receives the selected reactor's source -model without platform-specific POM parsing. Maven profile application is a +model without platform-specific POM parsing. Both settings documents are passed as content-addressed copies inside the +session's JDT LS state directory (`/.lithe/maven/`). The user-level copy +comes from `settingsPath`, else Maven's default `~/.m2/settings.xml`, else an +empty document when only `localRepositoryPath` is set, and carries that local +repository override. JDT LS detects settings changes by comparing paths, so a +content change must always produce a new path. An unreadable document is passed +by its original path with a session warning instead of failing startup. +Copies remain readable until JDT workspace cache eviction or index rebuilding; +notification delivery is not an acknowledgement that the server read them. + +`lsp.updateMavenConfiguration` accepts `{ sessionId, mavenContext, +reloadProjects? }` for a running Java session started with a `mavenContext`, +and returns `{ settingsChanged, projectsReloaded, profilesUpdating }`. When the +settings copies differ from the ones JDT LS holds, Core sends +`workspace/didChangeConfiguration` and JDT LS force-updates every Maven project +itself. When they are unchanged and `reloadProjects` is `true`, Core sends +`java/projectConfigurationsUpdate` for the reactor's project URIs, which +re-resolves dependencies even though no `pom.xml` changed. Changed profiles +restart the profile task once the session is ready. Before the `initialized` +handshake, the new configuration replaces the one the pending settings +notification sends. Explicit reloads received before `ServiceReady` are coalesced +and sent once projects are ready, even when settings are unchanged. Response +booleans describe actions sent immediately, not queued work. A newer profile +selection is applied after the preceding batch terminates, including failed +batches; timed-out requests must all drain before that follow-up can start. +The same failed selection is not automatically retried. +A stopped or failed session returns `invalidRequest`. +Resolution problems are not part of the response; JDT LS reports them as +`pom.xml` diagnostics. + +Maven profile application is a bounded background task: at most eight project commands are in flight, remaining projects are queued, and each project reports `running`, `succeeded`, or `failed` with optional error details. Project results use a redacted stable @@ -2200,3 +2233,24 @@ and real staging checkboxes. The native products must not duplicate planning or See `shared/fixtures/git/workspace-commit-v1.json` for primitive payloads and `shared/fixtures/git/workspace-commit-workflow-v1.json` for the complete planning and continuation fixture consumed by Rust, Swift, TypeScript and Tauri adapter tests. + +### Decoded document text classification + +`document.classifyText` accepts `{ text }` and returns `{ isPlainText }`. +The input is already decoded Unicode, independent of the filename, language ID, +or tokenizer availability. Core uses the same control-character policy as +`file.read`: reject U+0000–U+0008, U+000E–U+001F, and U+007F; allow other scalars, +including tabs, line breaks, form feed, Chinese, and emoji. The entire decoded +text is inspected, not an arbitrary byte prefix. A missing or non-string `text` +returns `invalid_request`. Hosts retain responsibility for size limits, I/O, +encoding selection, and decode failures; a permission or decode error must not +be relabeled as binary content. + +The synchronous C ABI `lithe_core_is_plain_text(const uint8_t *, size_t)` borrows +UTF-8 bytes for the call, including embedded NUL, without allocating a JSON +copy. It returns `1` for plain text, `0` for binary control characters, and `-1` +for invalid UTF-8 or an invalid pointer/length combination. A zero-length input +is plain text and may use a null pointer. Nonzero input must point to at least +`length` readable bytes and `length` must not exceed `isize::MAX`. The Swift +bridge exposes the same lifetime and result contract. Fixtures live in +`shared/fixtures/editor/text-content-v1.json` and exercise both entry points. diff --git a/shared/fixtures/editor/text-content-v1.json b/shared/fixtures/editor/text-content-v1.json new file mode 100644 index 000000000..bbea138b2 --- /dev/null +++ b/shared/fixtures/editor/text-content-v1.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "empty", + "text": "", + "isPlainText": true + }, + { + "name": "reported-properties", + "text": "greeting=你好\nfarewell=再见\n", + "isPlainText": true + }, + { + "name": "dense-properties", + "text": "greeting=你好世界\nfarewell=下次再见\n", + "isPlainText": true + }, + { + "name": "unicode", + "text": "中文日本語한국어😀مرحبا", + "isPlainText": true + }, + { + "name": "whitespace", + "text": "a\t\n\r\f\u000bb", + "isPlainText": true + }, + { + "name": "bom", + "text": "greeting=你好", + "isPlainText": true + }, + { + "name": "nul", + "text": "hello\u0000world", + "isPlainText": false + }, + { + "name": "escape", + "text": "hello\u001b[31m", + "isPlainText": false + }, + { + "name": "delete", + "text": "hello", + "isPlainText": false + }, + { + "name": "control", + "text": "hello\u0001", + "isPlainText": false + }, + { + "name": "large-unicode", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa你好😀", + "isPlainText": true + }, + { + "name": "late-control", + "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0000", + "isPlainText": false + } + ] +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 977cdf8b9..93cd8f26c 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-27", + "lastReviewed": "2026-09-29", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -460,6 +460,30 @@ "owner": "Editor", "verification": "分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。" }, + { + "id": "editor-wallpaper-overview-ruler", + "area": "编辑器", + "group": "编辑器外观", + "capability": "macOS 工作台壁纸模式下,编辑器概览标尺与代码缩略图随编辑器背景透明,不显示黑色竖条;普通主题仍保持实体背景", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "frontend/editor/src/theme.ts", + "macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift", + "macos/Experiments/Monaco/workbench.integration.ts" + ] + }, + "windows": { + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/features/editor/engines/monaco/theme.ts" + ] + }, + "owner": "Editor", + "verification": "在 macOS 选择工作台背景并打开文本文件,分别关闭和打开右侧 Agent 面板,确认编辑器最右侧概览标尺不出现黑色竖条;关闭背景图后确认普通主题底色保持不变。运行真实 WebKit 工作台主题集成测试。Windows 不使用 macOS 原生工作台壁纸。" + }, { "id": "editor-semantic-highlighting", "area": "编辑器", @@ -986,13 +1010,15 @@ "windows/tauri/src-tauri/src/maven.rs", "windows/tauri/src-tauri/src/maven/settings_xml.rs", "windows/tauri/src/features/maven/components/maven-detected-value.test.tsx", - "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx" + "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx", + "windows/tauri/src/features/run/stores/run.store.ts", + "windows/tauri/src/features/run/stores/run-maven-context.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。" + "verification": "在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。" }, { "id": "java-maven-project", @@ -1044,7 +1070,38 @@ "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。" + "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。" + }, + { + "id": "java-maven-settings-reach-jdt", + "area": "Java", + "group": "Java/Maven", + "capability": "Maven 设置与重新加载实时作用于 Java 语言服务,并展示依赖解析问题", + "macos": { + "evidence": [ + "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", + "macos/Sources/LitheExecutionModule/Services/MavenService.swift", + "macos/Sources/Lithe/Models/Java/MavenResolutionProblems.swift", + "macos/Sources/Lithe/Views/Run/MavenView.swift", + "rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/maven/services/java-maven-configuration.ts", + "windows/tauri/src/features/maven/services/reload-maven-workspace.ts", + "windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts", + "windows/tauri/src/platform/lsp-core-adapter.ts", + "windows/tauri/src/platform/tauri-core.ts", + "rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Java / Maven", + "verification": "打开一个依赖尚未下载的 Maven 项目,在 Java 语言服务导入过程中修改 settings.xml、本地仓库或镜像并保存,不点重新加载,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后点「重新加载」,确认 Java 语言服务不重启而依赖重新解析;故意写错一个依赖版本,确认 Maven 工具窗口列出 pom.xml 问题、通知可跳转到对应行,修正后问题消失;初始化期间设置不变时点击重新加载,确认导入就绪后仍执行一次强制更新;旧 profile 任务失败或超时期间切换 profile,确认旧请求结束后应用最新选择;保存设置与重新加载交错执行时,已发送的 settings 副本保持可读。" }, { "id": "java-maven-toolbar-tooltips", @@ -1086,13 +1143,15 @@ "windows": { "evidence": [ "windows/tauri/src/features/maven", - "windows/tauri/src/features/diagnostics" + "windows/tauri/src/features/diagnostics", + "windows/tauri/src/features/maven/components/maven-run-pane.tsx", + "windows/tauri/src/features/run/components/run-output-text.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。" + "verification": "使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 Windows Maven 构建输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。" }, { "id": "spring-index", @@ -1312,7 +1371,7 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因。" + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标。" }, { "id": "run-save-toolchain", @@ -1354,13 +1413,15 @@ "windows": { "evidence": [ "windows/tauri/src/features/run", - "shared/fixtures/debug" + "shared/fixtures/debug", + "windows/tauri/src/features/run/components/run-pane.tsx", + "windows/tauri/src/features/run/components/run-output-text.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。" + "verification": "运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。" }, { "id": "run-java-long-classpath", @@ -1861,9 +1922,19 @@ "macos": { "evidence": [ "macos/Sources/Lithe/LitheApp.swift", + "macos/Sources/Lithe/Views/App/RootView.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Sources/Lithe/Views/App/PluginManagementView.swift", + "macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift", + "macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift", + "macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift", "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift", - "macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift" + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift", + "macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift", + "macos/Sources/Lithe/Theme/LitheTheme.swift", + "macos/Tests/LitheTests/PluginManagementPresentationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -1876,7 +1947,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。" + "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。" }, { "id": "keymap-customization", @@ -1902,7 +1973,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。", + "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。", "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。" }, { @@ -1913,6 +1984,9 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/App/PluginManagementView.swift", + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift", + "macos/Tests/LitheTests/PluginManagementPresentationTests.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins" ], "implementationStatus": "implemented", @@ -1921,13 +1995,15 @@ "windows": { "evidence": [ "windows/tauri/src/extensions", - "windows/tauri/src/features/settings" + "windows/tauri/src/features/settings", + "windows/tauri/src-tauri/src/language_tools.rs", + "windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。" + "verification": "macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。" }, { "id": "onboarding-welcome", @@ -1976,6 +2052,57 @@ "owner": "Workbench", "verification": "搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。" }, + { + "id": "workbench-compact-menu-bar", + "area": "工作台", + "group": "命令与布局", + "capability": "紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区", + "macos": { + "evidence": [ + "macos/Sources/Lithe/LitheApp.swift" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/window/components/title-bar/title-bar.tsx", + "windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx", + "windows/tauri/src/features/window/components/window-menu-bar.tsx", + "windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts", + "windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Workbench", + "verification": "在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。" + }, + { + "id": "window-controls", + "area": "工作台", + "group": "窗口管理", + "capability": "标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏", + "macos": { + "evidence": [ + "macos/Sources/Lithe/LitheApp.swift", + "macos/Sources/Lithe/Views/App/RootView.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/window/components/title-bar/window-controls.tsx", + "windows/tauri/src/features/command-palette/constants/window-actions.tsx", + "windows/tauri/src/features/window/utils/window-actions.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Workbench", + "verification": "在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。" + }, { "id": "workbench-panes", "area": "工作台", @@ -2066,20 +2193,24 @@ "evidence": [ "macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift", "macos/Sources/Lithe/Views/Editor/MediaViewerView.swift", - "macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift" + "macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift", + "macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/viewer" + "windows/tauri/src/features/viewer", + "windows/tauri/src-tauri/src/html_browser.rs", + "windows/tauri/src/features/editor/components/html/html-preview.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Editor", - "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。" + "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。" }, { "id": "docker-compose", @@ -2215,13 +2346,14 @@ "windows/tauri/src/features/layout/components/app-update-control.tsx", "windows/tauri/src/features/layout/components/app-update-details-dialog.tsx", "windows/tauri/src/features/settings/components/macos-settings-panels.tsx", - "windows/tauri/src/features/settings/hooks/use-updater.ts" + "windows/tauri/src/features/settings/hooks/use-updater.ts", + "windows/tauri/src/features/settings/stores/update.store.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Release", - "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。" + "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 Windows 未检查、正在检查、跳过版本和失败时不得显示已是最新版;菜单通知明确指 Lithe 主程序,不代表语言扩展状态。" }, { "id": "updates-failure-rollback", @@ -2563,6 +2695,32 @@ "owner": "Git", "verification": "让子仓库推送失败,确认父仓库被阻塞、独立仓库成功;重试计划应显示子仓库只推送,验证子仓库提交数不增加,随后才提交和推送父仓库。", "notes": "两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。" + }, + { + "id": "editor-unicode-text-classification", + "area": "编辑器", + "group": "文本编辑", + "capability": "中文 properties 与未知扩展名文本的一致打开和恢复", + "owner": "Editor", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift", + "macos/Tests/LitheTests/TextContentPolicyTests.swift", + "frontend/editor/src/language-contributions.ts" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts", + "windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts", + "frontend/editor/src/language-contributions.ts" + ] + }, + "verification": "双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。" } ] } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index fabd89d26..24a12da9f 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2897,6 +2897,7 @@ dependencies = [ "tauri-plugin-window-state", "tokio", "url", + "webbrowser", "windows-sys 0.61.2", "zip 2.4.2", ] @@ -3057,6 +3058,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -6665,6 +6672,22 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webkit2gtk" version = "2.0.2" diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index d6ddba338..1d0527384 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ windows-sys = { version = "0.61", features = [ "Win32_System_ProcessStatus", "Win32_System_Threading", ] } +webbrowser = "1.2.4" [profile.release] lto = "thin" diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index b7ab90532..a0b9560ee 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -797,3 +797,11 @@ pub fn open_file_external(app: AppHandle, path: String) -> Result<(), String> { .open_path(path, None::<&str>) .map_err(|error| error.to_string()) } + +/// Open saved HTML with the default HTTP browser, independent of file associations. +#[tauri::command] +pub async fn open_html_in_browser(path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || crate::html_browser::open(&path)) + .await + .map_err(|_| "The browser launch task failed.".to_string())? +} diff --git a/windows/tauri/src-tauri/src/html_browser.rs b/windows/tauri/src-tauri/src/html_browser.rs new file mode 100644 index 000000000..f26425a64 --- /dev/null +++ b/windows/tauri/src-tauri/src/html_browser.rs @@ -0,0 +1,102 @@ +//! Opens saved HTML through the system's HTTP browser association, not .html. + +use std::path::Path; + +pub fn open(path: &str) -> Result<(), String> { + open_with(Path::new(path), |url| { + webbrowser::open(url).map_err(|_| { + "Could not open HTML. Configure a default web browser in Windows Settings and retry." + .to_string() + }) + }) +} + +fn open_with(path: &Path, launch: impl FnOnce(&str) -> Result<(), String>) -> Result<(), String> { + if !path.is_absolute() + || !path.is_file() + || !path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("html") || value.eq_ignore_ascii_case("htm") + }) + { + return Err("Select an existing local HTML file to preview.".to_string()); + } + let url = url::Url::from_file_path(path) + .map_err(|_| "Could not create the HTML file URL.".to_string())?; + launch(url.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "lithe-html-browser-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).expect("fixture directory"); + Self(root) + } + + fn file(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, "Preview").expect("fixture HTML"); + path + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).expect("fixture cleanup"); + } + } + + #[test] + fn browser_receives_encoded_file_url_for_unicode_and_reserved_characters() { + let fixture = Fixture::new(); + let file = fixture.file("中文 page #1 & two.HTML"); + let mut launched = false; + open_with(&file, |argument| { + let url = url::Url::parse(argument).expect("URL"); + assert_eq!(url.to_file_path().expect("file URL"), file); + assert!(url.fragment().is_none()); + assert!(url.query().is_none()); + launched = true; + Ok(()) + }) + .expect("open HTML"); + assert!(launched); + } + + #[test] + fn missing_files_directories_and_non_html_never_launch() { + let fixture = Fixture::new(); + for path in [ + fixture.0.clone(), + fixture.0.join("missing.html"), + fixture.file("tool.exe"), + PathBuf::from("relative.html"), + ] { + assert!(open_with(&path, |_| panic!("invalid input launched a browser")).is_err()); + } + } + + #[test] + fn browser_failure_is_returned_to_the_caller() { + let fixture = Fixture::new(); + let file = fixture.file("page.htm"); + assert_eq!( + open_with(&file, |_| Err("No default browser".into())), + Err("No default browser".into()) + ); + } +} diff --git a/windows/tauri/src-tauri/src/language_tools.rs b/windows/tauri/src-tauri/src/language_tools.rs index 9bd51e650..f477c2e24 100644 --- a/windows/tauri/src-tauri/src/language_tools.rs +++ b/windows/tauri/src-tauri/src/language_tools.rs @@ -210,15 +210,15 @@ pub fn get_tool_path( std::env::var_os("USERPROFILE").as_deref(), ), ToolRuntime::System => find_system_tool(path_env.as_deref(), config.executable_name()), - // Runtimes without a Windows implementation stay unresolved here; the - // install command reports the explicit failure for diagnostics. + // Automatic installation is separate from discovery: a user-provided + // language server remains usable even without a managed installer. ToolRuntime::Node | ToolRuntime::Python | ToolRuntime::Go | ToolRuntime::Rust | ToolRuntime::Ruby | ToolRuntime::R - | ToolRuntime::Binary => None, + | ToolRuntime::Binary => find_system_tool(path_env.as_deref(), config.executable_name()), }; Ok(resolved.map(|path| normalize_path(&path))) } @@ -300,66 +300,87 @@ fn requested_tool_config( .map_err(|error| format!("The {tool_type} tool configuration is invalid: {error}")) } -fn install_language_tool( - language_id: &str, - config: &LanguageToolConfig, - tools_root: &Path, - path_env: Option<&OsStr>, - user_home: Option<&OsStr>, - cancelled: &AtomicBool, +/// Read-only prerequisite check used before replacing an installed extension. +/// It neither downloads tools nor changes the active language-server process. +#[tauri::command] +pub async fn check_language_tool_requirements( + app: AppHandle, + language_id: String, + tools: Value, ) -> Result<(), String> { - match config.runtime { - ToolRuntime::Bun => install_bun_tool(language_id, config, tools_root, path_env, user_home, cancelled), - ToolRuntime::System => find_system_tool(path_env, config.executable_name()) - .map(|_| ()) - .ok_or_else(|| { - format!( - "{} was not found in PATH. Install it and make sure it is on the PATH environment variable.", - config.executable_name() - ) - }), - ToolRuntime::Node | ToolRuntime::Python | ToolRuntime::Go | ToolRuntime::Rust - | ToolRuntime::Ruby | ToolRuntime::R | ToolRuntime::Binary => Err(format!( - "The {:?} runtime for {} is not supported by this Lithe Windows build yet.", - config.runtime, - config.executable_name() - )), - } + tauri::async_runtime::spawn_blocking(move || { + let path_env = std::env::var_os("PATH"); + let user_home = std::env::var_os("USERPROFILE"); + let tools_root = managed_tools_root(&app); + for tool_type in TOOL_TYPES { + if let Some(config) = requested_tool_config(&tools, tool_type)? { + resolve_install_runtime( + &language_id, + &config, + &tools_root, + path_env.as_deref(), + user_home.as_deref(), + )?; + } + } + Ok(()) + }) + .await + .map_err(|_| "Language tool prerequisite check failed.".to_string())? } -fn install_bun_tool( +/// None means the tool is already available. Some is the Bun installer to run. +fn resolve_install_runtime( language_id: &str, config: &LanguageToolConfig, tools_root: &Path, path_env: Option<&OsStr>, user_home: Option<&OsStr>, - cancelled: &AtomicBool, -) -> Result<(), String> { - if cancelled.load(Ordering::Acquire) { - return Err("Language tool installation cancelled".into()); - } +) -> Result, String> { for required in &config.required_executables { if find_system_tool(path_env, required).is_none() { return Err(format!( - "{} requires {} on PATH. Install it, then retry plugin installation.", + "{} requires {} on PATH. Install it, restart Lithe, then retry plugin installation.", config.name, required )); } } + if config.runtime != ToolRuntime::Bun { + return find_system_tool(path_env, config.executable_name()).map(|_| None).ok_or_else(|| format!( + "Automatic installation of {} ({:?}) is not supported on Windows yet. Install the language server, add its executable to PATH, restart Lithe, then retry.", + config.executable_name(), config.runtime + )); + } if resolve_bun_tool_path(language_id, config, tools_root, path_env, user_home).is_some() { - return Ok(()); + return Ok(None); } - let packages = config.npm_packages(); - if packages.is_empty() { + if config.npm_packages().is_empty() { return Err(format!( "No npm package is configured for the {} language tool.", config.executable_name() )); } - let bun = find_bun_executable(path_env, user_home).ok_or_else(|| { - "bun was not found. Install bun (https://bun.sh) and make sure it is on the PATH environment variable." - .to_string() - })?; + find_bun_executable(path_env, user_home).map(Some).ok_or_else(|| { + "Bun is required to install this language extension. Install Bun from https://bun.sh, restart Lithe, then retry. Lithe application updates do not install language tools.".to_string() + }) +} + +fn install_language_tool( + language_id: &str, + config: &LanguageToolConfig, + tools_root: &Path, + path_env: Option<&OsStr>, + user_home: Option<&OsStr>, + cancelled: &AtomicBool, +) -> Result<(), String> { + if cancelled.load(Ordering::Acquire) { + return Err("Language tool installation cancelled".into()); + } + let Some(bun) = resolve_install_runtime(language_id, config, tools_root, path_env, user_home)? + else { + return Ok(()); + }; + let packages = config.npm_packages(); let tool_directory = managed_tool_directory(tools_root, language_id, &config.name); std::fs::create_dir_all(&tool_directory).map_err(|error| { format!( @@ -805,6 +826,50 @@ mod tests { ); } + #[test] + fn prerequisite_check_does_not_install_or_create_a_tool_directory() { + let directory = temp_dir("prerequisites"); + let config = tool_config(ToolRuntime::Bun, "example-server", "example-server"); + let tools = directory.join("managed-tools"); + let missing = resolve_install_runtime("example", &config, &tools, None, None); + fs::write(directory.join("bun.exe"), []).expect("fixture installer"); + let ready = resolve_install_runtime( + "example", + &config, + &tools, + Some(directory.as_os_str()), + None, + ); + let created_tools = tools.exists(); + fs::remove_dir_all(&directory).expect("fixture cleanup"); + assert!(missing + .expect_err("missing Bun") + .contains("Bun is required")); + assert_eq!( + ready.expect("installer available"), + Some(directory.join("bun.exe")) + ); + assert!(!created_tools, "preflight must remain read-only"); + } + + #[test] + fn manually_installed_binary_is_usable_without_a_managed_installer() { + let directory = temp_dir("manual-marksman"); + fs::create_dir_all(&directory).expect("fixture directory"); + fs::write(directory.join("marksman.exe"), []).expect("fixture tool"); + let config = tool_config(ToolRuntime::Binary, "marksman", ""); + let result = install_language_tool( + "markdown", + &config, + &directory, + Some(directory.as_os_str()), + None, + &AtomicBool::new(false), + ); + fs::remove_dir_all(&directory).expect("fixture cleanup"); + assert!(result.is_ok(), "{result:?}"); + } + #[test] fn unsupported_runtime_installs_fail_explicitly() { let config = tool_config(ToolRuntime::Binary, "marksman", ""); diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 260c7f9ba..eac189918 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -7,6 +7,7 @@ mod diagnostics; mod document; mod file_events; mod host; +mod html_browser; mod language_tools; mod logging; mod lsp; @@ -174,6 +175,7 @@ fn main() { host::rename_file, host::get_symlink_info, host::open_file_external, + host::open_html_in_browser, host::take_pending_cli_open_requests, host::clipboard_set, host::clipboard_get, @@ -187,6 +189,7 @@ fn main() { lsp::lsp_rebuild_java_index, language_tools::get_tool_path, language_tools::install_language_tools, + language_tools::check_language_tool_requirements, language_tools::cancel_language_tool_install, language_tools::uninstall_language_tools, maven::maven_load_configuration, diff --git a/windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts b/windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts index 7da40c6ca..0877fbdee 100644 --- a/windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts +++ b/windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts @@ -177,3 +177,71 @@ test("worker activation failure closes the language gate and unloads its provide extensionRegistry.unregisterExtension(manifest.id); } }); + +for (const message of ["Bun is required", "Automatic installation is not supported"]) { + test(`failed update preserves the installed extension: ${message}`, async () => { + const { spyOn } = await import("bun:test"); + const runtime = await import("./extension-store-runtime"); + const { extensionInstaller } = await import("../installer/extension-installer"); + const { default: json } = await import("../../../../../Plugins/win/Official/PhpSupport/plugin.json"); + const manifest = json as import("../types/extension-manifest").ExtensionManifest; + const extension = { manifest, isInstalled: true, isEnabled: true, isInstalling: false }; + extensionRegistry.registerExtension(manifest, { isEnabled: true, state: "installed" }); + const resolved = spyOn(runtime, "checkLanguageToolRequirements").mockRejectedValue(new Error(message)); + const uninstall = spyOn(extensionInstaller, "uninstallLanguage").mockResolvedValue(undefined); + let cleared = false; + let reinstalled = false; + try { + await expect(updateExtensionLifecycle({ + extensionId: manifest.id, extension, + clearInstalledStateForUpdate: () => { cleared = true; }, + reinstall: async () => { reinstalled = true; }, + })).rejects.toThrow(message); + expect(cleared).toBe(false); + expect(reinstalled).toBe(false); + expect(uninstall).not.toHaveBeenCalled(); + expect(extensionRegistry.getExtension(manifest.id)?.isEnabled).toBe(true); + } finally { + resolved.mockRestore(); + uninstall.mockRestore(); + extensionRegistry.unregisterExtension(manifest.id); + } + }); +} + +test("disabling a language extension wins over an update prerequisite check", async () => { + const { spyOn } = await import("bun:test"); + const runtime = await import("./extension-store-runtime"); + const native = await import("@/platform/tauri-core"); + const { LspClient } = await import("@/features/editor/lsp/lsp-client"); + const { disableExtensionLifecycle } = await import("./extension-store-lifecycle"); + const { default: json } = await import("../../../../../Plugins/win/Official/PhpSupport/plugin.json"); + const manifest = json as import("../types/extension-manifest").ExtensionManifest; + const extension = { manifest, isInstalled: true, isEnabled: true, isInstalling: false }; + extensionRegistry.registerExtension(manifest, { isEnabled: true, state: "installed" }); + let release!: () => void; + const check = new Promise((resolve) => { release = resolve; }); + const prerequisites = spyOn(runtime, "checkLanguageToolRequirements").mockImplementation(() => check); + const invoke = spyOn(native, "invoke").mockResolvedValue(undefined); + const stop = spyOn(LspClient.getInstance(), "stopLanguageServers").mockResolvedValue(undefined); + let cleared = false; + let reinstalled = false; + const updating = updateExtensionLifecycle({ + extensionId: manifest.id, extension, + clearInstalledStateForUpdate: () => { cleared = true; }, + reinstall: async () => { reinstalled = true; }, + }).then(() => "updated", () => "cancelled"); + try { + await disableExtensionLifecycle({ extensionId: manifest.id, extension }); + release(); + expect(await updating).toBe("cancelled"); + expect(cleared).toBe(false); + expect(reinstalled).toBe(false); + expect(extensionRegistry.getExtension(manifest.id)?.isEnabled).toBe(false); + } finally { + release(); + await updating; + for (const spy of [prerequisites, invoke, stop]) spy.mockRestore(); + extensionRegistry.unregisterExtension(manifest.id); + } +}); diff --git a/windows/tauri/src/extensions/registry/extension-store-lifecycle.ts b/windows/tauri/src/extensions/registry/extension-store-lifecycle.ts index 1f43f3301..c02871e58 100644 --- a/windows/tauri/src/extensions/registry/extension-store-lifecycle.ts +++ b/windows/tauri/src/extensions/registry/extension-store-lifecycle.ts @@ -23,6 +23,7 @@ import { import { extensionRegistry } from "./extension-registry"; import { buildRuntimeManifest, + checkLanguageToolRequirements, installLanguageExtensionManifest, registerLanguageProvider, resolveToolPaths, @@ -539,6 +540,17 @@ export async function updateExtensionLifecycle(params: { (language) => language.id, ); + // Check dependencies without downloads before touching the old parser or state. + if (languageIds.length > 0 && extension.manifest.lsp) { + const checkedExtension = extensionRegistry.getExtension(extensionId); + await checkLanguageToolRequirements(languageIds[0], extension.manifest); + // Disable, uninstall, or another update replaces this registry entry. A + // stale preflight must not restore the previous enabled/installed state. + if (extensionRegistry.getExtension(extensionId) !== checkedExtension) { + throw new Error("Extension changed while checking update requirements. Retry if needed."); + } + } + await disableExtensionLifecycle({ extensionId, extension }); if (languageIds.length > 0) { await uninstallLanguageArtifacts(languageIds); diff --git a/windows/tauri/src/extensions/registry/extension-store-runtime.ts b/windows/tauri/src/extensions/registry/extension-store-runtime.ts index 70faad07b..038cc81cc 100644 --- a/windows/tauri/src/extensions/registry/extension-store-runtime.ts +++ b/windows/tauri/src/extensions/registry/extension-store-runtime.ts @@ -425,6 +425,13 @@ export function resolveInstalledExtensionId( return installed.extensionId || `lithe.${installed.languageId}`; } +/** Check the required server without downloading tools or modifying an installed extension. */ +export async function checkLanguageToolRequirements(languageId: string, manifest: ExtensionManifest): Promise { + const config = getLanguageToolConfigSet(manifest); + if (!config?.lsp) return; + await invoke("check_language_tool_requirements", { languageId, tools: { lsp: config.lsp } }); +} + async function installLanguageTools( languageId: string, manifest?: ExtensionManifest, diff --git a/windows/tauri/src/features/command-palette/components/command-palette.tsx b/windows/tauri/src/features/command-palette/components/command-palette.tsx index fd042bcc0..fdc039f9b 100644 --- a/windows/tauri/src/features/command-palette/components/command-palette.tsx +++ b/windows/tauri/src/features/command-palette/components/command-palette.tsx @@ -1,4 +1,5 @@ import { appDataDir } from "@tauri-apps/api/path"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { ClockCounterClockwiseIcon as History } from "@/ui/icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconThemeSelectorContent } from "@/features/command-palette/components/icon-theme-selector"; @@ -56,6 +57,10 @@ import type { CommandPaletteViewId } from "../types/view.types"; import { useActionsStore } from "../stores/action-history.store"; import { useCommandPaletteViews } from "../services/command-palette-view-registry"; import { localizeCommandPaletteAction } from "../utils/action-localization"; +import { + readWindowPresentationState, + type WindowPresentationState, +} from "@/features/window/utils/window-actions"; interface CommandPaletteContentProps { commandPaletteInitialView: CommandPaletteViewId; @@ -84,6 +89,8 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont }; const [query, setQuery] = useState(""); + const [windowPresentationState, setWindowPresentationState] = + useState(null); const [viewStack, setViewStack] = useState(["root"]); const [activeInitialView, setActiveInitialView] = useState("root"); const resultsRef = useRef(null); @@ -95,6 +102,52 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont activeInitialView !== commandPaletteInitialView ? initialViewStack : viewStack; const currentView = renderedViewStack[renderedViewStack.length - 1] || "root"; + useEffect(() => { + const window = getCurrentWindow(); + let disposed = false; + let stateRequest = 0; + const unlisten: Array<() => void> = []; + + const syncWindowState = async () => { + const request = ++stateRequest; + try { + const state = await readWindowPresentationState(window); + if (!disposed && request === stateRequest) { + setWindowPresentationState(state); + } + } catch (error) { + if (!disposed && request === stateRequest) { + console.error("Error checking window state for command palette:", error); + } + } + }; + + const subscribe = async (register: () => Promise<() => void>): Promise => { + try { + const dispose = await register(); + if (disposed) { + dispose(); + } else { + unlisten.push(dispose); + } + } catch (error) { + if (!disposed) { + console.error("Error subscribing to window state for command palette:", error); + } + } + }; + + void Promise.all([ + subscribe(() => window.onResized(() => void syncWindowState())), + subscribe(() => window.onFocusChanged(() => void syncWindowState())), + ]).then(syncWindowState); + + return () => { + disposed = true; + unlisten.forEach((dispose) => dispose()); + }; + }, []); + const pushView = (view: CommandPaletteViewId) => { setQuery(""); setViewStack((currentStack) => [...currentStack, view]); @@ -303,9 +356,9 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont }, onClose, }), - ...createWindowActions({ - onClose, - }), + ...(windowPresentationState + ? createWindowActions({ onClose, ...windowPresentationState }) + : []), ...createGitActions({ rootFolderPath, activeRepoPath, @@ -361,7 +414,7 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont (index: number) => { const action = prioritizedActions[index]; if (!action) return; - action.action(); + void action.action(); pushAction(action.id); }, [prioritizedActions, pushAction], @@ -469,7 +522,7 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont { - action.action(); + void action.action(); pushAction(action.id); }} onMouseEnter={() => setSelectedIndex(index)} diff --git a/windows/tauri/src/features/command-palette/constants/window-actions.test.tsx b/windows/tauri/src/features/command-palette/constants/window-actions.test.tsx new file mode 100644 index 000000000..b437079a1 --- /dev/null +++ b/windows/tauri/src/features/command-palette/constants/window-actions.test.tsx @@ -0,0 +1,118 @@ +import { beforeEach, expect, mock, test } from "bun:test"; +import type { ReactElement } from "react"; + +const currentWindow = { + minimize: mock(async () => undefined), + maximize: mock(async () => undefined), + unmaximize: mock(async () => undefined), + isMaximized: mock(async () => false), + isFullscreen: mock(async () => false), + setFullscreen: mock(async () => undefined), +}; + +const { CopyIcon, ArrowsInIcon, ArrowsOutIcon, SquareIcon } = await import("@/ui/icons"); +const { createWindowActions } = await import("./window-actions"); +const { + maximizeCurrentWindow, + minimizeCurrentWindow, + readWindowPresentationState, + toggleCurrentWindowFullscreen, + toggleCurrentWindowMaximize, +} = await import("@/features/window/utils/window-actions"); + +const operations = { + minimize: mock(async () => undefined), + toggleMaximize: mock(async () => undefined), + toggleFullscreen: mock(async () => undefined), +}; + +function findAction(actions: ReturnType, id: string) { + const action = actions.find((candidate) => candidate.id === id); + expect(action).toBeDefined(); + return action!; +} + +beforeEach(() => { + Object.values(currentWindow).forEach((operation) => operation.mockClear()); + Object.values(operations).forEach((operation) => operation.mockClear()); + currentWindow.isMaximized.mockResolvedValue(false); + currentWindow.isFullscreen.mockResolvedValue(false); +}); + +test("window commands invoke Tauri window operations and close after success", async () => { + const onClose = mock(() => undefined); + const actions = createWindowActions({ + onClose, + isMaximized: false, + isFullscreen: false, + operations, + }); + const maximize = findAction(actions, "window-maximize"); + const enterFullscreen = findAction(actions, "window-enter-fullscreen"); + + expect((maximize.icon as ReactElement).type).toBe(SquareIcon); + expect((enterFullscreen.icon as ReactElement).type).toBe(ArrowsOutIcon); + + await findAction(actions, "window-minimize").action(); + await maximize.action(); + await enterFullscreen.action(); + + expect(operations.minimize).toHaveBeenCalledTimes(1); + expect(operations.toggleMaximize).toHaveBeenCalledTimes(1); + expect(operations.toggleFullscreen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(3); +}); + +test("window command labels and icons reflect restore and exit-fullscreen states", () => { + const actions = createWindowActions({ + onClose: mock(() => undefined), + isMaximized: true, + isFullscreen: true, + operations, + }); + const restore = findAction(actions, "window-restore"); + const exitFullscreen = findAction(actions, "window-exit-fullscreen"); + + expect(restore.label).toBe("Window: Restore"); + expect((restore.icon as ReactElement).type).toBe(CopyIcon); + expect(exitFullscreen.label).toBe("Window: Exit Fullscreen"); + expect((exitFullscreen.icon as ReactElement).type).toBe(ArrowsInIcon); +}); + +test("restore and fullscreen commands use the current native window state", async () => { + currentWindow.isMaximized.mockResolvedValue(true); + currentWindow.isFullscreen.mockResolvedValue(true); + const actions = createWindowActions({ + onClose: mock(() => undefined), + isMaximized: true, + isFullscreen: true, + operations: { + minimize: () => minimizeCurrentWindow(currentWindow), + toggleMaximize: () => toggleCurrentWindowMaximize(currentWindow), + toggleFullscreen: () => toggleCurrentWindowFullscreen(currentWindow), + }, + }); + + await minimizeCurrentWindow(currentWindow); + await maximizeCurrentWindow(currentWindow); + await findAction(actions, "window-restore").action(); + await findAction(actions, "window-exit-fullscreen").action(); + + expect(currentWindow.minimize).toHaveBeenCalledTimes(1); + expect(currentWindow.maximize).toHaveBeenCalledTimes(1); + expect(currentWindow.unmaximize).toHaveBeenCalledTimes(1); + expect(currentWindow.setFullscreen).toHaveBeenCalledWith(false); +}); + +test("window state reads are delegated to the active Tauri window", async () => { + currentWindow.isMaximized.mockResolvedValue(true); + currentWindow.isFullscreen.mockResolvedValue(false); + + await expect(readWindowPresentationState(currentWindow)).resolves.toEqual({ + isMaximized: true, + isFullscreen: false, + }); + + expect(currentWindow.isMaximized).toHaveBeenCalledTimes(1); + expect(currentWindow.isFullscreen).toHaveBeenCalledTimes(1); +}); diff --git a/windows/tauri/src/features/command-palette/constants/window-actions.tsx b/windows/tauri/src/features/command-palette/constants/window-actions.tsx index e755e5321..17386d54b 100644 --- a/windows/tauri/src/features/command-palette/constants/window-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/window-actions.tsx @@ -1,12 +1,38 @@ -import { ArrowsOutIcon as Maximize, ArrowsInIcon as Minimize } from "@/ui/icons"; +import { + ArrowsInIcon as ExitFullscreen, + ArrowsOutIcon as FullScreen, + CopyIcon as Restore, + MinusIcon as Minimize, + SquareIcon as Maximize, +} from "@/ui/icons"; +import { + minimizeCurrentWindow, + toggleCurrentWindowFullscreen, + toggleCurrentWindowMaximize, +} from "@/features/window/utils/window-actions"; import type { Action } from "../types/action.types"; interface WindowActionsParams { onClose: () => void; + isMaximized: boolean; + isFullscreen: boolean; + operations?: WindowActionOperations; } +interface WindowActionOperations { + minimize: typeof minimizeCurrentWindow; + toggleMaximize: typeof toggleCurrentWindowMaximize; + toggleFullscreen: typeof toggleCurrentWindowFullscreen; +} + +const windowActionOperations: WindowActionOperations = { + minimize: minimizeCurrentWindow, + toggleMaximize: toggleCurrentWindowMaximize, + toggleFullscreen: toggleCurrentWindowFullscreen, +}; + export const createWindowActions = (params: WindowActionsParams): Action[] => { - const { onClose } = params; + const { onClose, isMaximized, isFullscreen, operations = windowActionOperations } = params; return [ { @@ -15,33 +41,43 @@ export const createWindowActions = (params: WindowActionsParams): Action[] => { description: "Minimize the window", icon: , category: "Window", - action: () => { - window.dispatchEvent(new CustomEvent("minimize-window")); - onClose(); - }, + action: runWindowAction(onClose, operations.minimize, "minimizing"), }, { - id: "window-maximize", - label: "Window: Maximize", - description: "Maximize or restore the window", - icon: , + id: isMaximized ? "window-restore" : "window-maximize", + label: isMaximized ? "Window: Restore" : "Window: Maximize", + description: isMaximized ? "Restore the window" : "Maximize the window", + icon: isMaximized ? : , category: "Window", - action: () => { - window.dispatchEvent(new CustomEvent("maximize-window")); - onClose(); - }, + action: runWindowAction( + onClose, + operations.toggleMaximize, + isMaximized ? "restoring" : "maximizing", + ), }, { - id: "window-fullscreen", - label: "Window: Toggle Fullscreen", - description: "Enter or exit fullscreen mode", - icon: , + id: isFullscreen ? "window-exit-fullscreen" : "window-enter-fullscreen", + label: isFullscreen ? "Window: Exit Fullscreen" : "Window: Enter Fullscreen", + description: isFullscreen ? "Exit fullscreen mode" : "Enter fullscreen mode", + icon: isFullscreen ? : , category: "Window", commandId: "window.toggleFullscreen", - action: () => { - window.dispatchEvent(new CustomEvent("toggle-fullscreen")); - onClose(); - }, + action: runWindowAction(onClose, operations.toggleFullscreen, "toggling fullscreen"), }, ]; }; + +function runWindowAction( + onClose: () => void, + action: () => Promise, + operation: string, +): () => Promise { + return async () => { + try { + await action(); + onClose(); + } catch (error) { + console.error(`Error ${operation}:`, error); + } + }; +} diff --git a/windows/tauri/src/features/command-palette/types/action.types.ts b/windows/tauri/src/features/command-palette/types/action.types.ts index 821727b07..ec89cbf76 100644 --- a/windows/tauri/src/features/command-palette/types/action.types.ts +++ b/windows/tauri/src/features/command-palette/types/action.types.ts @@ -8,5 +8,5 @@ export interface Action { category: string; /** Registered command id to look up the current binding from the keymap registry. */ commandId?: string; - action: () => void; + action: () => void | Promise; } diff --git a/windows/tauri/src/features/editor/components/html/html-preview.tsx b/windows/tauri/src/features/editor/components/html/html-preview.tsx index b52f36be1..78804f329 100644 --- a/windows/tauri/src/features/editor/components/html/html-preview.tsx +++ b/windows/tauri/src/features/editor/components/html/html-preview.tsx @@ -45,10 +45,12 @@ export function HtmlPreview() { if (!sourcePath || !canOpenInBrowser) return; try { - await invoke("open_file_external", { path: sourcePath }); + await invoke("open_html_in_browser", { path: sourcePath }); } catch (error) { console.error("Failed to open HTML in browser:", error); - toast.error(t("editor.openHtmlInBrowserFailed")); + toast.error(t("editor.openHtmlInBrowserFailed"), { + description: error instanceof Error ? error.message : String(error), + }); } }; diff --git a/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx b/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx index 99082bc2c..3ebb5dfdb 100644 --- a/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx +++ b/windows/tauri/src/features/editor/components/toolbar/breadcrumb.tsx @@ -106,10 +106,12 @@ export default function Breadcrumb({ if (!sourcePath || !canOpenHtmlInBrowser) return; try { - await invoke("open_file_external", { path: sourcePath }); + await invoke("open_html_in_browser", { path: sourcePath }); } catch (error) { console.error("Failed to open HTML in browser:", error); - toast.error(t("editor.openHtmlInBrowserFailed")); + toast.error(t("editor.openHtmlInBrowserFailed"), { + description: error instanceof Error ? error.message : String(error), + }); } }; diff --git a/windows/tauri/src/features/editor/lsp/maven-profile-task.test.ts b/windows/tauri/src/features/editor/lsp/maven-profile-task.test.ts index e3798f131..d200efc92 100644 --- a/windows/tauri/src/features/editor/lsp/maven-profile-task.test.ts +++ b/windows/tauri/src/features/editor/lsp/maven-profile-task.test.ts @@ -65,3 +65,18 @@ test("shared Maven event fixture keeps task warnings separate from service readi expect(toast.warning).toHaveBeenCalledTimes(1); expect(toast.success).not.toHaveBeenCalled(); }); + +test("a completed profile task dismisses its progress without claiming Maven resolved", () => { + const toast = { + loading: mock(() => "id"), success: mock(() => "id"), + warning: mock(() => "id"), error: mock(() => "id"), dismiss: mock(() => "id"), + }; + + presentMavenProfileTask( + { sessionId: "session", status: "succeeded" }, + { toast, clearProjects: mock(() => undefined), retry: mock(async () => undefined) }, + ); + + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith("java-maven-profiles:session"); +}); diff --git a/windows/tauri/src/features/editor/lsp/maven-profile-task.ts b/windows/tauri/src/features/editor/lsp/maven-profile-task.ts index bbe6c5b7f..ecc7a580f 100644 --- a/windows/tauri/src/features/editor/lsp/maven-profile-task.ts +++ b/windows/tauri/src/features/editor/lsp/maven-profile-task.ts @@ -14,9 +14,10 @@ export function presentMavenProfileTask( if (task.status === "running") { effects.clearProjects(task.sessionId); effects.toast.loading("Applying Maven configuration", { id, action: undefined }); - } else if (task.status === "succeeded") { - effects.toast.success("Maven configuration applied", { id, duration: 2500, action: undefined }); - } else if (task.status === "cancelled" || task.status === "idle") { + } else if (["succeeded", "cancelled", "idle"].includes(task.status)) { + // Profile commands returning says nothing about dependency resolution, so + // success is not announced. Resolution failures surface as Maven problems + // read from pom.xml diagnostics instead (#970). effects.toast.dismiss(id); } else if (["failed", "timedOut", "partiallySucceeded"].includes(task.status)) { effects.toast.warning("Some Maven modules failed to update; the language service remains available", { diff --git a/windows/tauri/src/features/editor/utils/language-detection.test.ts b/windows/tauri/src/features/editor/utils/language-detection.test.ts index 18ad78a5c..d33795c7e 100644 --- a/windows/tauri/src/features/editor/utils/language-detection.test.ts +++ b/windows/tauri/src/features/editor/utils/language-detection.test.ts @@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"; import { detectLanguageFromFileName, detectLanguageFromPath } from "./language-detection"; describe("editor language detection fallbacks", () => { + test("properties use the bundled INI tokenizer before plugins initialize", () => { + expect(detectLanguageFromPath("C:\\work\\messages_ZH.PROPERTIES")).toBe("ini"); + expect(detectLanguageFromFileName("application.properties")).toBe("ini"); + }); test("recognizes Java sources even before extension contributions initialize", () => { expect(detectLanguageFromPath("C:\\work\\src\\DirectUploadService.JAVA")).toBe("java"); expect(detectLanguageFromFileName("DirectUploadService.java")).toBe("java"); diff --git a/windows/tauri/src/features/editor/utils/language-detection.ts b/windows/tauri/src/features/editor/utils/language-detection.ts index d0f6e3292..5b0142e31 100644 --- a/windows/tauri/src/features/editor/utils/language-detection.ts +++ b/windows/tauri/src/features/editor/utils/language-detection.ts @@ -41,6 +41,7 @@ export function detectLanguageFromPath(filePath: string): string { rs: "rust", go: "go", java: "java", + properties: "ini", c: "c", h: "c", cpp: "cpp", diff --git a/windows/tauri/src/features/editor/utils/language-id.ts b/windows/tauri/src/features/editor/utils/language-id.ts index 05f01b9c9..63fd205b4 100644 --- a/windows/tauri/src/features/editor/utils/language-id.ts +++ b/windows/tauri/src/features/editor/utils/language-id.ts @@ -21,6 +21,7 @@ const EXTENSION_TO_LANGUAGE: Record = { rs: "rust", go: "go", java: "java", + properties: "ini", c: "c", h: "c", cpp: "cpp", 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 3e3fe03d6..9213ff54b 100644 --- a/windows/tauri/src/features/file-system/controllers/file-utils.ts +++ b/windows/tauri/src/features/file-system/controllers/file-utils.ts @@ -182,35 +182,6 @@ export const isKnownTextFile = (path: string): boolean => { return Array.from(TEXT_EXTENSIONS).some((extension) => lowerPath.endsWith(extension)); }; -/** - * Best-effort binary sniffing for files with unknown extensions. - * Treat null bytes and a high ratio of control bytes as unsupported for the text editor. - */ -export const isBinaryContent = (data: Uint8Array, sampleSize = 8192): boolean => { - const limit = Math.min(data.length, sampleSize); - if (limit === 0) return false; - - let suspiciousBytes = 0; - - for (let i = 0; i < limit; i++) { - const byte = data[i]; - - if (byte === 0) { - return true; - } - - const isPrintableAscii = byte >= 0x20 && byte <= 0x7e; - const isCommonWhitespace = byte === 0x09 || byte === 0x0a || byte === 0x0d || byte === 0x0c; - const isLikelyUtf8Lead = byte >= 0xc2; - - if (!isPrintableAscii && !isCommonWhitespace && !isLikelyUtf8Lead) { - suspiciousBytes++; - } - } - - return suspiciousBytes / limit > 0.3; -}; - /** * Extract filename from a path */ 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 48427062e..c38f6db72 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 @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { DocumentReadDetails } from "@/platform/document-files"; import type { SessionRestoreController } from "./workspace-session-restore"; // The controller reads local files through `loadFileContent`, which delegates to @@ -9,7 +10,21 @@ const readFileContent = mock(async (_path: string): Promise => ""); mock.module("./file-operations", () => ({ readFileContent })); -const { createSessionRestoreController, SESSION_RESTORE_CONCURRENCY } = +// Routing tests control the Core verdict; the real predicate and ABI consume +// text-content-v1.json in Rust and Swift contract tests. +const classifyText = mock(async (_text: string) => ({ isPlainText: true })); +const readNativeDocument = mock(async (_args: unknown): Promise => ({ + content: "greeting=你好世界\n", encoding: "GBK", identity: "fixture-disk-identity", +})); +mock.module("@/platform/tauri-core", () => ({ + invoke: async (command: string, args: unknown) => { + if (command === "read_document_file_details") return readNativeDocument(args); + if (command === "document.classifyText") return classifyText((args as { text: string }).text); + throw new Error(`Unexpected native operation: ${command}`); + }, +})); + +const { createSessionRestoreController, SESSION_RESTORE_CONCURRENCY, loadFileContent } = await import("./workspace-session-restore"); interface Deferred { @@ -48,6 +63,9 @@ async function flushRestoreWork() { } beforeEach(() => { + readNativeDocument.mockClear(); + classifyText.mockReset(); + classifyText.mockResolvedValue({ isPlainText: true }); readFileContent.mockReset(); readFileContent.mockResolvedValue(""); }); @@ -131,8 +149,9 @@ describe("createSessionRestoreController", () => { expect(maxInFlight).toBe(SESSION_RESTORE_CONCURRENCY); // Resolving one job lets the next start without exceeding the cap. + const completed = h.controller.loadNow({ bufferId: "id_a.ts", path: "a.ts" }); pending.get("a.ts")!.resolve("content"); - await flushRestoreWork(); + await completed; expect(maxInFlight).toBe(SESSION_RESTORE_CONCURRENCY); expect(readFileContent).toHaveBeenCalledTimes(SESSION_RESTORE_CONCURRENCY + 1); }); @@ -170,8 +189,9 @@ describe("createSessionRestoreController", () => { h.controller.promote("id_c.ts"); expect(readOrder).toEqual(["a.ts", "b.ts"]); + const completed = h.controller.loadNow({ bufferId: "id_a.ts", path: "a.ts" }); pending.get("a.ts")!.resolve("content"); - await flushRestoreWork(); + await completed; expect(readOrder).toEqual(["a.ts", "b.ts", "c.ts"]); expect(h.controller.pendingCount()).toBe(1); // d.ts still queued @@ -271,8 +291,9 @@ describe("createSessionRestoreController", () => { h.validPaths.set("id_1", "a-renamed.ts"); h.controller.enqueue([{ bufferId: "id_1", path: "a.ts" }]); + const completed = h.controller.loadNow({ bufferId: "id_1", path: "a.ts" }); pending.resolve("old content"); - await flushRestoreWork(); + await completed; expect(h.applyLoaded).not.toHaveBeenCalled(); expect(h.markUnloaded).toHaveBeenCalledWith("id_1", "a.ts"); @@ -308,8 +329,10 @@ describe("createSessionRestoreController", () => { h.controller.enqueue([{ bufferId: "id_1", path: "a.ts" }]); h.setCurrent(false); // a newer restore replaced this controller while reading + const completed = h.controller.loadNow({ bufferId: "id_1", path: "a.ts" }); + expect(readFileContent).toHaveBeenCalledTimes(1); pending.get("a.ts")!.resolve("content"); - await flushRestoreWork(); + await completed; expect(h.applyLoaded).not.toHaveBeenCalled(); expect(h.markFailed).not.toHaveBeenCalled(); @@ -330,8 +353,10 @@ describe("createSessionRestoreController", () => { h.controller.enqueue([{ bufferId: "id_1", path: "a.ts" }]); h.validPaths.delete("id_1"); // tab closed while reading + const completed = h.controller.loadNow({ bufferId: "id_1", path: "a.ts" }); + expect(readFileContent).toHaveBeenCalledTimes(1); pending.get("a.ts")!.resolve("content"); - await flushRestoreWork(); + await completed; expect(h.applyLoaded).not.toHaveBeenCalled(); }); @@ -369,8 +394,40 @@ describe("createSessionRestoreController", () => { expect(h.controller.pendingCount()).toBe(0); // Completing an already-in-flight read must not surface a result. + const completed = h.controller.loadNow({ bufferId: "id_a.ts", path: "a.ts" }); pending.get("a.ts")!.resolve("content"); - await flushRestoreWork(); + await completed; expect(h.applyLoaded).not.toHaveBeenCalled(); }); }); + +describe("shared open and restore content routing", () => { + test("Chinese properties and unknown suffixes reach the text editor", async () => { + const content = "greeting=你好世界\nfarewell=下次再见\n"; + readFileContent.mockResolvedValue(content); + expect(await loadFileContent("app.properties")).toMatchObject({ kind: "text", content, language: "ini" }); + expect(await loadFileContent("app.unrecognized")).toMatchObject({ kind: "text", content }); + expect(classifyText).toHaveBeenLastCalledWith(content); + }); + + test("local files preserve native encoding and identity through classification", async () => { + const loaded = await loadFileContent("C:/workspace/messages.properties", "GBK"); + expect(readNativeDocument).toHaveBeenCalledWith({ path: "C:/workspace/messages.properties", encoding: "GBK" }); + expect(loaded).toEqual({ kind: "text", content: "greeting=你好世界\n", language: "ini", + encoding: "GBK", diskIdentity: "fixture-disk-identity" }); + expect(classifyText).toHaveBeenCalledWith("greeting=你好世界\n"); + expect(readFileContent).not.toHaveBeenCalled(); + }); + + test("Core rejection routes even a known text suffix to the binary viewer", async () => { + classifyText.mockResolvedValue({ isPlainText: false }); + readFileContent.mockResolvedValue("hello\0world"); + expect(await loadFileContent("app.java")).toEqual({ kind: "binary" }); + }); + + 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"); + expect(classifyText).not.toHaveBeenCalled(); + }); +}); diff --git a/windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts b/windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts index 551240bc0..0c0ab4cd8 100644 --- a/windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts +++ b/windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts @@ -35,11 +35,19 @@ export interface LoadedFileContent { diskIdentity?: string; } +async function classifyLoadedText(value: LoadedFileContent): Promise { + // Core owns the same policy on macOS and Windows. Decoding failures remain + // read errors; a missing tokenizer never makes content binary. + const result = await invoke<{ isPlainText: boolean }>("document.classifyText", { + text: value.content ?? "", + }); + return result.isPlainText ? value : { kind: "binary" }; +} + /** - * Read a file's content and type in a store-independent way, shared by the - * session-restore path. This intentionally does not consult the global - * `latestFileOpenRequestId` used by `handleFileSelect`, so concurrent restores - * are never misclassified as stale. + * Shared content loading for initial opens and session restoration. File I/O + * and codecs belong to native adapters; Core classifies decoded Unicode. + * Cancellation/stale-result checks belong to the caller that owns the tab. */ export async function loadFileContent(path: string, encoding?: FileEncoding): Promise { if (getDatabaseTypeFromPath(path)) return { kind: "database" }; @@ -56,7 +64,7 @@ export async function loadFileContent(path: string, encoding?: FileEncoding): Pr connectionId: match[1], filePath: match[2] || "/", }); - return { kind: "text", content, language }; + return classifyLoadedText({ kind: "text", content, language }); } const wslInfo = parseWslPath(path); @@ -65,22 +73,22 @@ export async function loadFileContent(path: string, encoding?: FileEncoding): Pr distro: wslInfo.distro, filePath: wslInfo.linuxPath, }); - return { kind: "text", content, language }; + return classifyLoadedText({ kind: "text", content, language }); } if (encoding || isLocalDocumentPath(path)) { const details = await readDocumentFileDetails(path, encoding); if (!details || details.content === null) throw new Error("File no longer exists"); - return { + return classifyLoadedText({ kind: "text", content: details.content, language, encoding: details.encoding, diskIdentity: details.identity, - }; + }); } const content = await readFileContent(path); - return { kind: "text", content, language, encoding: "UTF-8" }; + return classifyLoadedText({ kind: "text", content, language, encoding: "UTF-8" }); } export interface RestoreJob { @@ -226,8 +234,9 @@ export function createSessionRestoreController( loadNow(job) { if (disposed) return Promise.resolve(); - const activeCompletion = completionByBufferId.get(job.bufferId); - if (activeByBufferId.has(job.bufferId)) return activeCompletion?.promise ?? Promise.resolve(); + // Joining a background read must await decoding and classification even + // when no caller previously requested a completion promise. + if (activeByBufferId.has(job.bufferId)) return completionFor(job.bufferId).promise; if (completedByBufferId.get(job.bufferId) === job.path) return Promise.resolve(); completedByBufferId.delete(job.bufferId); diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 574ca50e1..7e3a778d1 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -1,7 +1,8 @@ +import { loadFileContent } from "../controllers/workspace-session-restore"; import { invoke } from "@/platform/tauri-core"; import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; import { basename, dirname, extname, join } from "@tauri-apps/api/path"; -import { copyFile, readFile } from "@tauri-apps/plugin-fs"; +import { copyFile } from "@tauri-apps/plugin-fs"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { immer } from "zustand/middleware/immer"; import type { StoreApi } from "zustand"; @@ -75,7 +76,6 @@ import { createNewFile, deleteFileOrDirectory, readDirectoryContents, - readFileContentWithEncoding, } from "../controllers/file-operations"; import { chooseProjectOpenDestination, @@ -93,7 +93,6 @@ import { import { getDatabaseTypeFromPath, getFilenameFromPath, - isBinaryContent, isBinaryFile, isKnownTextFile, isImageFile, @@ -1745,89 +1744,6 @@ const createFileSystemStore = (workspaceId: string): StoreApi - readFile(resolvedPath), - ); - - if (isStaleRequest()) return; - - if (isBinaryContent(fileData)) { - openBuffer( - path, - fileName, - "", - false, - undefined, - false, - false, - undefined, - false, - false, - false, - undefined, - false, - false, - true, - ); - recordLocalFileAccess( - path, - fileName, - workspaceRootPath, - getWorkspaceFolderPaths(get), - ); - fileOpenBenchmark.finish(path, "binary-sniff-buffer-opened"); - return; - } - - } catch (error) { - console.error("Failed to inspect file bytes before opening:", error); - } - } else if (wslInfo && !resolvedKnownTextPath) { - try { - const fileData = await readFileOnce( - `wsl-bytes:${wslInfo.distro}:${wslInfo.linuxPath}`, - () => - invoke("wsl_read_file_bytes", { - distro: wslInfo.distro, - filePath: wslInfo.linuxPath, - }), - ); - - if (isStaleRequest()) return; - - const bytes = new Uint8Array(fileData); - if (isBinaryContent(bytes)) { - openBuffer( - path, - fileName, - "", - false, - undefined, - false, - false, - undefined, - false, - false, - false, - undefined, - false, - false, - true, - ); - fileOpenBenchmark.finish(path, "binary-sniff-buffer-opened"); - return; - } - - } catch (error) { - console.error("Failed to inspect WSL file bytes before opening:", error); - } - } - // Check if external editor is enabled for text files const { settings } = useSettingsStore.getState(); const { openExternalEditorBuffer } = useBufferStore @@ -1869,39 +1785,42 @@ const createFileSystemStore = (workspaceId: string): StoreApi - invoke("ssh_read_file", { - connectionId, - filePath: remotePath, - }), + // An explicitly selected external editor owns decoding and size limits. + // Only the built-in editor needs the shared document classification. + const contentPath = wslInfo ? path : resolvedPath; + const loaded = await readFileOnce(`document:${contentPath}`, () => loadFileContent(contentPath)); + if (isStaleRequest()) return; + if (loaded.kind === "binary") { + openBuffer( + path, + fileName, + "", + false, + undefined, + false, + false, + undefined, + false, + false, + false, + undefined, + false, + false, + true, ); - } else if (wslInfo) { - content = await readFileOnce(`wsl-text:${wslInfo.distro}:${wslInfo.linuxPath}`, () => - invoke("wsl_read_file", { - distro: wslInfo.distro, - filePath: wslInfo.linuxPath, - }), - ); - } else { - const details = await readFileOnce(`local-details:${resolvedPath}`, () => - readFileContentWithEncoding(resolvedPath), + recordLocalFileAccess( + path, + fileName, + workspaceRootPath, + getWorkspaceFolderPaths(get), ); - content = details.content ?? ""; - encoding = details.encoding; - diskIdentity = details.identity; + fileOpenBenchmark.finish(path, "binary-content-buffer-opened"); + return; } + + const content = loaded.content ?? ""; + const encoding = loaded.encoding; + const diskIdentity = loaded.diskIdentity; fileOpenBenchmark.mark(path, "file-read", `${content.length} chars`); if (isStaleRequest()) return; diff --git a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx index 18e5c3cce..2b0ad2a78 100644 --- a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx +++ b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { Checkbox } from "@/ui/checkbox"; import { Button } from "@/ui/button"; import { MinusIcon as Minus, PlusIcon as Plus } from "@/ui/icons"; +import { Spinner } from "@/ui/spinner"; import { SidebarTreeRow } from "@/features/sidebar/components/sidebar-tree"; import { FILE_TREE_BASE_INDENT } from "@/features/file-explorer/lib/file-tree-row"; import { cn } from "@/utils/cn"; @@ -75,11 +76,7 @@ export function GitFileStageAction({ onContextMenu={(event) => event.stopPropagation()} > {pending ? ( - + ) : staged ? ( ) : ( diff --git a/windows/tauri/src/features/keymaps/commands/window-command-actions.ts b/windows/tauri/src/features/keymaps/commands/window-command-actions.ts index 3d219dcd8..2e854723c 100644 --- a/windows/tauri/src/features/keymaps/commands/window-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/window-command-actions.ts @@ -1,14 +1,21 @@ import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { createAppWindow } from "@/features/window/utils/create-app-window"; +import { + maximizeCurrentWindow, + minimizeCurrentWindow, + toggleCurrentWindowFullscreen, +} from "@/features/window/utils/window-actions"; import { isLinux, isMac } from "@/utils/platform"; export function toggleFullscreen(): void { - window.dispatchEvent(new CustomEvent("toggle-fullscreen")); + void toggleCurrentWindowFullscreen().catch((error: unknown) => + console.error("Error toggling fullscreen:", error), + ); } export function toggleFullscreenMac(): void { if (isMac()) { - window.dispatchEvent(new CustomEvent("toggle-fullscreen")); + toggleFullscreen(); } } @@ -17,24 +24,28 @@ export function createNewWindow(): void { } export function minimizeWindow(): void { - window.dispatchEvent(new CustomEvent("minimize-window")); + void minimizeCurrentWindow().catch((error: unknown) => + console.error("Error minimizing window:", error), + ); } export function minimizeWindowMac(): void { if (isMac()) { - window.dispatchEvent(new CustomEvent("minimize-window")); + minimizeWindow(); } } export function minimizeWindowAlt(): void { if (!isMac()) { - window.dispatchEvent(new CustomEvent("minimize-window")); + minimizeWindow(); } } export function maximizeWindow(): void { if (!isMac()) { - window.dispatchEvent(new CustomEvent("maximize-window")); + void maximizeCurrentWindow().catch((error: unknown) => + console.error("Error maximizing window:", error), + ); } } diff --git a/windows/tauri/src/features/layout/components/main-layout.tsx b/windows/tauri/src/features/layout/components/main-layout.tsx index 18e071cdd..586d134c1 100644 --- a/windows/tauri/src/features/layout/components/main-layout.tsx +++ b/windows/tauri/src/features/layout/components/main-layout.tsx @@ -8,6 +8,7 @@ import { useGitStore } from "@/features/git/stores/git.store"; import { isGitChangeRelevant, subscribeToGitChanges } from "@/features/git/events/git-events"; import { closeMavenToolWindow } from "@/features/maven/actions/maven-tool-window-actions"; import { useMavenStore } from "@/features/maven/stores/maven.store"; +import { useMavenResolutionNotifications } from "@/features/maven/hooks/use-maven-resolution-problems"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useOnboardingStore } from "@/features/onboarding/stores/onboarding.store"; import { CachedWorkspaceSplitViews } from "@/features/panes/components/split-view-root"; @@ -37,10 +38,10 @@ import Footer from "./footer/footer"; import { WorkbenchErrorBoundary } from "./workbench-error-boundary"; import { ResizablePane } from "./resizable-pane"; import { - COLLAPSED_ACTIVITY_RAIL_WIDTH, MainSidebar, SidebarActivityRail, } from "./sidebar/main-sidebar"; +import { COLLAPSED_ACTIVITY_RAIL_WIDTH } from "@/features/layout/constants/activity-rail"; import { PluginActivityRail } from "./plugin-activity-rail"; import { WelcomeScreen } from "./welcome-screen"; import { AppUpdateDetailsDialog } from "./app-update-details-dialog"; @@ -82,6 +83,7 @@ const MavenPane = lazy(() => import("@/features/maven/components/maven-pane")); export function MainLayout() { const { t } = useTranslation(); useAutoUpdate(); + useMavenResolutionNotifications(); const [deferredSurfacesReady, setDeferredSurfacesReady] = useState(false); usePaneKeyboard(); diff --git a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx index 9a7f6792c..fc760d65a 100644 --- a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx @@ -18,6 +18,7 @@ import GlobalSearchBuffer from "@/features/global-search/components/global-searc import { SidebarPaneSelector } from "@/features/layout/components/sidebar/sidebar-pane-selector"; import { SidebarProjectDots } from "@/features/layout/components/sidebar/sidebar-projects"; import { useSidebarPaneController } from "@/features/layout/hooks/use-sidebar-pane-controller"; +import { COLLAPSED_ACTIVITY_RAIL_WIDTH } from "@/features/layout/constants/activity-rail"; import { getAdjacentProjectIndex, getProjectCarouselDirection, @@ -91,7 +92,6 @@ interface SidebarActivityRailProps { expanded?: boolean; } -export const COLLAPSED_ACTIVITY_RAIL_WIDTH = 38; const DEFAULT_ACTIVITY_RAIL_WIDTH = 160; const MIN_ACTIVITY_RAIL_WIDTH = 140; const MAX_ACTIVITY_RAIL_WIDTH = 320; diff --git a/windows/tauri/src/features/layout/constants/activity-rail.ts b/windows/tauri/src/features/layout/constants/activity-rail.ts new file mode 100644 index 000000000..4b39b8e22 --- /dev/null +++ b/windows/tauri/src/features/layout/constants/activity-rail.ts @@ -0,0 +1 @@ +export const COLLAPSED_ACTIVITY_RAIL_WIDTH = 38; diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 9fe009ef6..454e9764a 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -37,6 +37,10 @@ import { cn } from "@/utils/cn"; import { joinPath } from "@/utils/path-helpers"; import { openMavenRunPane } from "../actions/maven-tool-window-actions"; import { ensureMavenProcessListeners } from "../hooks/use-maven-process-events"; +import { + useMavenResolutionProblems, + useOpenMavenResolutionProblem, +} from "../hooks/use-maven-resolution-problems"; import { availableMavenProfiles, useMavenStore } from "../stores/maven.store"; import { reloadJavaForMavenWorkspace, @@ -151,6 +155,9 @@ export default function MavenPane({ onClose }: MavenPaneProps) { const customProfiles = useMavenStore((state) => state.customProfiles); const skipTests = useMavenStore((state) => state.skipTests); const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const javaConfigurationError = useMavenStore((state) => state.javaConfigurationError); + const resolutionProblems = useMavenResolutionProblems(); + const openResolutionProblem = useOpenMavenResolutionProblem(); const reloadRequired = useMavenStore((state) => state.reloadRequired); const projectReloadRequired = useMavenStore((state) => state.projectReloadRequired); const taskStatus = useMavenStore((state) => state.taskStatus); @@ -712,6 +719,9 @@ export default function MavenPane({ onClose }: MavenPaneProps) { {configurationSaveError ?? + (javaConfigurationError + ? t("maven.javaConfigurationFailed", { error: javaConfigurationError }) + : null) ?? moduleOperationError ?? reloadError ?? projectError ?? @@ -729,6 +739,35 @@ export default function MavenPane({ onClose }: MavenPaneProps) { ) : null} + {project && resolutionProblems.length > 0 ? ( +
+
+ + + {t("maven.resolutionProblems", { count: resolutionProblems.length })} + +
+ {resolutionProblems.map((problem) => ( + + ))} +
+ ) : null} + {!project && projectStatus === "failed" ? (
diff --git a/windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts b/windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts new file mode 100644 index 000000000..2676bd697 --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-resolution-problems.ts @@ -0,0 +1,68 @@ +import { useEffect, useMemo, useRef } from "react"; +import { toast } from "sonner"; +import { useDiagnosticsStore } from "@/features/diagnostics/stores/diagnostics.store"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { useMavenStore } from "../stores/maven.store"; +import { + mavenResolutionProblems, + mavenResolutionProblemsSignature, + type MavenResolutionProblem, +} from "../utils/maven-resolution-problems"; + +/** Maven problems JDT LS currently reports for the active workspace. */ +export function useMavenResolutionProblems(): MavenResolutionProblem[] { + const root = useMavenStore((state) => state.root); + const diagnosticsByOwner = useDiagnosticsStore.use.diagnosticsByOwner(); + return useMemo( + () => mavenResolutionProblems(root, diagnosticsByOwner), + [root, diagnosticsByOwner], + ); +} + +/** Opens the `pom.xml` location a problem points at. */ +export function useOpenMavenResolutionProblem(): (problem: MavenResolutionProblem) => void { + const handleFileSelect = useFileSystemStore.use.handleFileSelect?.(); + return (problem) => { + void handleFileSelect?.(problem.pomPath, false, problem.line + 1, problem.column + 1, undefined, false); + }; +} + +/** + * Notifies once per distinct set of Maven problems in the active workspace. + * + * Mounted by the main layout so the notice appears even when the Maven tool + * window is closed: a failed import is otherwise indistinguishable from a + * successful one until every third-party import turns red (#970). + */ +export function useMavenResolutionNotifications(): void { + const { t } = useTranslation(); + const root = useMavenStore((state) => state.root); + const problems = useMavenResolutionProblems(); + const openProblem = useOpenMavenResolutionProblem(); + const signature = mavenResolutionProblemsSignature(problems); + const notified = useRef(new Map()); + const latest = useRef({ problems, openProblem, t }); + latest.current = { problems, openProblem, t }; + + useEffect(() => { + if (!root) return; + const id = `maven-resolution:${root}`; + if (!signature) { + // Keep the last signature: the same failure reappearing after a rebuild + // is still visible in the Maven tool window and needs no second notice. + toast.dismiss(id); + return; + } + if (notified.current.get(root) === signature) return; + notified.current.set(root, signature); + const { problems: current, openProblem: open, t: translate } = latest.current; + const first = current[0]!; + toast.warning(translate("maven.resolutionProblems", { count: current.length }), { + id, + description: first.message, + duration: Infinity, + action: { label: translate("maven.openPom"), onClick: () => open(first) }, + }); + }, [root, signature]); +} diff --git a/windows/tauri/src/features/maven/services/java-maven-configuration.ts b/windows/tauri/src/features/maven/services/java-maven-configuration.ts new file mode 100644 index 000000000..7f73ec287 --- /dev/null +++ b/windows/tauri/src/features/maven/services/java-maven-configuration.ts @@ -0,0 +1,35 @@ +import { invokeLsp } from "@/platform/lsp-core-adapter"; +import type { MavenLaunchContext } from "../types/maven.types"; + +/** What the running Java language session did with a Maven configuration. */ +export type JavaMavenConfigurationUpdate = + | { kind: "noSession" } + | { + kind: "updated"; + /** JDT LS received new settings documents and re-imports on its own. */ + settingsChanged: boolean; + /** Settings were unchanged, so a forced project update was requested. */ + projectsReloaded: boolean; + /** Changed profiles are being applied. */ + profilesUpdating: boolean; + }; + +/** + * Sends the workspace's Maven context to its running Java language session. + * + * JDT LS applies the change itself: new settings make it force-update every + * Maven project, and `reloadProjects` re-resolves dependencies even when + * nothing changed. Restarting the session instead would reuse its workspace + * state and skip every project whose `pom.xml` did not change (#970). + */ +export function updateJavaMavenConfiguration( + root: string, + mavenContext: MavenLaunchContext, + reloadProjects: boolean, +): Promise { + return invokeLsp("lsp_update_maven_configuration", { + workspacePath: root, + mavenContext, + reloadProjects, + }); +} diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts index 0e058e5b5..e75452589 100644 --- a/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts @@ -5,10 +5,21 @@ import type { MavenReloadSnapshot } from "../stores/maven.store"; import { reloadJavaForMavenWorkspace, reloadMavenWorkspaceProjects, + type MavenWorkspaceReloadDependencies, } from "./reload-maven-workspace"; afterEach(() => workspaceRuntimeRegistry.resetForTests()); +/** + * Dependencies for scenarios where no Java session can take the reload in + * place, which exercises the restart path. + */ +const withoutRunningJavaSession = { + getMavenContext: () => null, + updateJavaMavenConfiguration: mock(async () => ({ kind: "noSession" as const })), + trace: mock(() => undefined), +}; + type Deferred = { promise: Promise; resolve(value: T): void; @@ -112,6 +123,7 @@ test("finishes workspace A reload without reading or mutating active workspace B hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), getMavenState, getFileSystemState, + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop, prewarm }), }); @@ -165,6 +177,7 @@ test("does not recreate stores after the workspace is closed", async () => { const outcome = await reloadJavaForMavenWorkspace( { workspaceId: "closed-workspace", root: "D:/closed" }, { + ...withoutRunningJavaSession, hasWorkspace: () => false, getMavenState, getFileSystemState, @@ -211,6 +224,7 @@ test("acknowledges only the configuration revision active when Java reload start rootFolderPath: "D:/work", getAllProjectFiles: () => projectFiles.promise, }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop: mock(async () => undefined), prewarm: mock(async () => ({ kind: "ready" as const })), @@ -262,6 +276,7 @@ test("does not restart Java when the Maven scan fails with an old project", asyn rootFolderPath: "D:/work", getAllProjectFiles: async () => [], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop, prewarm }), }, ); @@ -304,6 +319,7 @@ test("coalesces concurrent reload requests for the same workspace root", async ( rootFolderPath: "D:/work", getAllProjectFiles: async () => [], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop: mock(async () => undefined), prewarm: mock(async () => ({ kind: "ready" as const })), @@ -362,6 +378,7 @@ test("does not coalesce case-sensitive workspace roots", async () => { rootFolderPath: root, getAllProjectFiles: async () => [], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop: mock(async () => undefined), prewarm: mock(async () => ({ kind: "ready" as const })), @@ -430,6 +447,7 @@ test("refreshes Java after the last Maven descriptor is removed", async () => { { name: "Main.java", path: "D:/work/src/Main.java", isDir: false }, ], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop, prewarm }), }, ); @@ -477,6 +495,7 @@ test("keeps reload actionable when Java restart fails", async () => { rootFolderPath: "D:/work", getAllProjectFiles: async () => [], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop: mock(async () => { throw new Error("JDT LS did not stop"); @@ -532,6 +551,7 @@ test("keeps reload actionable when Java preparation reports failure", async () = { name: "Main.java", path: "D:/work/src/Main.java", isDir: false }, ], }), + ...withoutRunningJavaSession, getJavaOwner: () => ({ stop: mock(async () => undefined), prewarm: mock(async () => ({ kind: "timedOut" as const })), @@ -548,3 +568,109 @@ test("keeps reload actionable when Java preparation reports failure", async () = "Unable to reload the Java language server.", ); }); + +function singleWorkspaceReload(options: { + updateJavaMavenConfiguration: MavenWorkspaceReloadDependencies["updateJavaMavenConfiguration"]; +}) { + const acknowledgeReload = mock((_revision?: number) => undefined); + const maven = { + root: "D:/work" as string | null, + visiblePaths: ["pom.xml"], + projectStatus: "ready" as const, + projectError: null as string | null, + reloadRevision: 4, + projectReloadRevision: 0, + project: mavenProject("demo") as MavenProject | null, + actions: { + loadProject: mock(async () => undefined), + acknowledgeReload, + restoreReloadSnapshot: mock(() => undefined), + }, + }; + const fileSystem = { + rootFolderPath: "D:/work", + getAllProjectFiles: mock(async () => [ + { name: "Main.java", path: "D:/work/src/Main.java", isDir: false }, + ]), + }; + const stop = mock(async () => undefined); + const prewarm = mock(async () => ({ kind: "ready" as const })); + const context = { + version: 1 as const, + reactorPath: ".", + profiles: [], + settingsPath: "D:/maven/conf/settings.xml", + localRepositoryPath: "D:/dev/.m2/repository", + skipTests: false, + mavenExecutablePath: "D:/maven/bin/mvn.cmd", + javaHomePath: null, + }; + const dependencies: MavenWorkspaceReloadDependencies = { + hasWorkspace: () => true, + getMavenState: () => maven, + getMavenContext: () => context, + getFileSystemState: () => fileSystem, + getJavaOwner: () => ({ stop, prewarm }), + updateJavaMavenConfiguration: options.updateJavaMavenConfiguration, + trace: mock(() => undefined), + }; + return { acknowledgeReload, context, dependencies, prewarm, stop }; +} + +test("reloads a running Java session in place with a forced Maven project update", async () => { + // #970: restarting reused the JDT LS workspace state and skipped every + // project whose pom.xml had not changed, so missing artifacts stayed missing. + const updateJavaMavenConfiguration = mock(async () => ({ + kind: "updated" as const, + settingsChanged: false, + projectsReloaded: true, + profilesUpdating: false, + })); + const reload = singleWorkspaceReload({ updateJavaMavenConfiguration }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "workspace", root: "D:/work" }, + reload.dependencies, + ); + + expect(outcome).toBe("completed"); + expect(updateJavaMavenConfiguration).toHaveBeenCalledWith("D:/work", reload.context, true); + expect(reload.stop).not.toHaveBeenCalled(); + expect(reload.prewarm).not.toHaveBeenCalled(); + expect(reload.acknowledgeReload).toHaveBeenCalledWith(4); +}); + +test("starts the Java session when none is running to reload", async () => { + const reload = singleWorkspaceReload({ + updateJavaMavenConfiguration: mock(async () => ({ kind: "noSession" as const })), + }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "workspace", root: "D:/work" }, + reload.dependencies, + ); + + expect(outcome).toBe("completed"); + expect(reload.prewarm).toHaveBeenCalledWith( + { workspaceId: "workspace", root: "D:/work" }, + "D:/work/src/Main.java", + ); +}); + +test("restarts a Java session that cannot take the reload", async () => { + const reload = singleWorkspaceReload({ + updateJavaMavenConfiguration: mock(async () => { + throw new Error("The Java language session is no longer running."); + }), + }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "workspace", root: "D:/work" }, + reload.dependencies, + ); + + expect(outcome).toBe("completed"); + expect(reload.stop).toHaveBeenCalledTimes(1); + expect(reload.prewarm).toHaveBeenCalledTimes(1); + expect(reload.acknowledgeReload).toHaveBeenCalledWith(4); +}); diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts index 1ae52ab87..6dd2ab585 100644 --- a/windows/tauri/src/features/maven/services/reload-maven-workspace.ts +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts @@ -7,8 +7,17 @@ import { workspaceScopeMatchesRoot, type WorkspaceLaunchScope, } from "@/features/workspace/types/workspace-launch-scope"; -import type { MavenProject } from "../types/maven.types"; -import { useMavenStore, type MavenReloadSnapshot } from "../stores/maven.store"; +import type { MavenLaunchContext, MavenProject } from "../types/maven.types"; +import { + currentMavenLaunchContext, + useMavenStore, + type MavenReloadSnapshot, +} from "../stores/maven.store"; +import { + updateJavaMavenConfiguration, + type JavaMavenConfigurationUpdate, +} from "./java-maven-configuration"; +import { frontendTrace } from "@/utils/frontend-trace"; interface MavenReloadState { root: string | null; @@ -55,8 +64,15 @@ interface JavaWorkspaceReloadOwner { export interface MavenWorkspaceReloadDependencies { hasWorkspace(workspaceId: string): boolean; getMavenState(workspaceId: string): MavenReloadState; + getMavenContext(scope: WorkspaceLaunchScope): MavenLaunchContext | null; getFileSystemState(workspaceId: string): FileSystemReloadState; getJavaOwner(): JavaWorkspaceReloadOwner; + updateJavaMavenConfiguration( + root: string, + context: MavenLaunchContext, + reloadProjects: boolean, + ): Promise; + trace: typeof frontendTrace; } export type MavenWorkspaceReloadOutcome = "completed" | "failed" | "noProject" | "stale"; @@ -73,8 +89,11 @@ function reloadKey(scope: WorkspaceLaunchScope): string { const defaultDependencies: MavenWorkspaceReloadDependencies = { hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), getMavenState: (workspaceId) => useMavenStore.getStore(workspaceId).getState(), + getMavenContext: (scope) => currentMavenLaunchContext(scope.root, scope.workspaceId), getFileSystemState: (workspaceId) => useFileSystemStore.getStore(workspaceId).getState(), getJavaOwner: getJavaWorkspaceLanguageServerOwner, + updateJavaMavenConfiguration, + trace: frontendTrace, }; function scopedStates( @@ -99,6 +118,31 @@ export async function reloadJavaForMavenWorkspace( if (!states) return "stale"; const targetReloadRevision = reloadRevision ?? states.maven.reloadRevision; + // A running session re-resolves in place through JDT LS's forced project + // update. Restarting it would reuse the workspace state and skip every + // project whose pom.xml did not change, keeping artifacts that failed to + // resolve missing (#970). A restart remains the recovery for a session that + // cannot take the update, and the way to start one that is not running. + const context = dependencies.getMavenContext(scope); + if (context) { + try { + const update = await dependencies.updateJavaMavenConfiguration(scope.root, context, true); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (update.kind === "updated") { + states.maven.actions.acknowledgeReload(targetReloadRevision); + return "completed"; + } + } catch (error) { + dependencies.trace("warn", "maven.java", "Java language session could not reload Maven projects; restarting it", { + workspaceId: scope.workspaceId, + error: error instanceof Error ? error.message : String(error), + }); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + } + } + const files = await states.fileSystem.getAllProjectFiles(); states = scopedStates(scope, dependencies); if (!states) return "stale"; diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 594699127..d94002c71 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -16,12 +16,26 @@ import { type MavenReloadSnapshot, type MavenStoreDependencies, } from "./maven.store"; +import type { JavaMavenConfigurationUpdate } from "../services/java-maven-configuration"; type Deferred = { promise: Promise; resolve: (value: T) => void; }; +/** + * Drains promise continuations until `condition` holds. The store's Java sync + * chain only awaits mocked promises, so a bounded number of microtask turns is + * a deterministic upper bound; exceeding it means the chain stalled. + */ +async function waitFor(condition: () => boolean, turns = 200): Promise { + for (let turn = 0; turn < turns; turn += 1) { + if (condition()) return; + await Promise.resolve(); + } + throw new Error(`condition did not hold after ${turns} microtask turns`); +} + function deferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((complete) => { @@ -164,6 +178,13 @@ const resolveEffectiveMavenExecutable = mock( ); const resolveJavaTestClass = mock(async (_root: string, _file: string, className: string): Promise => className); +const updateJavaMavenConfiguration = mock( + async ( + _root: string, + _context: unknown, + _reloadProjects: boolean, + ): Promise => ({ kind: "noSession" }), +); const dependencies = { createMavenPomWatchOperations, @@ -184,10 +205,13 @@ const dependencies = { startMavenProcess, stopMavenProcess, trace, + updateJavaMavenConfiguration, writeMavenConfiguration, } satisfies MavenStoreDependencies; beforeEach(() => { + updateJavaMavenConfiguration.mockReset(); + updateJavaMavenConfiguration.mockResolvedValue({ kind: "noSession" }); resolveJavaTestClass.mockReset(); resolveJavaTestClass.mockImplementation(async (_root, _file, className) => className); scanMavenProject.mockReset(); @@ -870,15 +894,22 @@ describe("Maven workspace state", () => { expect(store.getState().projectError).toBeNull(); }); - test("keeps configuration-only reloads Java-only and preserves a failed write", async () => { + test("sends configuration-only changes to the running Java session and preserves a failed write", async () => { const store = createMavenStore("workspace", dependencies); await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); writeMavenConfiguration.mockRejectedValueOnce(new Error("Unable to save settings")); store.getState().actions.setSkipTests(true); + await waitFor(() => updateJavaMavenConfiguration.mock.calls.length === 1); - expect(store.getState().reloadRequired).toBe(true); + // #970: JDT LS takes the change in place, so no reload prompt appears. + expect(store.getState().reloadRequired).toBe(false); expect(store.getState().projectReloadRequired).toBe(false); + expect(updateJavaMavenConfiguration).toHaveBeenCalledWith( + "D:/work", + expect.objectContaining({ reactorPath: "reactor", skipTests: true }), + false, + ); await store .getState() .actions.loadProject("D:/work", [...store.getState().visiblePaths]); @@ -889,6 +920,71 @@ describe("Maven workspace state", () => { expect(store.getState().projectReloadRequired).toBe(true); }); + test("sends the latest Maven settings after a burst of edits", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + const first = deferred(); + updateJavaMavenConfiguration.mockImplementationOnce(() => first.promise); + + store.getState().actions.updateLocalConfiguration({ + settingsPath: "D:/maven/conf/settings.xml", + localRepositoryPath: "", + mavenExecutablePath: "D:/maven/bin/mvn.cmd", + javaHomePath: "", + }); + store.getState().actions.updateLocalConfiguration({ + settingsPath: "D:/maven/conf/settings.xml", + localRepositoryPath: "D:/dev/.m2/repository", + mavenExecutablePath: "D:/maven/bin/mvn.cmd", + javaHomePath: "", + }); + await waitFor(() => updateJavaMavenConfiguration.mock.calls.length === 1); + first.resolve({ kind: "noSession" }); + await waitFor(() => updateJavaMavenConfiguration.mock.calls.length === 2); + + const last = updateJavaMavenConfiguration.mock.calls[1]![1] as Record; + expect(last.settingsPath).toBe("D:/maven/conf/settings.xml"); + expect(last.localRepositoryPath).toBe("D:/dev/.m2/repository"); + expect(last.mavenExecutablePath).toBe("D:/maven/bin/mvn.cmd"); + }); + + test("resolves automatic Maven selection before sending settings to Java", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + store.getState().actions.updateLocalConfiguration({ + settingsPath: "", + localRepositoryPath: "", + mavenExecutablePath: "D:/maven/bin/mvn.cmd", + javaHomePath: "", + }); + await waitFor(() => updateJavaMavenConfiguration.mock.calls.length === 1); + resolveEffectiveMavenExecutable.mockResolvedValueOnce("D:/detected/bin/mvn.cmd"); + + store.getState().actions.updateLocalConfiguration({ + settingsPath: "", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + await waitFor(() => updateJavaMavenConfiguration.mock.calls.length === 2); + + const context = updateJavaMavenConfiguration.mock.calls[1]![1] as Record; + expect(context.mavenExecutablePath).toBe("D:/detected/bin/mvn.cmd"); + }); + + test("offers a reload when the Java session cannot take a Maven change", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + updateJavaMavenConfiguration.mockRejectedValueOnce( + new Error("The Java language session is no longer running."), + ); + + store.getState().actions.setSkipTests(true); + await waitFor(() => store.getState().reloadRequired); + + expect(store.getState().projectReloadRequired).toBe(false); + }); + test("includes a newly observed nested POM in the next Maven scan", async () => { const store = createMavenStore("workspace", dependencies); await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 054e4a776..d1a3c69b3 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -28,6 +28,7 @@ import { } from "../services/maven-pom-watcher"; import { resolveJavaTestClass } from "../services/java-test-launch-target"; import { resolveEffectiveMavenExecutable } from "../services/resolve-maven-toolchain"; +import { updateJavaMavenConfiguration } from "../services/java-maven-configuration"; import type { MavenDependencyLoad, MavenDiagnostic, @@ -84,6 +85,7 @@ export interface MavenStoreDependencies { startMavenProcess: typeof startMavenProcess; stopMavenProcess: typeof stopMavenProcess; trace: typeof frontendTrace; + updateJavaMavenConfiguration: typeof updateJavaMavenConfiguration; writeMavenConfiguration: typeof writeMavenConfiguration; } @@ -106,6 +108,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { startMavenProcess, stopMavenProcess, trace: frontendTrace, + updateJavaMavenConfiguration, writeMavenConfiguration, }; @@ -145,6 +148,11 @@ export interface MavenState { resolvedMavenExecutablePath: string; javaHomePath: string; configurationSaveError: string | null; + /** + * Why the running Java language session did not take the latest Maven + * configuration. The reload action restarts that session. + */ + javaConfigurationError: string | null; /** * What a Maven launch would use for the saved configuration, shared by every * surface that shows these fields. `null` hides the detected values. @@ -398,14 +406,25 @@ function mavenTestGoals(selector: string): string[] { type MavenLaunchStage = "save-workspace" | "create-plan" | "resolve-launch" | "start-process"; -function mavenLaunchErrorMessage(error: unknown): string { +/** + * The reason a Maven operation failed, as the owning layer reported it. + * + * Host commands reject with strings or structured objects as well as `Error` + * values. Reading only `Error.message` replaced every host reason with the + * generic fallback, which hid why dependency loading failed (#970). + */ +function errorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message.trim()) return error.message; if (typeof error === "string" && error.trim()) return error; if (error && typeof error === "object" && "message" in error) { const message = (error as { message?: unknown }).message; if (typeof message === "string" && message.trim()) return message; } - return "Unable to start the Maven task."; + return fallback; +} + +function mavenLaunchErrorMessage(error: unknown): string { + return errorMessage(error, "Unable to start the Maven task."); } export const createMavenStore = ( @@ -607,7 +626,7 @@ export const createMavenStore = ( if (configurationRevision !== revision) return; set({ configurationSaveError: - error instanceof Error ? error.message : "Unable to save Maven configuration.", + errorMessage(error, "Unable to save Maven configuration."), }); }); }; @@ -633,10 +652,58 @@ export const createMavenStore = ( })); }; + // Maven changes reach the running Java language session in the order they + // were made. Each step reads the latest context, so a burst of changes + // ends with JDT LS holding the final configuration. + let javaConfigurationTask: Promise = Promise.resolve(); + const syncJavaConfiguration = () => { + javaConfigurationTask = javaConfigurationTask + .catch(() => undefined) + .then(async () => { + const state = get(); + if (!state.root || !state.project) return; + const root = state.root; + if (!state.mavenExecutablePath && !state.resolvedMavenExecutablePath) { + // Automatic selection must resolve to the installation a build + // uses; JDT LS reads that installation's settings. + const resolved = await dependencies.resolveEffectiveMavenExecutable(root, ""); + if (get().root !== root || get().mavenExecutablePath) return; + set({ resolvedMavenExecutablePath: resolved }); + } + const context = mavenLaunchContext(get()); + if (!context || get().root !== root) return; + await dependencies.updateJavaMavenConfiguration(root, context, false); + if (get().root === root) set({ javaConfigurationError: null }); + }) + .catch((error: unknown) => { + // The session could not take the change; offer the reload action, + // which restarts a session that is no longer usable. + const message = errorMessage(error, "Unknown error"); + dependencies.trace("warn", "maven.java", "Java language session did not take the Maven configuration", { + workspaceId, + error: message, + }); + markReloadRequired(); + set({ javaConfigurationError: message }); + }); + }; + + // Build configuration changes invalidate results computed under the old + // configuration and go straight to the running Java language session. + // No reload prompt is needed: JDT LS re-resolves on its own. const configurationDidChange = () => { - markReloadRequired(); - set({ configurationSaveError: null }); + invalidateDependencies(); + diagnosticsRevision += 1; + set((state) => ({ + configurationSaveError: null, + testResults: null, + activeTestRun: null, + // The revision still advances so an in-flight reload does not restore + // or acknowledge over a configuration edited meanwhile. + reloadRevision: state.reloadRevision + 1, + })); persistConfiguration(); + syncJavaConfiguration(); }; const reportTestLaunchFailure = (message: string) => { @@ -668,6 +735,7 @@ export const createMavenStore = ( resolvedMavenExecutablePath: "", javaHomePath: "", configurationSaveError: null, + javaConfigurationError: null, effectiveConfiguration: null, effectiveConfigurationStatus: "idle", reloadRequired: false, @@ -844,7 +912,7 @@ export const createMavenStore = ( } catch (error) { if (projectLoadRevision !== revision || get().root !== root) return; const message = - error instanceof Error ? error.message : "Unable to scan the Maven project."; + errorMessage(error, "Unable to scan the Maven project."); if (previous.root === root && previous.project) { set((state) => ({ projectStatus: "failed", @@ -1051,7 +1119,12 @@ export const createMavenStore = ( acknowledgeReload: (revision) => { if (revision !== undefined && get().reloadRevision !== revision) return; - set({ reloadRequired: false, projectReloadRequired: false, projectError: null }); + set({ + reloadRequired: false, + projectReloadRequired: false, + projectError: null, + javaConfigurationError: null, + }); }, runGoals: async (goals, module, title, testRun) => { @@ -1248,7 +1321,7 @@ export const createMavenStore = ( set({ taskStatus: "running", taskError: - error instanceof Error ? error.message : "Unable to stop the Maven task.", + errorMessage(error, "Unable to stop the Maven task."), }); } } finally { @@ -1317,9 +1390,7 @@ export const createMavenStore = ( if (diagnosticsRevision !== revision || get().root !== root) return; set({ taskError: - error instanceof Error - ? error.message - : "Unable to parse Maven build diagnostics.", + errorMessage(error, "Unable to parse Maven build diagnostics."), }); }); const testRun = state.activeTestRun; @@ -1362,9 +1433,7 @@ export const createMavenStore = ( if (diagnosticsRevision !== revision || get().root !== root) return; set({ taskError: - error instanceof Error - ? error.message - : "Unable to parse Maven test results.", + errorMessage(error, "Unable to parse Maven test results."), }); }); } @@ -1468,9 +1537,7 @@ export const createMavenStore = ( } clearDependencyTimer(); const message = - error instanceof Error - ? error.message - : "Unable to load Maven dependencies for this module."; + errorMessage(error, "Unable to load Maven dependencies for this module."); set({ activeDependencySessionId: null, activeDependencyModulePath: null, @@ -1505,9 +1572,7 @@ export const createMavenStore = ( await dependencies.stopMavenProcess(sessionId); } catch (error) { const message = - error instanceof Error - ? error.message - : "Unable to stop Maven dependency resolution."; + errorMessage(error, "Unable to stop Maven dependency resolution."); setDependencyLoad(modulePath, { status: "failed", dependencies: [], error: message }); } finally { releaseDependencySession(sessionId); @@ -1564,9 +1629,7 @@ export const createMavenStore = ( status: "failed", dependencies: [], error: - error instanceof Error - ? error.message - : "Unable to parse Maven dependencies for this module.", + errorMessage(error, "Unable to parse Maven dependencies for this module."), }); } finally { // Core has finished reading, successfully or not, before the file goes. diff --git a/windows/tauri/src/features/maven/utils/maven-resolution-problems.test.ts b/windows/tauri/src/features/maven/utils/maven-resolution-problems.test.ts new file mode 100644 index 000000000..1b77c2094 --- /dev/null +++ b/windows/tauri/src/features/maven/utils/maven-resolution-problems.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import type { Diagnostic } from "@/features/diagnostics/types/diagnostics.types"; +import { + mavenResolutionProblems, + mavenResolutionProblemsSignature, +} from "./maven-resolution-problems"; + +function diagnostic( + filePath: string, + severity: Diagnostic["severity"], + message: string, + line = 0, +): Diagnostic { + return { severity, filePath, line, column: 4, endLine: line, endColumn: 8, message }; +} + +function byOwner(entries: [string, string, Diagnostic[]][]) { + const map = new Map>(); + for (const [filePath, owner, diagnostics] of entries) { + const owners = map.get(filePath) ?? new Map(); + owners.set(owner, diagnostics); + map.set(filePath, owners); + } + return map; +} + +test("reports language-server errors on workspace pom.xml files as Maven problems", () => { + // #970: an unresolved dependency was only visible as a pom.xml marker, so + // the import looked successful while every third-party import stayed red. + const rootPom = "D:/waibao/qingsheng/qingsheng-server/pom.xml"; + const modulePom = "D:\\waibao\\qingsheng\\qingsheng-server\\service\\pom.xml"; + const problems = mavenResolutionProblems( + "D:\\waibao\\qingsheng\\qingsheng-server", + byOwner([ + [modulePom, "lsp", [diagnostic(modulePom, "error", "Missing artifact org.example:lib:jar:1.0", 12)]], + [ + rootPom, + "lsp", + [ + diagnostic(rootPom, "warning", "Project configuration is not up-to-date"), + diagnostic(rootPom, "error", "Non-resolvable parent POM", 7), + ], + ], + [rootPom.replace("pom.xml", "src/Main.java"), "lsp", [diagnostic("Main.java", "error", "x")]], + ["D:/other/pom.xml", "lsp", [diagnostic("D:/other/pom.xml", "error", "elsewhere")]], + ]), + ); + + expect(problems).toEqual([ + { pomPath: rootPom, modulePath: ".", line: 7, column: 4, message: "Non-resolvable parent POM" }, + { + pomPath: modulePom, + modulePath: "service", + line: 12, + column: 4, + message: "Missing artifact org.example:lib:jar:1.0", + }, + ]); +}); + +test("ignores diagnostics owned by other producers and a workspace without a root", () => { + const pom = "C:/work/pom.xml"; + const diagnostics = byOwner([[pom, "linter", [diagnostic(pom, "error", "lint")]]]); + + expect(mavenResolutionProblems("C:/work", diagnostics)).toEqual([]); + expect(mavenResolutionProblems(null, diagnostics)).toEqual([]); +}); + +test("the signature identifies a problem set independent of object identity", () => { + const pom = "C:/work/pom.xml"; + const first = mavenResolutionProblems("C:/work", byOwner([[pom, "lsp", [diagnostic(pom, "error", "a")]]])); + const same = mavenResolutionProblems("C:/work", byOwner([[pom, "lsp", [diagnostic(pom, "error", "a")]]])); + const other = mavenResolutionProblems("C:/work", byOwner([[pom, "lsp", [diagnostic(pom, "error", "b")]]])); + + expect(mavenResolutionProblemsSignature(first)).toBe(mavenResolutionProblemsSignature(same)); + expect(mavenResolutionProblemsSignature(first)).not.toBe(mavenResolutionProblemsSignature(other)); + expect(mavenResolutionProblemsSignature([])).toBe(""); +}); diff --git a/windows/tauri/src/features/maven/utils/maven-resolution-problems.ts b/windows/tauri/src/features/maven/utils/maven-resolution-problems.ts new file mode 100644 index 000000000..5ff49f69c --- /dev/null +++ b/windows/tauri/src/features/maven/utils/maven-resolution-problems.ts @@ -0,0 +1,69 @@ +import type { Diagnostic } from "@/features/diagnostics/types/diagnostics.types"; + +/** One error JDT LS reported on a Maven build file of the workspace. */ +export interface MavenResolutionProblem { + /** The `pom.xml` the problem belongs to, as the diagnostics store keys it. */ + pomPath: string; + /** Directory of that `pom.xml` relative to the workspace root; `.` for the root. */ + modulePath: string; + /** Zero-based position, as stored by the diagnostics model. */ + line: number; + column: number; + message: string; +} + +const LSP_DIAGNOSTICS_OWNER = "lsp"; + +function comparablePath(path: string): string { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +/** + * Maven problems for the workspace at `root`, read from the language server's + * `pom.xml` diagnostics. + * + * JDT LS imports Maven projects through m2e and reports every failure to read a + * POM or resolve an artifact as an error marker on that `pom.xml`. Those + * markers are the resolution result; without surfacing them a failed import + * looks successful while every third-party import stays unresolved (#970). + * Warnings such as an out-of-date project configuration are left to the + * Problems view. + */ +export function mavenResolutionProblems( + root: string | null, + diagnosticsByOwner: ReadonlyMap>, +): MavenResolutionProblem[] { + if (!root) return []; + const rootKey = comparablePath(root); + const problems: MavenResolutionProblem[] = []; + for (const [filePath, owners] of diagnosticsByOwner) { + const fileKey = comparablePath(filePath); + if (!fileKey.endsWith("/pom.xml") || !fileKey.startsWith(`${rootKey}/`)) continue; + const directory = fileKey.slice(rootKey.length + 1, -"/pom.xml".length); + for (const diagnostic of owners.get(LSP_DIAGNOSTICS_OWNER) ?? []) { + if (diagnostic.severity !== "error") continue; + problems.push({ + pomPath: filePath, + modulePath: directory || ".", + line: diagnostic.line, + column: diagnostic.column, + message: diagnostic.message, + }); + } + } + return problems.sort( + (left, right) => + left.modulePath.localeCompare(right.modulePath) || + left.line - right.line || + left.column - right.column || + left.message.localeCompare(right.message), + ); +} + +/** Stable identity of a problem set, used to notify once per distinct set. */ +export function mavenResolutionProblemsSignature(problems: readonly MavenResolutionProblem[]): string { + return problems + .map((problem) => `${problem.modulePath}\0${problem.line}\0${problem.column}\0${problem.message}`) + .join("\n"); +} diff --git a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx index de3237c1b..9b834c806 100644 --- a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx +++ b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx @@ -1,4 +1,5 @@ import { runExtensionAction } from "@/extensions/run/extension-run-actions"; +import { RefreshCw } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { BACKEND_UNAVAILABLE_TOOLTIP, @@ -18,7 +19,7 @@ import { Button } from "@/ui/button"; import { showConfirmDialog } from "@/ui/dialog"; import { Dropdown } from "@/ui/dropdown"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/ui/empty"; -import { ArrowClockwiseIcon as RefreshIcon, PlayIcon, PlusIcon } from "@/ui/icons"; +import { PlayIcon, PlusIcon } from "@/ui/icons"; import { SearchField } from "@/ui/search"; import { Spinner } from "@/ui/spinner"; import Tooltip from "@/ui/tooltip"; @@ -324,7 +325,7 @@ export default function RunActionsButton() { disabled={isDiscovering || !workspacePath} aria-label={t("run.rescanProjectActions")} > - {isDiscovering ? : } + {isDiscovering ? : }
diff --git a/windows/tauri/src/features/run/components/run-output-text.tsx b/windows/tauri/src/features/run/components/run-output-text.tsx index 2797871db..ce149604c 100644 --- a/windows/tauri/src/features/run/components/run-output-text.tsx +++ b/windows/tauri/src/features/run/components/run-output-text.tsx @@ -58,7 +58,7 @@ export function RunOutputText({
@@ -71,7 +71,7 @@ export function RunOutputText({
       ) : (
         
diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx
index 01c05471a..ed3106eb1 100644
--- a/windows/tauri/src/features/run/components/run-pane.tsx
+++ b/windows/tauri/src/features/run/components/run-pane.tsx
@@ -1,6 +1,6 @@
 import { ProjectPreparationStatus } from "./project-preparation-status";
 import { useEffect, useMemo, useRef, useState } from "react";
-import { WrapText } from "lucide-react";
+import { RefreshCw, WrapText } from "lucide-react";
 import { isBackendCapabilityAvailable } from "@/config/backend-capabilities";
 import { getBufferById } from "@/features/editor/utils/buffer-index";
 import { useBufferStore } from "@/features/editor/stores/buffer.store";
@@ -9,7 +9,6 @@ import { useTranslation } from "@/i18n/locale-provider";
 import { useUIState } from "@/features/window/stores/ui-state.store";
 import { Button } from "@/ui/button";
 import {
-  ArrowClockwiseIcon,
   ArrowFatLineDownIcon,
   GearIcon,
   MinusIcon,
@@ -229,7 +228,7 @@ export default function RunPane() {
             onClick={() => rootFolderPath && void actions.generate(rootFolderPath)}
             aria-label={t("run.rescan")}
           >
-            
+            
           
         
         
diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts
index eab684105..af0b7590a 100644
--- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts
+++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts
@@ -172,98 +172,103 @@ describe("Maven-backed Run context", () => {
     ]);
   });
 
-  test("a configured Maven path overrides the project Maven context", async () => {
-    const automaticContext: MavenLaunchContext = {
-      ...mavenContext,
-      mavenExecutablePath: "D:/project/mvn.cmd",
-      javaHomePath: "C:/project/jdk",
-    };
-    const resolveRunLaunch = mock(async () => ({
-      executable: "D:/Tools/mvn.cmd",
-      workingDirectory: "D:/work/reactor",
-      environment: {},
-    }));
-    const dependencies: RunStoreDependencies = {
-      createLaunchPlan: mock(async () => ({
-        executable: { toolchain: "project-maven" as const },
-        arguments: ["-B", "spring-boot:run"],
-        workingDirectory: "reactor",
-      })),
-      mavenLaunchContextForWorkspace: mock(async () => automaticContext),
-      resolveRunLaunch,
-      saveWorkspaceBeforeLaunch: mock(async () => undefined),
-      executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })),
-      startRunProcess: mock(async () => undefined),
-      stopRunProcess: mock(async () => undefined),
-      seedMavenLocalConfiguration: () => undefined,
-      prepareJavaRunLaunch: mock(async () => null),
-    };
-    const store = createRunStore("workspace", dependencies);
-    store.setState({
-      root: "D:/work",
-      configurations: [
-        {
-          ...configuration,
-          mavenExecutablePath: "D:/legacy/mvn.cmd",
-          mavenJavaHomePath: "C:/legacy/jdk",
-        },
-      ],
-      diagnostics: [],
-      effectiveRuntimeExecutablePaths: {},
-    });
+  const pathCases = [
+    {
+      name: "explicit configuration overrides project settings",
+      context: mavenContext,
+      selected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: "C:/selected/jdk" },
+      expected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: "C:/selected/jdk" },
+    },
+    {
+      name: "explicit configuration works without a Maven context",
+      context: null,
+      selected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: "C:/selected/jdk" },
+      expected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: "C:/selected/jdk" },
+    },
+    {
+      name: "blank configuration inherits project settings",
+      context: mavenContext,
+      selected: { mavenExecutablePath: "  ", mavenJavaHomePath: "" },
+      expected: { mavenExecutablePath: mavenContext.mavenExecutablePath, mavenJavaHomePath: mavenContext.javaHomePath },
+    },
+    {
+      name: "Maven override still inherits the project Maven JDK",
+      context: mavenContext,
+      selected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: "" },
+      expected: { mavenExecutablePath: "D:/selected/mvn.cmd", mavenJavaHomePath: mavenContext.javaHomePath },
+    },
+    {
+      name: "Maven JDK override still inherits the project Maven",
+      context: mavenContext,
+      selected: { mavenExecutablePath: "", mavenJavaHomePath: "C:/selected/jdk" },
+      expected: { mavenExecutablePath: mavenContext.mavenExecutablePath, mavenJavaHomePath: "C:/selected/jdk" },
+    },
+    {
+      name: "blank configuration without context delegates automatic discovery to the host",
+      context: null,
+      selected: { mavenExecutablePath: "", mavenJavaHomePath: "  " },
+      expected: { mavenExecutablePath: "", mavenJavaHomePath: "" },
+    },
+  ];
 
-    await store.getState().actions.runConfiguration(configuration.id);
+  for (const scenario of pathCases) {
+    test(`${scenario.name} for launch and pre-launch`, async () => {
+      const resolveRunLaunch = mock(async (
+        request: Parameters[0],
+      ) => ({
+        executable: request.mavenExecutablePath || "mvn",
+        workingDirectory: "D:/work/reactor",
+        environment: { JAVA_HOME: request.mavenJavaHomePath || "C:/automatic/jdk" },
+      }));
+      const executePreLaunchStep = mock(async () => ({ exitCode: 0, output: "" }));
+      const startRunProcess = mock(async () => undefined);
+      const dependencies: RunStoreDependencies = {
+        createLaunchPlan: mock(async () => ({
+          executable: { toolchain: "project-maven" as const },
+          arguments: ["-B", "spring-boot:run"],
+          workingDirectory: "reactor",
+          preLaunchSteps: [{
+            executable: { toolchain: "project-maven" as const },
+            arguments: ["-B", "compile"],
+          }],
+        })),
+        mavenLaunchContextForWorkspace: mock(async () => scenario.context),
+        resolveRunLaunch,
+        saveWorkspaceBeforeLaunch: mock(async () => undefined),
+        executePreLaunchStep,
+        startRunProcess,
+        stopRunProcess: mock(async () => undefined),
+        seedMavenLocalConfiguration: () => undefined,
+        prepareJavaRunLaunch: mock(async () => null),
+      };
+      const store = createRunStore("workspace", dependencies);
+      store.setState({
+        root: "D:/work",
+        configurations: [{ ...configuration, ...scenario.selected }],
+        diagnostics: [],
+        effectiveRuntimeExecutablePaths: {},
+      });
 
-    expect(resolveRunLaunch).toHaveBeenCalledWith(
-      expect.objectContaining({
-        mavenExecutablePath: "D:/legacy/mvn.cmd",
-        mavenJavaHomePath: "C:/legacy/jdk",
-      }),
-    );
-  });
+      expect(await store.getState().actions.runConfiguration(configuration.id)).toBe(configuration.id);
 
-  test("an empty run configuration Maven path uses the project Maven context", async () => {
-    const resolveRunLaunch = mock(async () => ({
-      executable: "D:/Tools/mvn.cmd",
-      workingDirectory: "D:/work/reactor",
-      environment: {},
-    }));
-    const dependencies: RunStoreDependencies = {
-      createLaunchPlan: mock(async () => ({
-        executable: { toolchain: "project-maven" as const },
+      expect(resolveRunLaunch).toHaveBeenCalledTimes(2);
+      for (const [request] of resolveRunLaunch.mock.calls) {
+        expect(request).toEqual(expect.objectContaining(scenario.expected));
+      }
+      const resolvedPaths = {
+        executable: scenario.expected.mavenExecutablePath || "mvn",
+        environment: { JAVA_HOME: scenario.expected.mavenJavaHomePath || "C:/automatic/jdk" },
+      };
+      expect(executePreLaunchStep).toHaveBeenCalledTimes(1);
+      expect(executePreLaunchStep).toHaveBeenCalledWith(expect.objectContaining({
+        ...resolvedPaths,
+        arguments: ["-B", "compile"],
+      }));
+      expect(startRunProcess).toHaveBeenCalledTimes(1);
+      expect(startRunProcess).toHaveBeenCalledWith(expect.objectContaining({
+        ...resolvedPaths,
         arguments: ["-B", "spring-boot:run"],
-        workingDirectory: "reactor",
-      })),
-      mavenLaunchContextForWorkspace: mock(async () => mavenContext),
-      resolveRunLaunch,
-      saveWorkspaceBeforeLaunch: mock(async () => undefined),
-      executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })),
-      startRunProcess: mock(async () => undefined),
-      stopRunProcess: mock(async () => undefined),
-      seedMavenLocalConfiguration: () => undefined,
-      prepareJavaRunLaunch: mock(async () => null),
-    };
-    const store = createRunStore("workspace", dependencies);
-    store.setState({
-      root: "D:/work",
-      configurations: [
-        {
-          ...configuration,
-          mavenExecutablePath: "  ",
-          mavenJavaHomePath: "",
-        },
-      ],
-      diagnostics: [],
-      effectiveRuntimeExecutablePaths: {},
+      }));
     });
-
-    await store.getState().actions.runConfiguration(configuration.id);
-
-    expect(resolveRunLaunch).toHaveBeenCalledWith(
-      expect.objectContaining({
-        mavenExecutablePath: "D:/Tools/apache-maven",
-        mavenJavaHomePath: "C:/Java/jdk-21",
-      }),
-    );
-  });
+  }
 });
diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx
index e7ee921dd..6e4847851 100644
--- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx
+++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx
@@ -442,7 +442,6 @@ function LspPanel() {
 function UpdatesPanel() {
   const { t } = useTranslation();
   const [appVersion, setAppVersion] = useState("");
-  const [hasCheckedForUpdates, setHasCheckedForUpdates] = useState(false);
   const {
     status,
     checking,
@@ -468,6 +467,7 @@ function UpdatesPanel() {
   }, []);
 
   const statusMessage = () => {
+    if (checking) return t("settings.mac.checking");
     if (downloading) {
       return t("update.updatingProgress", { percentage: downloadProgress?.percentage ?? 0 });
     }
@@ -477,7 +477,7 @@ function UpdatesPanel() {
     if (available) {
       return t("settings.mac.updateAvailable", { version: updateInfo?.targetVersion ?? "" });
     }
-    return hasCheckedForUpdates ? t("settings.mac.upToDate") : t("settings.mac.updateHint");
+    return status === "upToDate" ? t("settings.mac.upToDate") : t("settings.mac.updateHint");
   };
 
   return (
@@ -493,7 +493,6 @@ function UpdatesPanel() {
               size="sm"
               disabled={busy}
               onClick={() => {
-                setHasCheckedForUpdates(true);
                 void checkForUpdates({ ignoreSuppression: true });
               }}
             >
diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx
index 26732beb4..7e5ac26eb 100644
--- a/windows/tauri/src/features/settings/components/settings-dialog.tsx
+++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx
@@ -6,9 +6,9 @@ import { useTranslation } from "@/i18n/locale-provider";
 import { Button } from "@/ui/button";
 import Dialog from "@/ui/dialog";
 import {
-  ArrowClockwiseIcon,
   CodeBlockIcon,
   DatabaseIcon,
+  DownloadIcon,
   GearIcon,
   GearSixIcon,
   KeyboardIcon,
@@ -46,7 +46,7 @@ const categories: CategoryItem[] = [
   { id: "ai-commit", labelKey: "settings.tabs.aiCommit", icon: MagicWandIcon },
   { id: "git", labelKey: "settings.tabs.git", icon: CodeBlockIcon },
   { id: "logs", labelKey: "settings.tabs.logs", icon: FileTextIcon },
-  { id: "updates", labelKey: "settings.tabs.updates", icon: ArrowClockwiseIcon },
+  { id: "updates", labelKey: "settings.tabs.updates", icon: DownloadIcon },
 ];
 
 function categoryFromRequestedTab(tab: SettingsTab | null): MacSettingsCategory {
diff --git a/windows/tauri/src/features/settings/components/tabs/ai-settings.tsx b/windows/tauri/src/features/settings/components/tabs/ai-settings.tsx
index 0f2763459..8e85ba42d 100644
--- a/windows/tauri/src/features/settings/components/tabs/ai-settings.tsx
+++ b/windows/tauri/src/features/settings/components/tabs/ai-settings.tsx
@@ -1,3 +1,4 @@
+import { RefreshCw } from "lucide-react";
 import { invoke } from "@/platform/tauri-core";
 import {
   WarningCircleIcon as AlertCircle,
@@ -9,7 +10,6 @@ import {
   LaptopIcon as Laptop,
   PaletteIcon as Palette,
   SparkleIcon as Sparkles,
-  ArrowClockwiseIcon as RefreshCw,
   ArrowCounterClockwiseIcon as RotateCcw,
   TrashIcon as Trash2,
 } from "@/ui/icons";
diff --git a/windows/tauri/src/features/settings/components/tabs/general-settings.tsx b/windows/tauri/src/features/settings/components/tabs/general-settings.tsx
index 5beb39694..7556548fb 100644
--- a/windows/tauri/src/features/settings/components/tabs/general-settings.tsx
+++ b/windows/tauri/src/features/settings/components/tabs/general-settings.tsx
@@ -37,6 +37,7 @@ export const GeneralSettings = () => {
   const updateSetting = useSettingsStore((state) => state.actions.updateSetting);
   const { t } = useTranslation();
   const {
+    status,
     available,
     checking,
     downloading,
@@ -212,7 +213,9 @@ export const GeneralSettings = () => {
                 })
               : error
                 ? t("settings.general.updateCheckFailed", { version: appVersion || "..." })
-                : t("settings.general.upToDate", { version: appVersion || "..." })}
+                : status === "upToDate"
+                  ? t("settings.general.upToDate", { version: appVersion || "..." })
+                  : t("settings.mac.updateHint")}
       
 
       {downloading && downloadProgress ? (
diff --git a/windows/tauri/src/features/settings/components/update-status.test.tsx b/windows/tauri/src/features/settings/components/update-status.test.tsx
new file mode 100644
index 000000000..801d9555c
--- /dev/null
+++ b/windows/tauri/src/features/settings/components/update-status.test.tsx
@@ -0,0 +1,47 @@
+import { expect, spyOn, test } from "bun:test";
+import * as app from "@tauri-apps/api/app";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { LocaleProvider } from "@/i18n/locale-provider";
+import { installHappyDom } from "@/test-utils/happy-dom";
+import { useUpdateStore } from "../stores/update.store";
+import { MacSettingsPanel } from "./macos-settings-panels";
+
+test("settings only claims the application is current after a successful check", async () => {
+  const restoreDom = installHappyDom();
+  const previous = useUpdateStore.getState();
+  const version = spyOn(app, "getVersion").mockResolvedValue("0.5.6");
+  const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
+  const previousAct = environment.IS_REACT_ACT_ENVIRONMENT;
+  const container = document.createElement("div");
+  const root = createRoot(container);
+  try {
+    environment.IS_REACT_ACT_ENVIRONMENT = true;
+    useUpdateStore.setState({ status: "idle", error: null, updateInfo: null });
+    await act(async () => {
+      root.render( {}} />);
+    });
+    const message = () => container.querySelector('[role="status"]')?.textContent;
+    expect(message()).not.toContain("up to date");
+    await act(async () => { useUpdateStore.setState({ status: "checking" }); });
+    expect(message()).toBe("Checking…");
+    await act(async () => { useUpdateStore.setState({ status: "failed", error: "Offline" }); });
+    expect(message()).not.toContain("up to date");
+    await act(async () => { useUpdateStore.setState({ status: "upToDate", error: null }); });
+    expect(message()).toBe("Lithe is up to date.");
+    // Dismissal or suppression after a previous successful check must clear that claim.
+    await act(async () => { useUpdateStore.setState({ status: "idle" }); });
+    expect(message()).not.toContain("up to date");
+  } finally {
+    try {
+      await act(async () => root.unmount());
+    } finally {
+      useUpdateStore.setState(previous, true);
+      version.mockRestore();
+      container.remove();
+      if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT;
+      else environment.IS_REACT_ACT_ENVIRONMENT = previousAct;
+      restoreDom();
+    }
+  }
+});
diff --git a/windows/tauri/src/features/settings/stores/update.store.test.ts b/windows/tauri/src/features/settings/stores/update.store.test.ts
index 2dd8bb267..ad4841e0b 100644
--- a/windows/tauri/src/features/settings/stores/update.store.test.ts
+++ b/windows/tauri/src/features/settings/stores/update.store.test.ts
@@ -54,7 +54,7 @@ test("a user-initiated check shows a skipped version that a scheduled check hide
     const { actions } = useUpdateStore.getState();
 
     expect(await actions.checkForUpdates()).toBe("suppressed");
-    expect(useUpdateStore.getState().detailsOpen).toBe(false);
+    expect(useUpdateStore.getState()).toMatchObject({ status: "idle", detailsOpen: false });
 
     expect(await actions.checkForUpdates({ userInitiated: true })).toBe("available");
     expect(useUpdateStore.getState().detailsOpen).toBe(true);
@@ -73,3 +73,10 @@ test("details cannot open without an update and close when a new check starts",
   expect(await actions.checkForUpdates()).toBe("up-to-date");
   expect(useUpdateStore.getState()).toMatchObject({ status: "upToDate", detailsOpen: false });
 });
+
+// A failed request must never produce the reassuring app-version notification.
+test("a failed check remains failed rather than up to date", async () => {
+  check.mockImplementationOnce(async () => { throw new Error("connection failed"); });
+  expect(await useUpdateStore.getState().actions.checkForUpdates({ userInitiated: true })).toBe("failed");
+  expect(useUpdateStore.getState()).toMatchObject({ status: "failed", error: "connection failed" });
+});
diff --git a/windows/tauri/src/features/settings/stores/update.store.ts b/windows/tauri/src/features/settings/stores/update.store.ts
index a633e3d81..78e280595 100644
--- a/windows/tauri/src/features/settings/stores/update.store.ts
+++ b/windows/tauri/src/features/settings/stores/update.store.ts
@@ -178,7 +178,7 @@ const useUpdateStore = create()((set, get) => ({
             updateRef = null;
             updateInfoRef = null;
             set({
-              status: "upToDate",
+              status: "idle",
               error: null,
               errorCode: null,
               updateInfo: null,
diff --git a/windows/tauri/src/features/viewer/web/components/web-viewer.tsx b/windows/tauri/src/features/viewer/web/components/web-viewer.tsx
index 0d533fe9a..18b3aaaaa 100644
--- a/windows/tauri/src/features/viewer/web/components/web-viewer.tsx
+++ b/windows/tauri/src/features/viewer/web/components/web-viewer.tsx
@@ -82,7 +82,8 @@ function getWebViewerErrorMessage(error: unknown) {
 }
 
 function isWebviewNotFoundError(error: unknown) {
-  return typeof error === "string" && error.includes("Webview not found:");
+  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
+  return message.includes("Webview not found:");
 }
 
 export function WebViewer({
diff --git a/windows/tauri/src/features/viewer/web/hooks/use-embedded-webview.ts b/windows/tauri/src/features/viewer/web/hooks/use-embedded-webview.ts
index 72b57558b..367baaf1c 100644
--- a/windows/tauri/src/features/viewer/web/hooks/use-embedded-webview.ts
+++ b/windows/tauri/src/features/viewer/web/hooks/use-embedded-webview.ts
@@ -23,7 +23,8 @@ interface UseEmbeddedWebviewResult {
 }
 
 function isWebviewNotFoundError(error: unknown) {
-  return typeof error === "string" && error.includes("Webview not found:");
+  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
+  return message.includes("Webview not found:");
 }
 
 export function useEmbeddedWebview({
diff --git a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx
index c00bfff94..eaac4bce4 100644
--- a/windows/tauri/src/features/window/components/title-bar/title-bar.tsx
+++ b/windows/tauri/src/features/window/components/title-bar/title-bar.tsx
@@ -1,11 +1,12 @@
 import { getCurrentWindow, type Window as TauriWindow } from "@tauri-apps/api/window";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { createPortal } from "react-dom";
 import { BACKEND_UNAVAILABLE_TOOLTIP } from "@/config/backend-capabilities";
 import { useTranslation } from "@/i18n/locale-provider";
 import { openFolder } from "@/features/file-system/controllers/platform";
 import { useFileSystemStore } from "@/features/file-system/stores/file-system.store";
 import { useFooterGitBranchItem } from "@/features/layout/components/footer/footer-git-branch-item";
+import { COLLAPSED_ACTIVITY_RAIL_WIDTH } from "@/features/layout/constants/activity-rail";
 import { AppUpdateControl } from "@/features/layout/components/app-update-control";
 import SettingsDialog from "@/features/settings/components/settings-dialog";
 import { useSettingsStore } from "@/features/settings/stores/settings.store";
@@ -13,6 +14,7 @@ import { useUIState } from "@/features/window/stores/ui-state.store";
 import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store";
 import type { ProjectPickerMode } from "@/features/window/utils/project-picker-mode";
 import { useNativeWindowChrome } from "@/features/window/hooks/use-native-window-chrome";
+import { useCompactMenuBarDismissal } from "@/features/window/hooks/use-compact-menu-bar-dismissal";
 import { createAppWindow } from "@/features/window/utils/create-app-window";
 import { runTitleBarDrag } from "@/features/window/utils/title-bar-drag";
 import { Button } from "@/ui/button";
@@ -67,6 +69,8 @@ export const TitleBar = ({
 
   const [menuBarActiveMenu, setMenuBarActiveMenu] = useState(null);
   const [isCompactMenuVisible, setIsCompactMenuVisible] = useState(false);
+  const compactMenuBarContainerRef = useRef(null);
+  const compactMenuToggleRef = useRef(null);
   const [isMaximized, setIsMaximized] = useState(false);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [currentWindow, setCurrentWindow] = useState(null);
@@ -77,6 +81,7 @@ export const TitleBar = ({
   const usesNativeWindowChrome = useNativeWindowChrome();
   const showAppWindowControls = !isMacOS && !usesNativeWindowChrome;
   const shouldUseNativeMenuBar = !isWindows && !isLinux && nativeMenuBar;
+  const showCompactMenuBar = compactMenuBar && isCompactMenuVisible;
 
   useEffect(() => {
     const initWindow = async () => {
@@ -162,9 +167,9 @@ export const TitleBar = ({
     }
   }, [closeProject]);
 
-  const handleCompactMenuToggle = useCallback(() => {
-    setMenuBarActiveMenu(null);
-    setIsCompactMenuVisible((visible) => !visible);
+  const handleCompactMenuOpen = useCallback(() => {
+    setMenuBarActiveMenu("File");
+    setIsCompactMenuVisible(true);
   }, []);
 
   const handleCompactMenuClose = useCallback(() => {
@@ -172,6 +177,13 @@ export const TitleBar = ({
     setIsCompactMenuVisible(false);
   }, []);
 
+  useCompactMenuBarDismissal(
+    showCompactMenuBar,
+    compactMenuBarContainerRef,
+    handleCompactMenuClose,
+    compactMenuToggleRef,
+  );
+
   const titleBarContextMenuContent = (
     
        void createAppWindow()}>
@@ -205,27 +217,31 @@ export const TitleBar = ({
   const menuItem =
     !isMacOS && !shouldUseNativeMenuBar ? (
       compactMenuBar ? (
-        
- - - - {isCompactMenuVisible ? ( +
+ {showCompactMenuBar ? ( - ) : null} + ) : ( + + + + )}
) : ( @@ -239,6 +255,21 @@ export const TitleBar = ({ ); + const appBrandMark = !isMacOS ? ( + + ) : null; + const quickOpenAction = ( ); } + +function WindowControlGlyph({ + kind, +}: { + kind: "minimize" | "maximize" | "restore" | "close"; +}) { + return ( + + ); +} diff --git a/windows/tauri/src/features/window/components/window-menu-bar.test.ts b/windows/tauri/src/features/window/components/window-menu-bar.test.ts index a35ead0b4..17de97676 100644 --- a/windows/tauri/src/features/window/components/window-menu-bar.test.ts +++ b/windows/tauri/src/features/window/components/window-menu-bar.test.ts @@ -1,12 +1,24 @@ import { expect, test } from "bun:test"; -test("compact floating menu keeps translated labels on one row", async () => { +test("expanded compact menu replaces its toggle with an open File menu", async () => { const menuSource = await Bun.file(new URL("./window-menu-bar.tsx", import.meta.url)).text(); + const titleBarSource = await Bun.file( + new URL("./title-bar/title-bar.tsx", import.meta.url), + ).text(); const primitiveSource = await Bun.file( new URL("../../../ui/menubar.tsx", import.meta.url), ).text(); - expect(menuSource).toContain('"absolute top-full left-0 mt-1 w-max"'); - expect(menuSource).toContain('"h-auto w-max flex-nowrap rounded-2xl'); + expect(menuSource).toContain("compactExpanded"); + expect(menuSource).toContain('"h-full w-max flex-nowrap rounded-none border-none'); + expect(menuSource).not.toContain("absolute top-full left-0"); + expect(menuSource).toContain( + 'ref={compactExpanded && menuName === "File" ? firstMenuTriggerRef : undefined}', + ); + expect(titleBarSource).toContain("!showCompactMenuBar ? projectControls : null"); + expect(titleBarSource).toContain("{showCompactMenuBar ? ("); + expect(titleBarSource).toContain('setMenuBarActiveMenu("File")'); + expect(titleBarSource).toContain("onClick={handleCompactMenuOpen}"); + expect(menuSource).toContain("firstMenuTriggerRef.current?.focus()"); expect(primitiveSource).toContain("h-5 shrink-0 select-none items-center whitespace-nowrap"); }); diff --git a/windows/tauri/src/features/window/components/window-menu-bar.tsx b/windows/tauri/src/features/window/components/window-menu-bar.tsx index 0b60866eb..a35ffd349 100644 --- a/windows/tauri/src/features/window/components/window-menu-bar.tsx +++ b/windows/tauri/src/features/window/components/window-menu-bar.tsx @@ -9,7 +9,6 @@ import { isBackendCapabilityAvailable, } from "@/config/backend-capabilities"; import { useRegisteredThemes } from "@/extensions/themes/use-registered-themes"; -import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useTranslation } from "@/i18n/locale-provider"; import { createAppWindow } from "@/features/window/utils/create-app-window"; import { @@ -29,20 +28,20 @@ import { IS_LINUX, IS_WINDOWS } from "@/utils/platform"; interface Props { activeMenu: string | null; setActiveMenu: React.Dispatch>; - compactFloating?: boolean; + compactExpanded?: boolean; onCompactClose?: () => void; } const WindowMenuBar = ({ activeMenu, setActiveMenu, - compactFloating = false, + compactExpanded = false, onCompactClose, }: Props) => { const { t } = useTranslation(); - const compactMenuBar = useSettingsStore((state) => state.settings.compactMenuBar); const themes = useRegisteredThemes(); const menuWindowRaiseRef = useRef<{ restoreTo: boolean } | null>(null); + const firstMenuTriggerRef = useRef(null); const shouldRaiseWindowForMenu = (IS_WINDOWS || IS_LINUX) && Boolean(activeMenu); const closeMenu = useCallback(() => { setActiveMenu(null); @@ -102,6 +101,10 @@ const WindowMenuBar = ({ }; }, [shouldRaiseWindowForMenu]); + useEffect(() => { + if (compactExpanded) firstMenuTriggerRef.current?.focus(); + }, [compactExpanded]); + const handleClickEmit = useCallback( (event: string, payload?: unknown) => { const currentWindow = getCurrentWebviewWindow(); @@ -535,26 +538,23 @@ const WindowMenuBar = ({ return (
setActiveMenu(value || null)} className={cn( - compactMenuBar && - compactFloating && - "h-auto w-max flex-nowrap rounded-2xl border border-border bg-background/95 px-1 py-1 shadow-(--shadow-popover) backdrop-blur-sm", - compactMenuBar && - !compactFloating && - "h-full rounded-none border-none bg-transparent px-2 py-0", + compactExpanded && + "h-full w-max flex-nowrap rounded-none border-none bg-transparent px-0 py-0", )} > {Object.entries(menus).map(([menuName, menuContent]) => ( (null); + const focusTargetRef = useRef(null); + useCompactMenuBarDismissal(isOpen, containerRef, () => setIsOpen(false), focusTargetRef); + + return ( + <> +
+ {isOpen ? ( +
+ +
+ ) : ( + + )} + {isOpen ? ( +
+ +
+ ) : null} +
+ + + ); +} + +async function renderHarness(container: HTMLElement): Promise { + const root = createRoot(container); + await act(async () => { + root.render(); + }); + return root; +} + +test("compact menu stays open for its triggers and portaled menu items, then dismisses outside", async () => { + const restoreDom = installHappyDom(); + const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + const container = document.createElement("div"); + let root: Root | undefined; + + try { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + document.body.append(container); + root = await renderHarness(container); + + const menuBar = container.querySelector('[data-testid="menu-bar"]'); + const menuToggle = menuBar?.querySelector("button"); + const menuPopup = container.querySelector('[data-slot="menubar-content"]'); + const menuAction = menuPopup?.querySelector("button"); + const outside = container.querySelector('[data-testid="outside"]'); + + expect(menuBar?.dataset.open).toBe("true"); + expect(menuToggle).not.toBeNull(); + expect(menuAction).not.toBeNull(); + expect(outside).not.toBeNull(); + + await act(async () => { + menuToggle?.dispatchEvent(new Event("pointerdown", { bubbles: true })); + menuAction?.dispatchEvent(new Event("pointerdown", { bubbles: true })); + }); + expect(menuBar?.dataset.open).toBe("true"); + + await act(async () => { + outside?.dispatchEvent(new Event("pointerdown", { bubbles: true })); + }); + expect(menuBar?.dataset.open).toBe("false"); + } finally { + try { + await act(async () => root?.unmount()); + } finally { + container.remove(); + if (previousActEnvironment === undefined) { + delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; + } else { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + restoreDom(); + } + } +}); + +test("Escape dismisses the expanded compact menu", async () => { + const restoreDom = installHappyDom(); + const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + const container = document.createElement("div"); + let root: Root | undefined; + + try { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + document.body.append(container); + root = await renderHarness(container); + + const menuBar = container.querySelector('[data-testid="menu-bar"]'); + expect(menuBar?.dataset.open).toBe("true"); + const fileMenuTrigger = menuBar?.querySelector("button") ?? null; + fileMenuTrigger?.focus(); + expect(document.activeElement).toBe(fileMenuTrigger); + const escapeEvent = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + + await act(async () => { + document.dispatchEvent(escapeEvent); + }); + + expect(menuBar?.dataset.open).toBe("false"); + expect(escapeEvent.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(container.querySelector('[data-testid="menu-toggle"]')); + } finally { + try { + await act(async () => root?.unmount()); + } finally { + container.remove(); + if (previousActEnvironment === undefined) { + delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; + } else { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + restoreDom(); + } + } +}); diff --git a/windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts b/windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts new file mode 100644 index 000000000..464aa15ca --- /dev/null +++ b/windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, type RefObject } from "react"; + +const MENU_POPUP_SELECTOR = '[data-slot="menubar-content"], [data-slot="menubar-sub-content"]'; + +export function useCompactMenuBarDismissal( + isOpen: boolean, + containerRef: RefObject, + onDismiss: () => void, + focusTargetRef: RefObject, +): void { + const shouldRestoreFocusRef = useRef(false); + + useEffect(() => { + if (isOpen || !shouldRestoreFocusRef.current) return; + + shouldRestoreFocusRef.current = false; + focusTargetRef.current?.focus(); + }, [focusTargetRef, isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (containerRef.current?.contains(target)) return; + + if (target instanceof Element && target.closest(MENU_POPUP_SELECTOR)) return; + + onDismiss(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + + event.preventDefault(); + event.stopPropagation(); + shouldRestoreFocusRef.current = true; + onDismiss(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown, true); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [containerRef, focusTargetRef, isOpen, onDismiss]); +} diff --git a/windows/tauri/src/features/window/hooks/use-menu-events-wrapper.ts b/windows/tauri/src/features/window/hooks/use-menu-events-wrapper.ts index 544e3a308..464faa25c 100644 --- a/windows/tauri/src/features/window/hooks/use-menu-events-wrapper.ts +++ b/windows/tauri/src/features/window/hooks/use-menu-events-wrapper.ts @@ -338,9 +338,9 @@ export function useMenuEventsWrapper() { // An available update opens the shared details dialog from the store. const result = await checkForUpdates({ userInitiated: true }); if (result === "up-to-date") { - showToast({ message: "You're on the latest version", type: "success" }); + showToast({ message: t("settings.general.latestVersion"), type: "success" }); } else if (result === "failed") { - showToast({ message: "Failed to check for updates", type: "error" }); + showToast({ message: t("settings.mac.updateFailed"), type: "error" }); } }, onOpenSettings: () => { diff --git a/windows/tauri/src/features/window/utils/window-actions.ts b/windows/tauri/src/features/window/utils/window-actions.ts new file mode 100644 index 000000000..6a36945e0 --- /dev/null +++ b/windows/tauri/src/features/window/utils/window-actions.ts @@ -0,0 +1,53 @@ +import { getCurrentWindow, type Window } from "@tauri-apps/api/window"; + +export interface WindowPresentationState { + isMaximized: boolean; + isFullscreen: boolean; +} + +export type WindowActionTarget = Pick< + Window, + "minimize" | "maximize" | "unmaximize" | "isMaximized" | "isFullscreen" | "setFullscreen" +>; + +export async function readWindowPresentationState( + window: WindowActionTarget = getCurrentWindow(), +): Promise { + const [isMaximized, isFullscreen] = await Promise.all([ + window.isMaximized(), + window.isFullscreen(), + ]); + + return { isMaximized, isFullscreen }; +} + +export async function minimizeCurrentWindow( + window: WindowActionTarget = getCurrentWindow(), +): Promise { + await window.minimize(); +} + +export async function maximizeCurrentWindow( + window: WindowActionTarget = getCurrentWindow(), +): Promise { + await window.maximize(); +} + +export async function toggleCurrentWindowMaximize( + window: WindowActionTarget = getCurrentWindow(), +): Promise { + const isMaximized = await window.isMaximized(); + + if (isMaximized) { + await window.unmaximize(); + } else { + await window.maximize(); + } +} + +export async function toggleCurrentWindowFullscreen( + window: WindowActionTarget = getCurrentWindow(), +): Promise { + const isFullscreen = await window.isFullscreen(); + await window.setFullscreen(!isFullscreen); +} diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index d620d07f6..622842209 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1678,6 +1678,8 @@ const catalogs = { "maven.collapseAll": "Collapse all", "maven.clearOutput": "Clear build output", "maven.configurationChanged": "Maven configuration changed", + "maven.javaConfigurationFailed": "The Java language server did not take the Maven configuration: {error}", + "maven.resolutionProblems": "Maven could not resolve this project ({count} problems)", "maven.reloadJdt": "Reload JDT LS", "maven.reloadFailed": "Unable to reload the Java language server.", "maven.loadFailed": "Unable to load Maven project", @@ -2095,7 +2097,7 @@ const catalogs = { "settings.general.cliUninstallFailed": "Failed to uninstall CLI: {error}", "settings.general.installCommandCopied": "Install command copied to clipboard", "settings.general.copyCommandFailed": "Failed to copy command: {error}", - "settings.general.latestVersion": "You're on the latest version", + "settings.general.latestVersion": "Lithe is up to date. Language extensions are updated separately.", "settings.general.reportTemplateCopied": "Report template copied", "settings.general.prepareReportFailed": "Failed to prepare bug report", "settings.general.bugReportTemplate": @@ -3856,7 +3858,9 @@ const catalogs = { "commandPalette.actions.open-url.label": "View: Open URL...", "commandPalette.actions.window-minimize.label": "Window: Minimize", "commandPalette.actions.window-maximize.label": "Window: Maximize", - "commandPalette.actions.window-fullscreen.label": "Window: Toggle Fullscreen", + "commandPalette.actions.window-restore.label": "Window: Restore", + "commandPalette.actions.window-enter-fullscreen.label": "Window: Enter Fullscreen", + "commandPalette.actions.window-exit-fullscreen.label": "Window: Exit Fullscreen", "commandPalette.actions.tab-close.label": "Tab: Close Tab", "commandPalette.actions.tab-next.label": "Tab: Next Tab", "commandPalette.actions.tab-previous.label": "Tab: Previous Tab", @@ -6135,6 +6139,8 @@ const catalogs = { "maven.collapseAll": "全部折叠", "maven.clearOutput": "清除构建输出", "maven.configurationChanged": "Maven 配置已更改", + "maven.javaConfigurationFailed": "Java 语言服务器未能应用 Maven 配置:{error}", + "maven.resolutionProblems": "Maven 无法解析此项目({count} 个问题)", "maven.reloadJdt": "重新加载 JDT LS", "maven.reloadFailed": "无法重新加载 Java 语言服务器。", "maven.loadFailed": "无法加载 Maven 项目", @@ -6525,7 +6531,7 @@ const catalogs = { "settings.general.cliUninstallFailed": "卸载 CLI 失败:{error}", "settings.general.installCommandCopied": "安装命令已复制到剪贴板", "settings.general.copyCommandFailed": "复制命令失败:{error}", - "settings.general.latestVersion": "您使用的是最新版本", + "settings.general.latestVersion": "Lithe 主程序已是最新版本,语言扩展需单独更新。", "settings.general.reportTemplateCopied": "报告模板已复制", "settings.general.prepareReportFailed": "准备问题报告失败", "settings.general.bugReportTemplate": @@ -8198,7 +8204,9 @@ const catalogs = { "commandPalette.actions.open-url.label": "视图:打开 URL...", "commandPalette.actions.window-minimize.label": "窗口:最小化", "commandPalette.actions.window-maximize.label": "窗口:最大化", - "commandPalette.actions.window-fullscreen.label": "窗口:切换全屏", + "commandPalette.actions.window-restore.label": "窗口:还原", + "commandPalette.actions.window-enter-fullscreen.label": "窗口:进入全屏", + "commandPalette.actions.window-exit-fullscreen.label": "窗口:退出全屏", "commandPalette.actions.tab-close.label": "标签页:关闭标签页", "commandPalette.actions.tab-next.label": "标签页:下一个标签页", "commandPalette.actions.tab-previous.label": "标签页:上一个标签页", diff --git a/windows/tauri/src/platform/git-patch-files.test.ts b/windows/tauri/src/platform/git-patch-files.test.ts index 71b5d15cd..09457dd91 100644 --- a/windows/tauri/src/platform/git-patch-files.test.ts +++ b/windows/tauri/src/platform/git-patch-files.test.ts @@ -2,7 +2,14 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; let response = { bytes: [] as number[], truncated: false }; const invoke = mock(async (_command: string, _args?: unknown) => response); -mock.module("./tauri-core", () => ({ invoke })); +// Mock the Tauri boundary, not `./tauri-core`: a module mock is process-wide in +// Bun, and replacing `tauri-core` would hide the real router from every later +// test file in the same run. +mock.module("@tauri-apps/api/core", () => ({ + Channel: class {}, + convertFileSrc: (path: string) => path, + invoke: (command: string, args?: unknown) => invoke(command, args), +})); const { readGitPatchFile, writeGitPatchFile } = await import("./git-patch-files"); beforeEach(() => { diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 9db691cf5..ba09e6a0f 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -385,6 +385,14 @@ const executeCore = mock( data: { operationId }, }; } + if (request.command === "lsp.updateMavenConfiguration") { + requestPayload = request.payload; + return { + id: request.id, + ok: true as const, + data: { settingsChanged: true, projectsReloaded: false, profilesUpdating: false }, + }; + } return { id: request.id, ok: true as const, data: null }; }, ); @@ -630,6 +638,39 @@ describe("Rust Core LSP adapter failures", () => { expect(ownsLspSession("java-session")).toBe(false); }); + test("sends Maven configuration changes to the running Java session of the workspace", async () => { + scenario = "capabilities"; + const mavenContext = { version: 1, reactorPath: ".", settingsPath: "C:/maven/settings.xml" }; + + const beforeStart = await invokeLsp("lsp_update_maven_configuration", { + workspacePath: "C:/work", + mavenContext, + reloadProjects: true, + }); + await invokeLsp("lsp_start", { + workspacePath: "C:/work", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + const running = await invokeLsp("lsp_update_maven_configuration", { + workspacePath: "C:\\work", + mavenContext, + reloadProjects: true, + }); + + // Without a session the next start reads the current context; Core is not asked. + expect(beforeStart).toEqual({ kind: "noSession" }); + expect(running).toEqual({ + kind: "updated", + settingsChanged: true, + projectsReloaded: false, + profilesUpdating: false, + }); + expect(requestPayload).toEqual({ sessionId: "java-session", mavenContext, reloadProjects: true }); + expect(commands.filter((command) => command === "lsp.updateMavenConfiguration")).toHaveLength(1); + }); + test("startup poll failure retires preparation and explicit stop clears the failed snapshot", async () => { scenario = "poll-failure"; await expect( @@ -1497,6 +1538,7 @@ describe("Rust Core LSP adapter failures", () => { "lsp_stop_for_file", "lsp_workspace_files_changed", "lsp_retry_maven_profiles", + "lsp_update_maven_configuration", ]); const clientSource = readFileSync( new URL("../features/editor/lsp/lsp-client.ts", import.meta.url), diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 1b63e1386..385aaf765 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -1480,6 +1480,30 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom await core("lsp.retryMavenProfiles", { sessionId: session.id }, crypto.randomUUID()); return undefined as T; } + if (command === "lsp_update_maven_configuration") { + // A running Java session takes Maven changes through JDT LS's own update + // path. Without one there is nothing to update: the next start reads the + // workspace's current Maven context. + const session = [...sessions.values()].find( + (candidate) => + candidate.languageId === "java" && + !isSessionTerminal(candidate.lifecycle) && + candidate.lifecycle.phase !== "stopping" && + candidate.lifecycle.phase !== "recovering" && + normalizedPathKey(candidate.workspacePath) === normalizedPathKey(args.workspacePath), + ); + if (!session) return { kind: "noSession" } as T; + const result = await core( + "lsp.updateMavenConfiguration", + { + sessionId: session.id, + mavenContext: args.mavenContext, + reloadProjects: args.reloadProjects === true, + }, + crypto.randomUUID(), + ); + return { kind: "updated", ...result } as T; + } if (command === "lsp_stop_for_file") { const operation = new LspOperationLog("fileDetach", crypto.randomUUID(), { filePath: args.filePath, diff --git a/windows/tauri/src/platform/tauri-core.routing.test.ts b/windows/tauri/src/platform/tauri-core.routing.test.ts new file mode 100644 index 000000000..d1c2e4c3b --- /dev/null +++ b/windows/tauri/src/platform/tauri-core.routing.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { isPlatformDispatcherCommand } from "./tauri-core"; + +// The routing rule in `tauri-core.ts` must agree with the host: a command that +// `main.rs` registers is invoked directly, and only names the shared +// dispatcher translates reach `platform_invoke`. A drift here fails every call +// to the affected command at runtime, as the Maven dependency tree did (#970). + +const mainSource = readFileSync( + fileURLToPath(new URL("../../src-tauri/src/main.rs", import.meta.url)), + "utf8", +); + +function registeredHostCommands(): string[] { + const handler = /generate_handler!\[([\s\S]*?)\]/.exec(mainSource); + if (!handler) throw new Error("main.rs no longer registers commands with generate_handler!"); + return handler[1]! + .split(",") + .map((entry) => entry.replace(/\/\/.*$/gm, "").trim()) + .filter(Boolean) + .map((path) => path.split("::").pop()!); +} + +test("main.rs registers the commands this test inspects", () => { + const commands = registeredHostCommands(); + expect(commands).toContain("platform_invoke"); + expect(commands).toContain("maven_create_dependency_output"); +}); + +test("every registered host command is invoked directly", () => { + const misrouted = registeredHostCommands().filter( + (command) => command !== "platform_invoke" && isPlatformDispatcherCommand(command), + ); + expect(misrouted).toEqual([]); +}); + +test("dispatcher-owned names stay with the platform dispatcher", () => { + expect(isPlatformDispatcherCommand("git_status")).toBe(true); + expect(isPlatformDispatcherCommand("ai_commit_detect")).toBe(true); + expect(isPlatformDispatcherCommand("maven.dependencyPlan")).toBe(true); + expect(isPlatformDispatcherCommand("watch_git_repository")).toBe(false); +}); diff --git a/windows/tauri/src/platform/tauri-core.test.ts b/windows/tauri/src/platform/tauri-core.test.ts index e549f5a9a..1c217a0d7 100644 --- a/windows/tauri/src/platform/tauri-core.test.ts +++ b/windows/tauri/src/platform/tauri-core.test.ts @@ -8,7 +8,7 @@ mock.module("@tauri-apps/api/core", () => ({ invoke: tauriInvoke, })); -const { invoke, isNativeCommand } = await import("./tauri-core"); +const { HostCommandError, invoke, isPlatformDispatcherCommand } = await import("./tauri-core"); beforeEach(() => { tauriInvoke.mockClear(); @@ -20,12 +20,69 @@ test("Java index maintenance routes directly to the Tauri host", async () => { workspaceFingerprint: "build=|modules=|jdtls=1.38.0", }; - expect(isNativeCommand("lsp_rebuild_java_index")).toBe(true); + expect(isPlatformDispatcherCommand("lsp_rebuild_java_index")).toBe(false); await invoke("lsp_rebuild_java_index", args); expect(tauriInvoke).toHaveBeenCalledWith("lsp_rebuild_java_index", args, undefined); }); +test("Maven host commands route directly instead of through the platform dispatcher", async () => { + // Regression for #970: these were registered Tauri commands that the old + // native allowlist omitted, so the dispatcher rejected every call. + for (const command of [ + "maven_create_dependency_output", + "maven_remove_dependency_output", + "maven_resolve_installation", + "maven_resolve_effective_configuration", + "run_execute_prelaunch", + ]) { + tauriInvoke.mockClear(); + await invoke(command, { sessionId: "fixture" }); + expect(tauriInvoke).toHaveBeenCalledWith(command, { sessionId: "fixture" }, undefined); + } +}); + +test("a string rejection from a host command surfaces as an Error with the host message", async () => { + tauriInvoke.mockImplementationOnce(async () => { + throw "Maven settings file is not readable"; + }); + + const error = await invoke("maven_resolve_installation", { root: "C:/fixture" }).catch( + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(HostCommandError); + expect((error as Error).message).toBe("Maven settings file is not readable"); + expect(String(error)).toBe("Maven settings file is not readable"); + expect((error as InstanceType).command).toBe("maven_resolve_installation"); +}); + +test("a structured rejection keeps its code and details", async () => { + tauriInvoke.mockImplementationOnce(async () => { + throw { code: "processStartFailed", message: "Maven did not start", details: "exit 1" }; + }); + + const error = (await invoke("run_start_process", {}).catch((reason: unknown) => reason)) as Error & { + code?: string; + details?: unknown; + }; + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Maven did not start"); + expect(error.code).toBe("processStartFailed"); + expect(error.details).toBe("exit 1"); +}); + +test("a dispatcher rejection surfaces as an Error", async () => { + tauriInvoke.mockImplementationOnce(async () => { + throw "Shared core task failed"; + }); + + const error = await invoke("git.consolePresentation", {}).catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Shared core task failed"); +}); test("pure console projection bypasses execution events and Git settings", async () => { const args = { records: [], search: "" }; diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 05e92ac33..4d66dbd82 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -15,89 +15,76 @@ import { adaptCoreResult } from "./core-result-adapter"; export { Channel, convertFileSrc }; -const nativeCommands = new Set([ - "begin_frontend_terminal_session", - "clipboard_clear", - "clipboard_get", - "clipboard_paste", - "clipboard_set", - "clear_lithe_logs", - "close_terminal", - "core_cancel", - "core_execute", - "read_document_file", - "read_document_file_details", - "read_document_file_change", - "save_document_file", - "set_document_watches", - "create_app_window", - "claim_project_window", - "release_project_window", - "release_pending_project_window", - "create_terminal", - "debug_send_request", - "debug_start_session", - "debug_session_ready", - "debug_stop_session", - "debug_stop_workspace_sessions", - "export_diagnostic_bundle", - "get_secure_secret", - "frontend_trace", - "get_application_memory_usage", - "get_bundled_extensions_path", - "get_log_settings", - "get_monospace_fonts", - "get_symlink_info", - "get_system_fonts", - "get_system_theme", - "get_tool_path", - "install_language_tools", - "cancel_language_tool_install", - "uninstall_language_tools", - "list_shells", - "lsp_rebuild_java_index", - "lsp_resolve_java_launch", - "maven_load_configuration", - "maven_write_configuration", - "move_file", - "open_log_directory", - "open_file_external", - "preview_diagnostic_bundle", - "read_file_custom", - "read_local_file", - "read_local_file_bounded", - "record_startup_milestone", - "read_lithe_log", - "remove_secure_secret", - "resolve_previous_log_cleanup", - "rename_file", - "run_discover_toolchains", - "run_list_java_sources", - "run_resolve_launch", - "run_resolve_toolchains", - "run_start_process", - "run_stop_process", - "run_write_documents", - "run_write_generated", - "run_write_stdin", - "set_native_window_appearance", - "set_diagnostic_logging", - "set_log_directory", - "set_project_root", - "start_watching", - "stop_watching", - "watch_git_repository", - "unwatch_git_repository", - "store_secure_secret", - "terminal_resize", - "terminal_set_paused", - "terminal_write", - "take_pending_cli_open_requests", - "warm_terminal_environment", - "validate_font", - "write_file", - "write_patch_file", -]); +/** + * Commands the shared `platform_invoke` dispatcher owns: Git compatibility + * names, AI commit generation, and dotted shared Core operations. Every other + * command is a Tauri command registered in `src-tauri/src/main.rs` and is + * invoked directly. + * + * The rule mirrors the dispatcher instead of listing native commands. A list + * silently sends a newly registered host command to the dispatcher, which + * rejects it as not implemented; that is how the Maven dependency tree broke + * (#970). `tauri-core.routing.test.ts` checks the rule against `main.rs`. + */ +export function isPlatformDispatcherCommand(command: string): boolean { + return ( + command.startsWith("git_") || + command.startsWith("ai_commit_") || + command.includes(".") + ); +} + +/** + * A rejected host command, normalized to an `Error`. + * + * Tauri rejects with whatever the Rust command serialized, usually a bare + * string. Callers that report `error instanceof Error ? error.message : …` + * would otherwise replace the host's reason with their generic fallback. + * Fields of a structured rejection such as `code` and `details` are kept, and + * `toString` returns the message so string interpolation reads as before. + */ +export class HostCommandError extends Error { + readonly command: string; + readonly code?: string; + readonly details?: unknown; + + constructor(command: string, message: string, fields: Record = {}) { + super(message); + this.name = "HostCommandError"; + this.command = command; + for (const [key, value] of Object.entries(fields)) { + if (key === "message" || key === "name" || key === "stack" || key === "command") continue; + (this as Record)[key] = value; + } + } + + override toString(): string { + return this.message; + } +} + +export function toHostCommandError(command: string, error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "string") return new HostCommandError(command, error); + if (error && typeof error === "object") { + const fields = error as Record; + const message = + typeof fields.message === "string" && fields.message.trim() + ? fields.message + : safeJson(error); + return new HostCommandError(command, message, fields); + } + return new HostCommandError(command, String(error)); +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + export function invoke(command: string, args?: InvokeArgs, options?: Partial & { gitExecutionSource?: "user" | "background" | "unknown" }): Promise { const { gitExecutionSource = "unknown", ...forwardedOptions } = options ?? {}; @@ -111,8 +98,11 @@ export function invoke(command: string, args?: InvokeArgs, options?: Partial< new Error(`${BACKEND_UNAVAILABLE_TOOLTIP}: ${requiredCapability} (${command})`), ); } - if (isNativeCommand(command)) { - return tauriInvoke(command, args, nativeOptions); + const rejectAsError = (error: unknown): never => { + throw toHostCommandError(command, error); + }; + if (!isPlatformDispatcherCommand(command)) { + return tauriInvoke(command, args, nativeOptions).catch(rejectAsError); } if ((command.startsWith("git_") || command.startsWith("git.")) && command !== "git.consolePresentation") { @@ -128,18 +118,15 @@ export function invoke(command: string, args?: InvokeArgs, options?: Partial< (value) => { return adaptCoreResult(command, args as Record | undefined, value); }, - (error) => { throw error; }, + rejectAsError, ); } return tauriInvoke("platform_invoke", { command, args: args ?? {} }, nativeOptions).then( (value) => adaptCoreResult(command, args as Record | undefined, value), + rejectAsError, ); } -export function isNativeCommand(command: string): boolean { - return nativeCommands.has(command); -} - function capabilityForCommand(command: string): BackendCapability | null { if (command.startsWith("github_")) return "github"; if (command.startsWith("docker_")) return "docker"; diff --git a/windows/tauri/src/ui/icons.test.tsx b/windows/tauri/src/ui/icons.test.tsx index 055b5f5d0..45d016269 100644 --- a/windows/tauri/src/ui/icons.test.tsx +++ b/windows/tauri/src/ui/icons.test.tsx @@ -15,7 +15,7 @@ function renderIcon(IconComponent: ElementType) { describe("application icon mappings", () => { test("exports the complete icon inventory", () => { - expect(iconEntries).toHaveLength(204); + expect(iconEntries).toHaveLength(205); }); test("avoids unintended help fallbacks", () => { diff --git a/windows/tauri/src/ui/icons.tsx b/windows/tauri/src/ui/icons.tsx index 0ff85a8b1..9a49ec9af 100644 --- a/windows/tauri/src/ui/icons.tsx +++ b/windows/tauri/src/ui/icons.tsx @@ -222,6 +222,7 @@ function createIconComponent(IconComponent: ComponentType, displayName: str export const Icon = createIconComponent(Nucleo.IconCircleQuestionOutline18, "Icon"); export const AiLoadingIcon = createIconComponent(Nucleo.IconAiLoadingOutline18, "AiLoadingIcon"); +export const LoaderCircleIcon = createIconComponent(lucideIcons.LoaderCircle, "LoaderCircleIcon"); export const ArchiveIcon = createIconComponent(Nucleo.IconArchiveOutline18, "ArchiveIcon"); export const ArrowBendDownLeftIcon = createIconComponent( Nucleo.IconArrowCornerBottomLeftOutline18, diff --git a/windows/tauri/src/ui/spinner.tsx b/windows/tauri/src/ui/spinner.tsx index 37fb3ad7a..5908fb2ce 100644 --- a/windows/tauri/src/ui/spinner.tsx +++ b/windows/tauri/src/ui/spinner.tsx @@ -1,5 +1,6 @@ import type { ComponentProps } from "react"; import { useTranslation } from "@/i18n/locale-provider"; +import { LoaderCircleIcon } from "@/ui/icons"; import { cn } from "@/utils/cn"; type SpinnerProps = Omit, "children"> & { @@ -23,12 +24,14 @@ function Spinner({ aria-hidden={showLabel || undefined} aria-label={showLabel ? undefined : resolvedLabel} className={cn( - "inline-block shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent", + "inline-flex shrink-0", compact ? "size-3" : "size-4", !showLabel && className, )} {...props} - /> + > +