From d324d5d56017148a12073de0b4b9752d91244a64 Mon Sep 17 00:00:00 2001 From: Lichenkang <2188718831@qq.com> Date: Mon, 28 Sep 2026 19:27:32 +0800 Subject: [PATCH 1/4] Merge pull request #965 from 1lck/codex/fix-agent-icon-bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(macos): 修复 Agent 对话入口闪退并改进侧栏图标 --- ...026-09-25-shared-acp-agent-conversation.md | 3 + docs/development/platform-parity-matrix.csv | 4 +- docs/development/platform-parity-matrix.md | 4 +- macos/Resources/IDEAIcons/NOTICE.txt | 3 + .../IDEAIcons/toolwindows/toolWindowAgent.svg | 7 ++ .../Lithe/Views/Agent/AgentPanelStyle.swift | 30 ++++++- .../WorkbenchModuleUIComposition.swift | 2 +- .../AgentBrandIconResourceTests.swift | 78 +++++++++++++++++++ shared/platform-feature-matrix.json | 11 ++- 9 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg create mode 100644 macos/Tests/LitheTests/AgentBrandIconResourceTests.swift diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md index 8942b1c3f..494c07eb7 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -17,6 +17,8 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 ## 决策 +- **侧栏入口图标**:Agent 使用 Lithe 原创的“对话气泡 + 星光”线性 SVG,由工作台 renderer 显式绑定,避免落入通用模块图标而让用户误认成插件管理。图标作为固定源资源随应用打包,复用现有模板着色与选中态;不下载、不生成运行时文件,不影响安装包的只读边界。面板内 Codex/Claude 标志仍表示具体供应商。 +- **安装包图标解析**:Agent 品牌图标从应用的 `Contents/Resources/Lithe_Lithe.bundle` 只读加载;资源包或图标缺失时使用默认标志。不要在已安装的 `.app` 中直接调用 SwiftPM(Swift 包管理器)生成的 `Bundle.module`:它只查应用旁的资源包和编译目录,找不到会直接终止进程,开发机器残留的构建资源还会掩盖问题。开发和测试入口保留 SwiftPM 回退;安装版不使用编译目录,也不通过启动时复制资源来修补发行包,避免改变代码签名和 Sparkle 增量更新基线。 - **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 - **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 - **API Key 模式显式登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。Codex 使用 Responses,Claude 使用 Anthropic Messages;二者均不静默回退到账户订阅。 @@ -114,6 +116,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 - 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议和 host,但订阅选择和额度 UI 待接入。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index facdfba4c..46f8d0a81 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -1,8 +1,8 @@ id,area,feature group,capability,macOS implementation,macOS verification,Windows implementation,Windows verification,owner,verification,notes,macOS evidence,Windows evidence -agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift,windows/tauri/src/features +agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift; macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg; macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift,windows/tauri/src/features agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json -agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host,rust/lithe-agent-host +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host; macos/Tests/LitheTests/AgentBrandIconResourceTests.swift,rust/lithe-agent-host agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json agent-management,AI,Agent 对话,Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败,已实现,待验证,部分实现,待验证,Agent,在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。,,macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift; macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift; rust/lithe-agent-host/src/install.rs; rust/lithe-core/src/agent/mod.rs; rust/lithe-agent-host/src/npm-progress.mjs; rust/lithe-agent-host/src/cli_update.rs; macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift,rust/lithe-core/src/agent/mod.rs agent-provider-configuration,AI,Agent 对话,Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联,已实现,待验证,部分实现,待验证,Agent,macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift; macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift,rust/lithe-core/src/ai/agent_configuration.rs; shared/fixtures/agent/provider-configuration-v1.json diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 2c9bb9b60..2e1c35bc8 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -34,10 +34,10 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 | | +| Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift`、`macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg`、`macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。 | | | Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | | Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | -| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host`、`macos/Tests/LitheTests/AgentBrandIconResourceTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 | | | Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | | Agent 对话 | **Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败**
agent-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift`、`macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift`、`rust/lithe-agent-host/src/install.rs`、`rust/lithe-core/src/agent/mod.rs`、`rust/lithe-agent-host/src/npm-progress.mjs`、`rust/lithe-agent-host/src/cli_update.rs`、`macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/agent/mod.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。 | | | Agent 对话 | **Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联**
agent-provider-configuration | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift`、`macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/ai/agent_configuration.rs`、`shared/fixtures/agent/provider-configuration-v1.json` | Agent | macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。 | | diff --git a/macos/Resources/IDEAIcons/NOTICE.txt b/macos/Resources/IDEAIcons/NOTICE.txt index f1efcf430..5bb5ba097 100644 --- a/macos/Resources/IDEAIcons/NOTICE.txt +++ b/macos/Resources/IDEAIcons/NOTICE.txt @@ -13,3 +13,6 @@ the Community icon set. The source project and these resources are distributed under the Apache License 2.0. Lithe does not include JetBrains product logos or external third-party plugin icons. See LICENSE-APACHE-2.0.txt for the license text. + +Exception: toolwindows/toolWindowAgent.svg is an original Lithe icon, not an +imported JetBrains asset. It follows the Lithe repository license. diff --git a/macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg b/macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg new file mode 100644 index 000000000..404946fea --- /dev/null +++ b/macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift index e5a46cf66..f01612380 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift @@ -56,21 +56,26 @@ struct AgentBrandIcon: View { @MainActor enum AgentBrandIconLoader { private struct CacheKey: Hashable { + let bundleURL: URL let filename: String let size: Int } private static var images: [CacheKey: NSImage] = [:] - static func image(name: String?, size: CGFloat = 64) -> NSImage? { + static func image( + name: String?, size: CGFloat = 64, + resourceBundle: Bundle? = resolveResourceBundle() + ) -> NSImage? { let filename: String switch name?.lowercased() { case "codex": filename = "openai" case "claude", "claude code": filename = "claude" default: return nil } - let key = CacheKey(filename: filename, size: max(1, Int(size.rounded()))) + guard let resourceBundle else { return nil } + let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, size: max(1, Int(size.rounded()))) if let image = images[key] { return image } - guard let url = Bundle.module.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), + guard let url = resourceBundle.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), let image = NSImage(contentsOf: url) else { return nil } image.isTemplate = true // Native Menu labels read NSImage.size rather than the SwiftUI frame. @@ -78,6 +83,25 @@ enum AgentBrandIconLoader { images[key] = image return image } + + nonisolated static func resolveResourceBundle( + mainBundle: Bundle = .main, + developmentBundle: () -> Bundle = { Bundle.module } + ) -> Bundle? { + // Packaged assets are read-only inputs. SwiftPM's generated accessor looks + // beside the app or in the build tree, not in Contents/Resources. + let packagedURL = mainBundle.resourceURL? + .appendingPathComponent("Lithe_Lithe.bundle", isDirectory: true) + if let packagedURL, let bundle = Bundle(url: packagedURL) { + return bundle + } + // A damaged installed app must use the fallback glyph, never the fatal + // SwiftPM accessor or an unrelated development machine's build output. + guard mainBundle.bundleURL.pathExtension != "app" else { return nil } + let adjacentURL = mainBundle.bundleURL + .appendingPathComponent("Lithe_Lithe.bundle", isDirectory: true) + return Bundle(url: adjacentURL) ?? developmentBundle() + } } struct AgentToolbarButtonStyle: ButtonStyle { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 105aa507c..585b590f8 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -32,7 +32,7 @@ enum WorkbenchModuleUIComposition { renderers: [ .init( id: "agent.conversation", - ideaAssetPath: nil, + ideaAssetPath: "toolwindows/toolWindowAgent.svg", isVisible: { $0.workspaceURL != nil }, isSelected: { $0.workbenchFeature.isVisible(.agent) }, // The panel renders its full layout even before the optional diff --git a/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift new file mode 100644 index 000000000..4e85e96d6 --- /dev/null +++ b/macos/Tests/LitheTests/AgentBrandIconResourceTests.swift @@ -0,0 +1,78 @@ +import AppKit +import Testing +@testable import Lithe + +@MainActor +@Suite("Agent brand icon resources") +struct AgentBrandIconResourceTests { + @Test + func installedAppLoadsBothMarksWithoutDevelopmentResources() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeApp(at: root) + let resources = try #require(app.resourceURL) + let packagedURL = resources.appendingPathComponent("Lithe_Lithe.bundle") + try FileManager.default.createDirectory(at: packagedURL, withIntermediateDirectories: true) + let source = try #require(AgentBrandIconLoader.resolveResourceBundle()?.resourceURL) + try FileManager.default.copyItem( + at: source.appendingPathComponent("AgentIcons"), + to: packagedURL.appendingPathComponent("AgentIcons") + ) + let before = try contents(at: resources) + let bundle = try #require(AgentBrandIconLoader.resolveResourceBundle(mainBundle: app) { + Issue.record("An installed app must not access the SwiftPM development fallback") + return Bundle.main + }) + #expect(bundle.bundleURL.standardizedFileURL == packagedURL.standardizedFileURL) + for name in ["Codex", "Claude"] { + let image = try #require(AgentBrandIconLoader.image(name: name, size: 16, resourceBundle: bundle)) + #expect(image.isTemplate) + #expect(image.size == NSSize(width: 16, height: 16)) + } + // Reading icons must preserve the signed release/update baseline. + #expect(try contents(at: resources) == before) + } + + @Test + func missingInstalledResourcesUseFallbackEvenAfterCacheIsWarm() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let app = try makeApp(at: root) + _ = try #require(AgentBrandIconLoader.image(name: "Codex")) + let bundle = AgentBrandIconLoader.resolveResourceBundle(mainBundle: app) { + Issue.record("Missing installed resources must not call the fatal SwiftPM accessor") + return Bundle.main + } + #expect(bundle == nil) + #expect(AgentBrandIconLoader.image(name: "Codex", resourceBundle: bundle) == nil) + let emptyURL = try #require(app.resourceURL).appendingPathComponent("Empty.bundle") + try FileManager.default.createDirectory(at: emptyURL, withIntermediateDirectories: true) + let emptyBundle = try #require(Bundle(url: emptyURL)) + #expect(AgentBrandIconLoader.image(name: "Codex", resourceBundle: emptyBundle) == nil) + } + + private func makeApp(at root: URL) throws -> Bundle { + let appURL = root.appendingPathComponent("Fixture.app") + let contents = appURL.appendingPathComponent("Contents") + try FileManager.default.createDirectory( + at: contents.appendingPathComponent("Resources"), withIntermediateDirectories: true + ) + let info = try PropertyListSerialization.data( + fromPropertyList: ["CFBundleIdentifier": "test.agent-icons", "CFBundlePackageType": "APPL"], + format: .xml, options: 0 + ) + try info.write(to: contents.appendingPathComponent("Info.plist")) + return try #require(Bundle(url: appURL)) + } + + private func contents(at root: URL) throws -> [String: Data] { + let paths = try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted() + var result: [String: Data] = [:] + for path in paths { + let url = root.appendingPathComponent(path) + let values = try url.resourceValues(forKeys: [.isRegularFileKey]) + result[path] = values.isRegularFile == true ? try Data(contentsOf: url) : Data() + } + return result + } +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index df4308d1d..18b534c04 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -61,7 +61,9 @@ "evidence": [ "macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift", "macos/Sources/Lithe/Views/Agent/AgentConversationView.swift", - "macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift" + "macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift", + "macos/Resources/IDEAIcons/toolwindows/toolWindowAgent.svg", + "macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift" ] }, "windows": { @@ -72,7 +74,7 @@ ] }, "owner": "Agent", - "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。" + "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 Agent 侧栏入口使用对话气泡加星光的专用线性图标;在深浅主题、选中与未选中状态下检查可辨识性。" }, { "id": "agent-conversation-history", @@ -134,7 +136,8 @@ "evidence": [ "macos/Sources/LitheAgentConversationModule", "macos/Sources/Lithe/Views/Agent", - "rust/lithe-agent-host" + "rust/lithe-agent-host", + "macos/Tests/LitheTests/AgentBrandIconResourceTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -147,7 +150,7 @@ "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。" + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。" }, { "id": "agent-file-references", From c69a98d68a93e54a7f69250f5c3f5a06368c7c5b Mon Sep 17 00:00:00 2001 From: arkleselect <166632698+arkleselect@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:16:02 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(macOS):=20=E6=94=AF=E6=8C=81=E4=B8=89?= =?UTF-8?q?=E5=A5=97=E5=8F=AF=E5=88=87=E6=8D=A2=E7=9A=84=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E9=A2=84=E8=AE=BE=20(#946)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(macos): add selectable keymap presets * fix(macOS): resolve Eclipse keymap inheritance and Classic Stop binding * fix(ci): allow Rust Core bridge job to finish on macOS --------- Co-authored-by: Xiaoyumuxi <3075514079@qq.com> --- .../2026-08-15-macos-keymap-customization.md | 30 +++- .github/workflows/ci-macos.yml | 2 +- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- .../KeyboardShortcutFeatureModel.swift | 13 +- .../Models/Keymap/LitheCommandCatalog.swift | 116 ++++++++++++++++ .../Lithe/Models/Settings/AppSettings.swift | 66 +++++++-- .../App/KeyboardShortcutSettingsView.swift | 15 ++ .../LitheTests/KeyboardShortcutTests.swift | 128 ++++++++++++++++++ shared/platform-feature-matrix.json | 2 +- 10 files changed, 355 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md b/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md index 023e0edff..5b182f631 100644 --- a/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md +++ b/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md @@ -4,7 +4,7 @@ ## 先说结论 -快捷键只有一个集中目录,菜单、搜索、设置和实际分发都从这个目录生成。持久化使用稳定的命令 ID,显示文字或分组变化不能随意改变 ID;普通输入按键也不能被误识别成全局命令。 +快捷键只有一个集中命令目录,菜单、搜索、设置和实际分发都从这个目录生成。用户可以在 macOS、IntelliJ IDEA Classic 和 Eclipse 三套预设间切换;每套预设的自定义修改分别保存。持久化使用稳定的命令 ID,显示文字或分组变化不能随意改变 ID;普通输入按键也不能被误识别成全局命令。 ## 问题 @@ -27,7 +27,8 @@ ID、本地化标题、分组和默认绑定;菜单、Search Everywhere、设 `⌘`、`⌥` 等字符串只在渲染时生成。普通字符必须带有 Command、Control 或 Option 之一,功能键、方向键等特殊按键可以单独使用。 -`AppSettings` 在版本化的 `keyboardShortcutOverrides` 载荷中保存用户覆盖: +`AppSettings` 保存当前预设和按预设分开的版本化用户覆盖。旧版单套 +`keyboardShortcutOverrides` 载荷只在首次迁移时归入 macOS 预设: - 没有命令条目时使用目录默认值; - 非空列表替换该命令的全部绑定; @@ -36,15 +37,29 @@ Command、Control 或 Option 之一,功能键、方向键等特殊按键可以 - 未知命令、非法绑定和重复绑定被忽略; - 整个载荷无法解码时回退到全部默认值。 +预设只映射 Lithe 已有且与 IntelliJ Community 源码能对应的操作,真正没有 +对应上游动作的 Lithe 命令才沿用 Lithe 默认绑定。Eclipse 预设先解析 +`$default`、`Mac OS X 10.5+`、`Eclipse (Mac OS X)` 的继承顺序;子层 +没有声明动作时继承父层,声明空动作时清除父层绑定。例如 Eclipse 的 +`Find` 为空,不能回退成 Lithe 的 `⌘F`。切换预设时先切换预设身份,再发布该预设的覆盖值, +使设置页、菜单和原生监听器同时看到新绑定;录制中的操作在切换前取消。 +IDEA 的 `Stop` 是按上下文停止运行或调试的单一动作;Lithe 目前仍有独立的 +`stop-run` 和 `stop-debug`,因此 `Control-F2` 暂时只绑定 `stop-run`。 +要同时覆盖调试场景,需要先提供能识别当前会话的统一 Stop 命令。 +应用级恢复默认清除所有预设的用户覆盖并返回 macOS,Keymap 页的恢复默认 +只清除当前预设的覆盖。官方映射依据是 IntelliJ Community 的 +[`$default.xml`](https://github.com/JetBrains/intellij-community/blob/master/platform/platform-resources/src/keymaps/%24default.xml) +和 [`Eclipse (Mac OS X).xml`](https://github.com/JetBrains/intellij-community/blob/master/plugins/keymaps/eclipse-keymap/resources/keymaps/Eclipse%20%28Mac%20OS%20X%29.xml); +Eclipse 文件继承的 macOS 映射见 [`Mac OS X 10.5+.xml`](https://github.com/JetBrains/intellij-community/blob/master/platform/platform-resources/src/keymaps/Mac%20OS%20X%2010.5%2B.xml)。 + `KeyboardShortcutFeatureModel` 负责合成有效值、录制状态、冲突检测、单项 恢复和全部恢复。相同绑定发生冲突时指出占用它的命令并拒绝保存,不自动 覆盖已有命令。`ShortcutSessionCoordinator` 将有效注册同步给 macOS 监听器;录制快捷键时暂停命令分发,非活动会话和已关闭会话不执行排队的 旧命令。 -Search Everywhere 保留双击 Shift 和 `⇧⌘O` 两个默认入口。应用级 -“恢复全部默认”会同时清除快捷键覆盖,Keymap 页面自己的“全部恢复默认” -只清除快捷键覆盖。该功能只属于 macOS,不把 Windows keymap 或新的跨平台 +Search Everywhere 的 macOS 预设保留双击 Shift 和 `⇧⌘O` 两个默认入口。 +该功能只属于 macOS,不把 Windows keymap 或新的跨平台 快捷键契约纳入本决策。 ## 考虑过的备选方案 @@ -59,8 +74,9 @@ Search Everywhere 保留双击 Shift 和 `⇧⌘O` 两个默认入口。应用 明确报告冲突并拒绝保存。 - **立即设计 Windows 与 macOS 共用 keymap 契约**:两端事件模型和原生 编辑行为不同,当前收益不足以抵消跨平台兼容面,暂不扩展范围。 -- **加入 IDEA keymap 导入、预设、导出或云同步**:这些能力会扩大稳定性 - 和数据兼容范围,当前需求不包含它们。 +- **直接导入 IDEA 全量 keymap 文件**:Lithe 没有 IDEA 的全部命令和动作 + 语义,全量导入会产生无法执行的配置;只映射现有对应操作。自定义 keymap + 文件导入、导出和云同步仍不在本次范围。 ## 后果 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index eaa5b0dfd..c424a3123 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -219,7 +219,7 @@ jobs: needs: changes if: needs.changes.outputs.rust_core == 'true' runs-on: macos-26 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out source diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 46f8d0a81..2470ef4cd 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -74,7 +74,7 @@ references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实 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,从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。,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 +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 onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。,,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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 2e1c35bc8..017e443c7 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -212,7 +212,7 @@ | 功能组 | 能力点 | 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 | 从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 | +| 应用配置 | **快捷键查看与自定义**
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、原生文件对话框和快捷键实际执行验收。 | diff --git a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift index d0f6fc1bf..40976814d 100644 --- a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift @@ -35,6 +35,14 @@ final class KeyboardShortcutFeatureModel: ObservableObject { LitheCommandCatalog.commands } + var selectedPreset: KeyboardShortcutPreset { + settings.keyboardShortcutPreset + } + + func selectPreset(_ preset: KeyboardShortcutPreset) { + settings.selectKeyboardShortcutPreset(preset) + } + var registrations: [KeyboardShortcutRegistration] { registrations(for: settings.keyboardShortcutOverrides) } @@ -45,7 +53,7 @@ final class KeyboardShortcutFeatureModel: ObservableObject { commands.map { command in KeyboardShortcutRegistration( commandID: command.id, - bindings: overrides[command.id] ?? command.defaultBindings + bindings: overrides[command.id] ?? settings.keyboardShortcutPreset.bindings(for: command) ) } } @@ -85,7 +93,8 @@ final class KeyboardShortcutFeatureModel: ObservableObject { if let override = settings.keyboardShortcutOverrides[commandID] { return override } - return LitheCommandCatalog.command(id: commandID)?.defaultBindings ?? [] + guard let command = LitheCommandCatalog.command(id: commandID) else { return [] } + return settings.keyboardShortcutPreset.bindings(for: command) } func displayText(for commandID: String) -> String? { diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 1de0c71c2..3eecf3591 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -8,6 +8,122 @@ struct LitheCommandDefinition: Identifiable, Equatable, Sendable { let defaultBindings: [KeyboardShortcutBinding] } +enum KeyboardShortcutPreset: String, CaseIterable, Sendable { + case macOS = "macOS" + case ideaClassic = "idea-classic" + case eclipse = "eclipse" + + var title: String { + switch self { + case .macOS: "macOS" + case .ideaClassic: "IntelliJ IDEA Classic" + case .eclipse: "Eclipse" + } + } + + func bindings(for command: LitheCommandDefinition) -> [KeyboardShortcutBinding] { + switch self { + case .macOS: command.defaultBindings + case .ideaClassic: Self.ideaClassicBindings[command.id] ?? command.defaultBindings + case .eclipse: Self.eclipseBindings[command.id] + ?? Self.eclipseParentBindings[command.id] + ?? command.defaultBindings + } + } + + private static func shortcut(_ key: String, _ modifiers: KeyboardShortcutModifiers = []) -> [KeyboardShortcutBinding] { + [.keyPress(key: key, modifiers: modifiers)] + } + + // Adapt the matching Lithe actions from IntelliJ Community's $default keymap. + private static let ideaClassicBindings: [String: [KeyboardShortcutBinding]] = [ + "settings": shortcut("s", [.control, .option]), + "save": shortcut("s", [.control]), + "run": shortcut("f10", [.shift]), + "debug": shortcut("f9", [.shift]), + // ponytail: JetBrains has one context-aware Stop action. Lithe binds Stop Run only + // until a shared Stop command can choose the active run or debug session. + "stop-run": shortcut("f2", [.control]), + "toggle-breakpoint": shortcut("f8", [.control]), + "view-breakpoints": shortcut("f8", [.control, .shift]), + "search-everywhere": [.doubleTap(.shift)], + "navigate-back": shortcut("left", [.control, .option]), + "navigate-forward": shortcut("right", [.control, .option]), + "find-in-file": shortcut("f", [.control]), + "find-next": shortcut("f3"), + "find-previous": shortcut("f3", [.shift]), + "replace-in-file": shortcut("r", [.control]), + "go-to-line": shortcut("g", [.control]), + "go-to-definition": shortcut("b", [.control]), + "go-to-implementation": shortcut("b", [.control, .option]), + "find-usages": shortcut("f7", [.option]), + "search-in-project": shortcut("f", [.control, .shift]), + "replace-in-project": shortcut("r", [.control, .shift]), + "toggle-terminal": shortcut("f12", [.option]), + "toggle-run": shortcut("4", [.option]), + "toggle-debug": shortcut("5", [.option]) + ] + + // Resolved matching actions from Mac OS X 10.5+, including its $default parent. + // Only Lithe-only commands fall back to Lithe defaults. + private static let eclipseParentBindings: [String: [KeyboardShortcutBinding]] = [ + "settings": shortcut(",", [.command]), + "save": shortcut("s", [.control]), + "run": shortcut("r", [.control]), + "debug": shortcut("d", [.control]), + "stop-run": shortcut("f2", [.control]), + "debug-resume": shortcut("f9"), + "debug-step-over": shortcut("f8"), + "debug-step-into": shortcut("f7"), + "debug-step-out": shortcut("f8", [.shift]), + "toggle-breakpoint": shortcut("f8", [.control]), + "view-breakpoints": shortcut("f8", [.control, .shift]), + "search-everywhere": [.doubleTap(.shift)], + "navigate-back": shortcut("[", [.command]), + "navigate-forward": shortcut("]", [.command]), + "find-in-file": shortcut("f", [.command]), + "find-next": [ + .keyPress(key: "f3", modifiers: []), + .keyPress(key: "l", modifiers: [.control]) + ], + "find-previous": [ + .keyPress(key: "f3", modifiers: [.shift]), + .keyPress(key: "l", modifiers: [.control, .shift]) + ], + "replace-in-file": shortcut("r", [.control]), + "go-to-line": shortcut("l", [.command]), + "go-to-definition": shortcut("b", [.command]), + "go-to-implementation": shortcut("b", [.control, .option]), + "find-usages": shortcut("f7", [.option]), + "search-in-project": shortcut("f", [.command, .shift]), + "replace-in-project": shortcut("r", [.command, .shift]), + "toggle-terminal": shortcut("f12", [.option]), + "toggle-problems": shortcut("6", [.command]), + "toggle-run": shortcut("4", [.command]), + "toggle-debug": shortcut("5", [.command]) + ] + + // Eclipse (Mac OS X).xml overrides its parent; an empty action clears the inherited binding. + private static let eclipseBindings: [String: [KeyboardShortcutBinding]] = [ + "run": shortcut("f11", [.command, .shift]), + "debug": shortcut("f11", [.command]), + "debug-resume": shortcut("f8"), + "debug-step-over": shortcut("f6"), + "debug-step-into": shortcut("f5"), + "debug-step-out": shortcut("f7"), + "toggle-breakpoint": shortcut("b", [.command, .shift]), + "find-in-file": [], + "find-next": shortcut("k", [.command]), + "find-previous": shortcut("k", [.command, .shift]), + "replace-in-file": [], + "find-usages": shortcut("g", [.command, .shift]), + "go-to-definition": shortcut("f3"), + "go-to-implementation": [], + "search-in-project": shortcut("h", [.control]), + "replace-in-project": [] + ] +} + // Note: macOS 快捷键的集中目录、覆盖与冲突规则见 .agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md enum LitheCommandCatalog { static let commands: [LitheCommandDefinition] = validated([ diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index 2ded17cbf..92fc82154 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -37,6 +37,8 @@ final class AppSettings: ObservableObject { static let agentArguments = "settings.agentArguments" static let agentConfigurations = "settings.agentConfigurations" static let keyboardShortcutOverrides = "settings.keyboardShortcutOverrides" + static let keyboardShortcutPreset = "settings.keyboardShortcutPreset" + static let keyboardShortcutPresets = "settings.keyboardShortcutPresets" static let customLogDirectory = "settings.customLogDirectory" static let workbenchBackground = "settings.workbenchBackground" static let javaBuildFailurePolicies = "settings.javaBuildFailurePolicies" @@ -49,6 +51,13 @@ final class AppSettings: ObservableObject { let commands: [String: [KeyboardShortcutBinding]] } + private struct KeyboardShortcutPresetsPayload: Codable { + static let currentVersion = 1 + + let version: Int + let presets: [String: [String: [KeyboardShortcutBinding]]] + } + private let defaults: any KeyValueStore private let logDirectoryProvider: any LogDirectoryProviding @@ -138,6 +147,8 @@ final class AppSettings: ObservableObject { } } @Published private(set) var keyboardShortcutOverrides: [String: [KeyboardShortcutBinding]] + @Published private(set) var keyboardShortcutPreset: KeyboardShortcutPreset + private var keyboardShortcutOverridesByPreset: [String: [String: [KeyboardShortcutBinding]]] @Published private(set) var customLogDirectory: URL? @Published private(set) var workbenchBackground: WorkbenchBackgroundConfiguration @Published var workbenchBackgroundOpacity: Double { @@ -199,7 +210,13 @@ final class AppSettings: ObservableObject { projectOpenBehavior = ProjectOpenBehavior( rawValue: defaults.string(forKey: Key.projectOpenBehavior) ?? "" ) ?? .ask - keyboardShortcutOverrides = Self.loadKeyboardShortcutOverrides(from: defaults) + let selectedPreset = KeyboardShortcutPreset( + rawValue: defaults.string(forKey: Key.keyboardShortcutPreset) ?? "" + ) ?? .macOS + let presetOverrides = Self.loadKeyboardShortcutPresets(from: defaults) + keyboardShortcutPreset = selectedPreset + keyboardShortcutOverridesByPreset = presetOverrides + keyboardShortcutOverrides = presetOverrides[selectedPreset.rawValue] ?? [:] customLogDirectory = defaults.string(forKey: Key.customLogDirectory).flatMap { path in guard !path.isEmpty else { return nil } return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL @@ -355,14 +372,25 @@ final class AppSettings: ObservableObject { setCustomLogDirectory(nil) clearWorkbenchBackground() workbenchBackgroundOpacity = 0.22 + keyboardShortcutOverridesByPreset = [:] + keyboardShortcutPreset = .macOS + defaults.set(KeyboardShortcutPreset.macOS.rawValue, forKey: Key.keyboardShortcutPreset) setKeyboardShortcutOverrides([:]) javaBuildFailurePolicies = [:] defaults.set(nil, forKey: Key.javaBuildFailurePolicies) } func setKeyboardShortcutOverrides(_ value: [String: [KeyboardShortcutBinding]]) { + keyboardShortcutOverridesByPreset[keyboardShortcutPreset.rawValue] = value keyboardShortcutOverrides = value - saveKeyboardShortcutOverrides() + saveKeyboardShortcutPresets() + } + + func selectKeyboardShortcutPreset(_ preset: KeyboardShortcutPreset) { + guard keyboardShortcutPreset != preset else { return } + keyboardShortcutPreset = preset + defaults.set(preset.rawValue, forKey: Key.keyboardShortcutPreset) + keyboardShortcutOverrides = keyboardShortcutOverridesByPreset[preset.rawValue] ?? [:] } /// Providers an agent speaking `apiProtocol` can use. @@ -511,13 +539,29 @@ final class AppSettings: ObservableObject { defaults.set(data, forKey: Key.commitMessageAI) } - private func saveKeyboardShortcutOverrides() { - let payload = KeyboardShortcutOverridesPayload( - version: KeyboardShortcutOverridesPayload.currentVersion, - commands: keyboardShortcutOverrides + private func saveKeyboardShortcutPresets() { + let payload = KeyboardShortcutPresetsPayload( + version: KeyboardShortcutPresetsPayload.currentVersion, + presets: keyboardShortcutOverridesByPreset ) guard let data = try? JSONEncoder().encode(payload) else { return } - defaults.set(data, forKey: Key.keyboardShortcutOverrides) + defaults.set(data, forKey: Key.keyboardShortcutPresets) + defaults.set(nil, forKey: Key.keyboardShortcutOverrides) + } + + private static func loadKeyboardShortcutPresets( + from defaults: any KeyValueStore + ) -> [String: [String: [KeyboardShortcutBinding]]] { + guard let data = defaults.data(forKey: Key.keyboardShortcutPresets) else { + return [KeyboardShortcutPreset.macOS.rawValue: loadKeyboardShortcutOverrides(from: defaults)] + } + guard let payload = try? JSONDecoder().decode(KeyboardShortcutPresetsPayload.self, from: data), + payload.version == KeyboardShortcutPresetsPayload.currentVersion else { + return [:] + } + return payload.presets + .filter { KeyboardShortcutPreset(rawValue: $0.key) != nil } + .mapValues(validatedKeyboardShortcutOverrides) } private static func loadKeyboardShortcutOverrides( @@ -529,8 +573,14 @@ final class AppSettings: ObservableObject { return [:] } + return validatedKeyboardShortcutOverrides(payload.commands) + } + + private static func validatedKeyboardShortcutOverrides( + _ commands: [String: [KeyboardShortcutBinding]] + ) -> [String: [KeyboardShortcutBinding]] { let knownCommandIDs = Set(LitheCommandCatalog.commands.map(\.id)) - return payload.commands.filter { commandID, bindings in + return commands.filter { commandID, bindings in knownCommandIDs.contains(commandID) && bindings.allSatisfy(\.isAssignable) && Set(bindings).count == bindings.count diff --git a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift index 760738d9b..08dfc0902 100644 --- a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift @@ -66,6 +66,20 @@ struct KeyboardShortcutSettingsView: View { .lithePointer() } + LitheSettingsSelect( + selection: Binding( + get: { feature.selectedPreset }, + set: { preset in + cancelEditing() + feature.selectPreset(preset) + } + ), + options: KeyboardShortcutPreset.allCases, + width: 200, + accessibilityLabel: "Keymap", + title: { $0.title } + ) + LitheSettingsSearchField("Search actions or shortcuts", text: $query) { _ in editingTarget = nil validationIssue = nil @@ -228,6 +242,7 @@ struct KeyboardShortcutSettingsView: View { } private func cancelEditing() { + feature.endRecording() editingTarget = nil validationIssue = nil } diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index cd38d6cd1..97a46edf9 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -21,6 +21,48 @@ struct KeyboardShortcutTests { } } + @Test + func presetsContainAssignableConflictFreeBindings() { + for preset in KeyboardShortcutPreset.allCases { + var owners: [KeyboardShortcutBinding: String] = [:] + for command in LitheCommandCatalog.commands { + for binding in preset.bindings(for: command) { + #expect(binding.isAssignable, "Invalid \(preset.title) binding for \(command.id)") + #expect(owners[binding] == nil, "\(preset.title): \(command.id) conflicts with \(owners[binding] ?? "")") + owners[binding] = command.id + } + } + } + } + + @Test + func eclipseResolvesJetBrainsInheritanceAndExplicitlyClearedActions() throws { + func bindings(_ id: String) throws -> [KeyboardShortcutBinding] { + KeyboardShortcutPreset.eclipse.bindings(for: try #require(LitheCommandCatalog.command(id: id))) + } + + #expect(try bindings("save") == [.keyPress(key: "s", modifiers: [.control])]) // $default + #expect(try bindings("settings") == [.keyPress(key: ",", modifiers: [.command])]) // Mac parent + #expect(try bindings("toggle-run") == [.keyPress(key: "4", modifiers: [.command])]) + #expect(try bindings("debug-resume") == [.keyPress(key: "f8", modifiers: [])]) // Eclipse override + for id in ["find-in-file", "replace-in-file", "go-to-implementation", "replace-in-project"] { + #expect(try bindings(id).isEmpty, "Eclipse explicitly clears \(id)") + } + #expect(try bindings("open-project") == [.keyPress(key: "o", modifiers: [.command])]) // Lithe-only + + let feature = KeyboardShortcutFeatureModel(settings: AppSettings(store: KeyboardShortcutTestStore())) + feature.selectPreset(.eclipse) + #expect(feature.displayText(for: "find-in-file") == nil) + #expect(feature.registrations.first { $0.commandID == "find-in-file" }?.bindings == []) + } + + @Test + func ideaClassicStopUsesControlF2() throws { + let stop = try #require(LitheCommandCatalog.command(id: "stop-run")) + #expect(KeyboardShortcutPreset.ideaClassic.bindings(for: stop) + == [.keyPress(key: "f2", modifiers: [.control])]) + } + @Test func toggleBreakpointUsesTheIDEADefaultShortcut() throws { let command = try #require(LitheCommandCatalog.command(id: "toggle-breakpoint")) @@ -121,6 +163,55 @@ struct KeyboardShortcutTests { #expect(settings.keyboardShortcutOverrides.isEmpty) } + @Test + func switchingPresetsKeepsOverridesSeparateAndPersistsSelection() throws { + let store = KeyboardShortcutTestStore() + let settings = AppSettings(store: store) + let feature = KeyboardShortcutFeatureModel(settings: settings) + let macBinding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option]) + let classicBinding = KeyboardShortcutBinding.keyPress(key: "p", modifiers: [.control, .option]) + + try feature.replaceBindings(for: "run", with: [macBinding]) + feature.selectPreset(.ideaClassic) + #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f10", modifiers: [.shift])]) + try feature.replaceBindings(for: "run", with: [classicBinding]) + feature.selectPreset(.macOS) + #expect(feature.effectiveBindings(for: "run") == [macBinding]) + feature.selectPreset(.eclipse) + #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f11", modifiers: [.command, .shift])]) + + let reloaded = KeyboardShortcutFeatureModel(settings: AppSettings(store: store)) + #expect(reloaded.selectedPreset == .eclipse) + reloaded.selectPreset(.ideaClassic) + #expect(reloaded.effectiveBindings(for: "run") == [classicBinding]) + reloaded.selectPreset(.macOS) + #expect(reloaded.effectiveBindings(for: "run") == [macBinding]) + } + + @Test + func legacyOverridesMigrateToTheMacOSPreset() throws { + struct LegacyPayload: Encodable { + let version = 1 + let commands: [String: [KeyboardShortcutBinding]] + } + let store = KeyboardShortcutTestStore() + let binding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option]) + store.set( + try JSONEncoder().encode(LegacyPayload(commands: ["run": [binding]])), + forKey: "settings.keyboardShortcutOverrides" + ) + + let feature = KeyboardShortcutFeatureModel(settings: AppSettings(store: store)) + #expect(feature.selectedPreset == .macOS) + #expect(feature.effectiveBindings(for: "run") == [binding]) + feature.selectPreset(.eclipse) + #expect(feature.effectiveBindings(for: "run") != [binding]) + feature.selectPreset(.macOS) + #expect(feature.effectiveBindings(for: "run") == [binding]) + try feature.replaceBindings(for: "run", with: [binding]) + #expect(store.data(forKey: "settings.keyboardShortcutOverrides") == nil) + } + @Test func conflictReportsTheOwningCommandAndDoesNotPersist() throws { let settings = AppSettings(store: KeyboardShortcutTestStore()) @@ -207,6 +298,23 @@ struct KeyboardShortcutTests { == LitheCommandCatalog.command(id: "run")?.defaultBindings ) } + + @Test + func applicationRestoreDefaultsClearsEveryPreset() throws { + let settings = AppSettings(store: KeyboardShortcutTestStore()) + let feature = KeyboardShortcutFeatureModel(settings: settings) + let binding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option]) + try feature.replaceBindings(for: "run", with: [binding]) + feature.selectPreset(.ideaClassic) + try feature.replaceBindings(for: "run", with: [binding]) + + settings.restoreDefaults() + + #expect(feature.selectedPreset == .macOS) + #expect(settings.keyboardShortcutOverrides.isEmpty) + feature.selectPreset(.ideaClassic) + #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f10", modifiers: [.shift])]) + } } @Suite("Shortcut session coordination") @@ -237,6 +345,26 @@ struct ShortcutSessionCoordinatorTests { #expect(!factory.detector.isSuspended) } + @Test + func presetSwitchUpdatesTheActiveDetectorImmediately() { + let settings = AppSettings(store: KeyboardShortcutTestStore()) + let feature = KeyboardShortcutFeatureModel(settings: settings) + let factory = RecordingShortcutDetectorFactory() + var publications = 0 + let coordinator = ShortcutSessionCoordinator( + settings: settings, feature: feature, factory: factory, + onRegistrationsChanged: { publications += 1 }, onCommand: { _ in } + ) + defer { coordinator.shutdown() } + + feature.selectPreset(.ideaClassic) + + #expect(feature.selectedPreset == .ideaClassic) + #expect(factory.detector.registrations.first { $0.commandID == "run" }?.bindings + == [.keyPress(key: "f10", modifiers: [.shift])]) + #expect(publications == 2) + } + @Test func inactiveRecordingAndShutdownSessionsRejectQueuedCommands() { let settings = AppSettings(store: KeyboardShortcutTestStore()) diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 18b534c04..6feb3bdb4 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1902,7 +1902,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。", + "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。", "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。" }, { From 84636937c589de174e2454c2882fb3176681b8af Mon Sep 17 00:00:00 2001 From: arkleselect <166632698+arkleselect@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:16:29 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=20macOS=20=E6=AC=A2?= =?UTF-8?q?=E8=BF=8E=E9=A1=B5=E6=A0=B7=E5=BC=8F=E4=B8=8E=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=EF=BC=8C=E5=AF=B9=E9=BD=90=20IDEA=20?= =?UTF-8?q?=E7=A4=BE=E5=8C=BA=E7=89=88=20(#949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(macos): 对齐欢迎页与 IDEA 社区版样式 * fix(macOS): restore welcome update and stable rollback actions --------- Co-authored-by: Lichenkang <2188718831@qq.com> --- docs/development/platform-parity-matrix.csv | 4 +- docs/development/platform-parity-matrix.md | 4 +- macos/Sources/Lithe/Views/App/RootView.swift | 4 +- .../Sources/Lithe/Views/App/WelcomeView.swift | 250 ++++++++++++------ .../Views/Components/LitheContextMenu.swift | 93 ++++--- shared/platform-feature-matrix.json | 4 +- 6 files changed, 243 insertions(+), 116 deletions(-) diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 2470ef4cd..bba97ef9b 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -76,7 +76,7 @@ diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实 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 -onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap +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-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色。,,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 @@ -87,7 +87,7 @@ remote-workspace,工作区,远程开发,远程连接与远程路径工作区,未 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 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。,,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-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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 017e443c7..9092dffcd 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -230,7 +230,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 首次启动 | **欢迎页、项目入口与首次启动引导**
onboarding-welcome | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/WelcomeView.swift`、`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/onboarding`、`windows/tauri/src/features/bootstrap` | Onboarding | 清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。 | | +| 首次启动 | **欢迎页、项目入口与首次启动引导**
onboarding-welcome | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/WelcomeView.swift`、`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/onboarding`、`windows/tauri/src/features/bootstrap` | Onboarding | 清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。 | | @@ -239,7 +239,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` | Release | 使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。 | | | 应用更新 | **失败重试与回滚**
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/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 51fe67798..0ebfef0e3 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -336,7 +336,7 @@ enum LitheWindowLayout: Equatable { case workspace case standalone - static let welcomeContentSize = NSSize(width: 900, height: 620) + static let welcomeContentSize = NSSize(width: 800, height: 650) static let workspaceContentSize = NSSize(width: 1440, height: 900) static let standaloneContentSize = NSSize(width: 1200, height: 760) static let standaloneMinimumContentSize = NSSize(width: 760, height: 480) @@ -353,7 +353,7 @@ enum LitheWindowLayout: Equatable { var minimumContentSize: NSSize { switch self { - case .welcome: NSSize(width: 820, height: 560) + case .welcome: Self.welcomeContentSize case .workspace: NSSize(width: 980, height: 640) case .standalone: Self.standaloneMinimumContentSize } diff --git a/macos/Sources/Lithe/Views/App/WelcomeView.swift b/macos/Sources/Lithe/Views/App/WelcomeView.swift index 6a0473ebd..9b8b7d439 100644 --- a/macos/Sources/Lithe/Views/App/WelcomeView.swift +++ b/macos/Sources/Lithe/Views/App/WelcomeView.swift @@ -2,125 +2,142 @@ import AppKit import SwiftUI struct WelcomeView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.locale) private var locale @EnvironmentObject private var model: AppModel @EnvironmentObject private var updateChecker: UpdateChecker @State private var projectFilter = "" @State private var hoveredProjectID: String? @State private var hoveredProjectMenuID: String? + @State private var showingStableRollback = false @FocusState private var searchFocused: Bool + // JetBrains/intellij-community: platform/platform-resources/src/themes/islands/ManyIslandsDark.theme.json + // and platform/platform-resources/src/themes/expUI/expUI_light.theme.json. + // Keep these welcome colors local so workspace and settings themes remain independent. + private var surface: Color { colorScheme == .dark ? Color(red: 0.098, green: 0.102, blue: 0.110) : .white } + private var sidebarSurface: Color { colorScheme == .dark ? Color(red: 0.098, green: 0.102, blue: 0.110) : Color(red: 0.969, green: 0.973, blue: 0.980) } + private var textColor: Color { colorScheme == .dark ? Color(red: 0.820, green: 0.827, blue: 0.851) : .black } + private var mutedColor: Color { colorScheme == .dark ? Color(red: 0.451, green: 0.463, blue: 0.486) : Color(red: 0.424, green: 0.439, blue: 0.494) } + private var separatorColor: Color { colorScheme == .dark ? Color(red: 0.149, green: 0.157, blue: 0.173) : Color(red: 0.875, green: 0.882, blue: 0.898) } + private var selectionColor: Color { colorScheme == .dark ? Color(red: 0.165, green: 0.263, blue: 0.443) : Color(red: 0.831, green: 0.886, blue: 1) } + private var hoverColor: Color { colorScheme == .dark ? .white.opacity(0.063) : Color(red: 0.922, green: 0.925, blue: 0.941) } + private var borderColor: Color { colorScheme == .dark ? Color(red: 0.251, green: 0.263, blue: 0.290) : Color(red: 0.788, green: 0.800, blue: 0.839) } + var body: some View { HStack(spacing: 0) { welcomeSidebar - Rectangle().fill(LitheTheme.divider.opacity(0.55)).frame(width: 1) + Rectangle().fill(separatorColor).frame(width: 1) projectsContent } - .background(LitheTheme.editor) + .background(surface.ignoresSafeArea(.container, edges: .top)) .background(WelcomeInitialFocusReset()) + .sheet(isPresented: $showingStableRollback) { + VStack(alignment: .leading, spacing: 16) { + Text("Return to Stable").font(.headline) + StableRollbackControl() + Button("Close") { showingStableRollback = false } + } + .padding(20) + .frame(width: 420) + } } private var welcomeSidebar: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 11) { - LitheIcons.appLogo(size: 42) - VStack(alignment: .leading, spacing: 3) { + LitheIcons.appLogo(size: 28) + VStack(alignment: .leading, spacing: 0) { Text("Lithe") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Text("\(updateChecker.versionDescription) · macOS") - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - updatePrompt + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(textColor) + Text(updateChecker.versionDescription) + .font(.system(size: 11)) + .foregroundStyle(mutedColor) } } .padding(.horizontal, 20) - .padding(.top, 28) - .padding(.bottom, 30) + .padding(.top, 26) + .padding(.bottom, 38) HStack(spacing: 9) { LitheIcon(kind: .folder, size: 15) Text("Projects") .font(.system(size: 13, weight: .medium)) } - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(textColor) .padding(.horizontal, 14) .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 36) - .background(LitheTheme.selection) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) + .frame(height: 32) + .background(selectionColor) + .clipShape(RoundedRectangle(cornerRadius: 4)) .padding(.horizontal, 14) Spacer() Button { - model.showSettings() + showSettingsMenu() } label: { - HStack(spacing: 9) { - LitheSystemIcon(systemImage: "gearshape") - .font(.system(size: 14)) - Text("Settings") - .font(.system(size: 12.5)) - Spacer() - } - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 14) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 34) - .contentShape(Rectangle()) + LitheIDEAIcon(resourcePath: "expui/general/settings@20x20.svg", size: 14) + .frame(width: 28, height: 28) + .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: selectionColor) } .buttonStyle(.plain) - .lithePointer() - .litheRowHover() + .foregroundStyle(mutedColor) + .help("Settings") + .accessibilityLabel("Settings") .padding(.horizontal, 14) .padding(.bottom, 14) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(width: 240) + .frame(width: 224) .background( - LitheTheme.sidebar + sidebarSurface .ignoresSafeArea(.container, edges: .top) ) } - @ViewBuilder - private var updatePrompt: some View { - VStack(alignment: .leading, spacing: 8) { - UpdateControl() - StableRollbackControl(compact: true) - } - } - private var projectsContent: some View { VStack(spacing: 0) { HStack(spacing: 10) { HStack(spacing: 8) { LitheSystemIcon(systemImage: "magnifyingglass") .font(.system(size: 12.5)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(mutedColor) TextField("Search projects", text: $projectFilter) .textFieldStyle(.plain) .font(.system(size: 13)) .focused($searchFocused) } - .litheSearchField(isFocused: searchFocused, height: 30) - .frame(maxWidth: 300) + .foregroundStyle(textColor) + .padding(.horizontal, 8) + .frame(maxWidth: 220) Spacer() - Button("Clone") { - model.showCloneRepository() + Button("New Project") { + model.chooseProject(title: "New Project", prompt: "Choose Folder") } - .buttonStyle(LitheSecondaryButtonStyle()) + .buttonStyle(.plain) + .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) Button("Open") { model.chooseProject() } - .buttonStyle(LithePrimaryButtonStyle()) + .buttonStyle(.plain) + .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) + + Button("Clone Repository") { + model.showCloneRepository() + } + .buttonStyle(.plain) + .welcomeActionStyle(foreground: textColor, border: borderColor, hover: hoverColor) } - .padding(.horizontal, 20) - .frame(height: 66) + .padding(.horizontal, 14) + .frame(height: 68) - Rectangle().fill(LitheTheme.divider).frame(height: 1) - .padding(.horizontal, 18) + Rectangle().fill(separatorColor).frame(height: 1) + .padding(.horizontal, 12) if filteredProjects.isEmpty { emptyProjectsState @@ -132,25 +149,25 @@ struct WelcomeView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.horizontal, 24) + .padding(.vertical, 14) } } } - .background(LitheTheme.editor) + .background(surface.ignoresSafeArea(.container, edges: .top)) } private var emptyProjectsState: some View { VStack(spacing: 10) { Image(systemName: "folder.badge.plus") .font(.system(size: 32, weight: .light)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(mutedColor) Text(model.recentProjects.isEmpty ? "No recent projects" : "No matching projects") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(textColor) Text("Open a local folder to start working.") .font(LitheTheme.uiFont) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(mutedColor) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -161,21 +178,21 @@ struct WelcomeView: View { Button { if exists { model.openProject(project.url) } } label: { - HStack(spacing: 12) { + HStack(spacing: 8) { Text(initials(for: project.name)) - .font(.system(size: 12, weight: .bold, design: .rounded)) + .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundStyle(.white) - .frame(width: 34, height: 34) - .background(exists ? color(for: project.name) : LitheTheme.raised) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 20, height: 20) + .background(exists ? color(for: project.name) : hoverColor) + .clipShape(RoundedRectangle(cornerRadius: 4)) VStack(alignment: .leading, spacing: 3) { Text(project.name) .font(.system(size: 13.5, weight: .medium)) - .foregroundStyle(exists ? LitheTheme.primaryText : LitheTheme.secondaryText) + .foregroundStyle(exists ? textColor : mutedColor) Text(project.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(mutedColor) .lineLimit(1) .truncationMode(.middle) } @@ -185,7 +202,6 @@ struct WelcomeView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .lithePointer() .disabled(!exists) Spacer(minLength: 0) @@ -202,11 +218,11 @@ struct WelcomeView: View { } } label: { ZStack { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) - .fill(hoveredProjectMenuID == project.id ? LitheTheme.hoverBackground : .clear) + RoundedRectangle(cornerRadius: 4) + .fill(hoveredProjectMenuID == project.id ? hoverColor : .clear) LitheSystemIcon(systemImage: "ellipsis") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(mutedColor) } .frame(width: 28, height: 28) .contentShape(Rectangle()) @@ -216,16 +232,15 @@ struct WelcomeView: View { } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .lithePointer() .frame(width: 28, height: 28) .opacity(hoveredProjectID == project.id ? 1 : 0) .allowsHitTesting(hoveredProjectID == project.id) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) - .frame(height: 52) - .background(hoveredProjectID == project.id ? LitheTheme.hoverBackground : .clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) + .frame(height: 68) + .background(hoveredProjectID == project.id ? selectionColor : .clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) .onHover { isHovering in hoveredProjectID = isHovering ? project.id : nil } @@ -252,6 +267,52 @@ struct WelcomeView: View { } } + private func showSettingsMenu() { + guard let window = NSApp.keyWindow else { return } + let screenPoint = NSApp.currentEvent.flatMap { event in + event.window === window ? window.convertPoint(toScreen: event.locationInWindow) : nil + } ?? NSPoint(x: window.frame.minX + 28, y: window.frame.minY + 28) + var items: [LitheContextMenuItem] = [ + .action("Settings…", action: { model.showSettings() }), + .separator + ] + switch updateChecker.status { + case .available(let version, _): + items.append(.action("Update to \(version)…", action: { updateChecker.presentDetails() })) + case .waitingForTermination: + items.append(.action("Continue Installation", action: { + Task { await updateChecker.retryInstallation() } + })) + case .checking: + items.append(.action("Checking for Updates…", isEnabled: false, action: {})) + case .downloading: + items.append(.action("Downloading Update…", isEnabled: false, action: {})) + case .installing: + items.append(.action("Installing Update…", isEnabled: false, action: {})) + case .failed where updateChecker.updateInfo != nil: + items.append(.action("Retry Update…", action: { updateChecker.presentDetails() })) + case .idle, .upToDate, .failed: + items.append(.action( + "Check for Updates…", + isEnabled: !updateChecker.isBusy, + action: { + Task { await updateChecker.checkForUpdates(manual: true, presentingDetails: true) } + } + )) + } + if updateChecker.isPreview { + items.append(.action("Return to Stable…", action: { showingStableRollback = true })) + } + LitheContextMenuPresenter.shared.show( + items: items, + at: screenPoint, + appearance: window.effectiveAppearance, + locale: locale, + opensUpward: true, + settingsStyle: true + ) + } + private func initials(for name: String) -> String { let words = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) let characters = words.prefix(2).compactMap(\.first) @@ -260,11 +321,15 @@ struct WelcomeView: View { private func color(for value: String) -> Color { let palette: [Color] = [ - Color(red: 0.90, green: 0.43, blue: 0.28), - Color(red: 0.12, green: 0.63, blue: 0.68), - Color(red: 0.28, green: 0.53, blue: 0.88), - Color(red: 0.30, green: 0.66, blue: 0.48), - Color(red: 0.70, green: 0.52, blue: 0.12) + Color(red: 0.878, green: 0.533, blue: 0.333), + Color(red: 0.690, green: 0.545, blue: 0.078), + Color(red: 0.631, green: 0.639, blue: 0.349), + Color(red: 0.231, green: 0.573, blue: 0.722), + Color(red: 0.208, green: 0.455, blue: 0.941), + Color(red: 0.584, green: 0.353, blue: 0.878), + Color(red: 0.373, green: 0.678, blue: 0.396), + Color(red: 0.141, green: 0.639, blue: 0.580), + Color(red: 0.784, green: 0.302, blue: 0.561) ] var hash: UInt64 = 1_469_598_103_934_665_603 for byte in value.utf8 { @@ -275,6 +340,35 @@ struct WelcomeView: View { } } +private struct WelcomeActionStyle: ViewModifier { + let foreground: Color + let border: Color + let hover: Color + @State private var isHovering = false + + func body(content: Content) -> some View { + content + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(foreground) + .padding(.horizontal, 13) + .frame(height: 28) + .background(isHovering ? hover : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .strokeBorder(border, lineWidth: 1) + } + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + } +} + +private extension View { + func welcomeActionStyle(foreground: Color, border: Color, hover: Color) -> some View { + modifier(WelcomeActionStyle(foreground: foreground, border: border, hover: hover)) + } +} + private struct WelcomeInitialFocusReset: NSViewRepresentable { func makeNSView(context: Context) -> NSView { WelcomeInitialFocusResetView() diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index 6493d464d..911210f61 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -148,11 +148,13 @@ private final class LitheContextMenuSelection: ObservableObject { } private struct LitheContextMenuContent: View { + @Environment(\.colorScheme) private var colorScheme @ObservedObject var selection: LitheContextMenuSelection let width: CGFloat let submenuWidth: CGFloat let submenuOnLeft: Bool let maximumHeight: CGFloat + let settingsStyle: Bool var body: some View { HStack(alignment: .top, spacing: LitheContextMenuMetrics.submenuSpacing) { @@ -178,6 +180,7 @@ private struct LitheContextMenuContent: View { LitheContextMenuRow( item: item, isSelected: (isChild ? selection.childID : selection.selectedID) == item.id, + settingsStyle: settingsStyle, action: { if case .submenu = item.kind { selection.open(item.id) } else { selection.dismiss(); item.action() } @@ -203,26 +206,42 @@ private struct LitheContextMenuContent: View { if let id { proxy.scrollTo(id) } } } - .frame(width: width, height: min(LitheContextMenuPresenter.menuHeight(for: items), maximumHeight)) - .litheContextMenuSurface() + .frame(width: width, height: min(LitheContextMenuPresenter.menuHeight(for: items, settingsStyle: settingsStyle), maximumHeight)) + .background { + RoundedRectangle(cornerRadius: settingsStyle ? 8 : LitheTheme.Metrics.contextMenuCornerRadius) + .fill(settingsStyle + ? (colorScheme == .dark ? Color(red: 38 / 255, green: 40 / 255, blue: 44 / 255) : .white) + : LitheTheme.contextMenuBackground) + } + .overlay { + RoundedRectangle(cornerRadius: settingsStyle ? 8 : LitheTheme.Metrics.contextMenuCornerRadius) + .stroke(settingsStyle + ? (colorScheme == .dark ? Color(red: 76 / 255, green: 79 / 255, blue: 86 / 255) + : Color(red: 233 / 255, green: 234 / 255, blue: 238 / 255)) + : LitheTheme.panelBorder, lineWidth: 1) + } } } private struct LitheContextMenuRow: View { + @Environment(\.colorScheme) private var colorScheme let item: LitheContextMenuItem let action: (() -> Void)? let onSubmenuHover: ((Bool) -> Void)? let isSelected: Bool + let settingsStyle: Bool private var isHovering: Bool { isSelected } init( item: LitheContextMenuItem, isSelected: Bool, + settingsStyle: Bool, action: @escaping () -> Void, onHover: ((Bool) -> Void)? = nil ) { self.item = item self.isSelected = isSelected + self.settingsStyle = settingsStyle self.action = action self.onSubmenuHover = onHover } @@ -237,24 +256,26 @@ private struct LitheContextMenuRow: View { action?() } label: { HStack(spacing: 9) { - Group { - if let iconKind = item.iconKind { - LitheIcon(kind: iconKind, size: 16) - } else if let systemImage = item.systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .regular)) - } else { - Color.clear + if !settingsStyle { + Group { + if let iconKind = item.iconKind { + LitheIcon(kind: iconKind, size: 16) + } else if let systemImage = item.systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .regular)) + } else { + Color.clear + } } + .frame(width: 16, height: 16) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) } - .frame(width: 16, height: 16) - .foregroundStyle( - isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText - ) Text(LocalizedStringKey(item.title)) - .font(Font(LitheContextMenuMetrics.itemFont)) - .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText) + .font(settingsStyle ? .system(size: 12.5) : Font(LitheContextMenuMetrics.itemFont)) + .foregroundStyle(isHovering + ? (settingsStyle ? (colorScheme == .dark ? .white : .black) : LitheTheme.toolWindowSelectedText) + : LitheTheme.primaryText) .lineLimit(1) Spacer(minLength: 14) @@ -269,14 +290,19 @@ private struct LitheContextMenuRow: View { .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText) } } - .padding(.horizontal, 9) - .frame(height: LitheContextMenuMetrics.rowHeight) + .padding(.horizontal, settingsStyle ? 8 : 9) + .frame(height: settingsStyle ? 24 : LitheContextMenuMetrics.rowHeight) .contentShape(Rectangle()) .background { - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(isHovering ? LitheTheme.selection : .clear) + RoundedRectangle(cornerRadius: settingsStyle ? 4 : 5, style: .continuous) + .fill(isHovering + ? (settingsStyle + ? (colorScheme == .dark ? Color(red: 42 / 255, green: 67 / 255, blue: 113 / 255) + : Color(red: 208 / 255, green: 223 / 255, blue: 254 / 255)) + : LitheTheme.selection) + : .clear) } - .padding(.horizontal, 5) + .padding(.horizontal, settingsStyle ? 6 : 5) } .buttonStyle(.plain) .disabled(!item.isEnabled) @@ -310,20 +336,23 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { items: [LitheContextMenuItem], at screenPoint: NSPoint, appearance: NSAppearance?, - locale: Locale + locale: Locale, + opensUpward: Bool = false, + settingsStyle: Bool = false ) { dismiss() guard !items.isEmpty else { return } let menuWidth = Self.menuWidth( for: items, - minimumWidth: LitheContextMenuMetrics.minimumRootWidth + minimumWidth: settingsStyle ? 156 : LitheContextMenuMetrics.minimumRootWidth, + chromeWidth: settingsStyle ? 28 : 67 ) let visibleFrame = NSScreen.screens.first(where: { $0.frame.contains(screenPoint) })?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero self.visibleFrame = visibleFrame.insetBy(dx: 6, dy: 6) let maximumHeight = max(1, visibleFrame.height - 12) - let menuHeight = min(Self.menuHeight(for: items), maximumHeight) + let menuHeight = min(Self.menuHeight(for: items, settingsStyle: settingsStyle), maximumHeight) let submenuWidths = items.compactMap { item -> CGFloat? in guard case .submenu(let submenuItems) = item.kind else { return nil } return Self.menuWidth( @@ -337,7 +366,10 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } let submenuWidth = submenuWidths.max() ?? 0 let submenuHeight = min(submenuHeights.max() ?? 0, maximumHeight) - let preferredOrigin = NSPoint(x: screenPoint.x - 6, y: screenPoint.y - menuHeight + 6) + let preferredOrigin = NSPoint( + x: screenPoint.x - 6, + y: opensUpward ? screenPoint.y + 6 : screenPoint.y - menuHeight + 6 + ) let origin = NSPoint( x: min(max(preferredOrigin.x, visibleFrame.minX + 6), visibleFrame.maxX - menuWidth - 6), y: min(max(preferredOrigin.y, visibleFrame.minY + 6), visibleFrame.maxY - menuHeight - 6) @@ -354,7 +386,7 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } let content = LitheContextMenuContent( selection: selection, width: menuWidth, submenuWidth: submenuWidth, - submenuOnLeft: submenuOnLeft, maximumHeight: maximumHeight + submenuOnLeft: submenuOnLeft, maximumHeight: maximumHeight, settingsStyle: settingsStyle ) .environment(\.locale, locale) @@ -416,7 +448,8 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { fileprivate static func menuWidth( for items: [LitheContextMenuItem], - minimumWidth: CGFloat + minimumWidth: CGFloat, + chromeWidth: CGFloat = 67 ) -> CGFloat { let widestItem = items.reduce(CGFloat.zero) { width, item in guard case .action = item.kind else { @@ -425,20 +458,20 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } return max(width, menuItemWidth(item)) } - let contentWidth = widestItem + 67 + let contentWidth = widestItem + chromeWidth return min( max(contentWidth, minimumWidth), LitheContextMenuMetrics.maximumWidth ) } - fileprivate static func menuHeight(for items: [LitheContextMenuItem]) -> CGFloat { + fileprivate static func menuHeight(for items: [LitheContextMenuItem], settingsStyle: Bool = false) -> CGFloat { items.reduce(LitheContextMenuMetrics.verticalPadding) { height, item in switch item.kind { case .separator: height + LitheContextMenuMetrics.separatorHeight case .action, .submenu: - height + LitheContextMenuMetrics.rowHeight + height + (settingsStyle ? 24 : LitheContextMenuMetrics.rowHeight) } } } diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 6feb3bdb4..4c6a62a37 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1951,7 +1951,7 @@ "verificationStatus": "pending" }, "owner": "Onboarding", - "verification": "清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。" + "verification": "清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。" }, { "id": "command-palette", @@ -2221,7 +2221,7 @@ "verificationStatus": "pending" }, "owner": "Release", - "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从菜单或欢迎页手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。" + "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。" }, { "id": "updates-failure-rollback", From 7ba56adcc55d5d33c78f81acf6cd4cbd3a0e4fdb Mon Sep 17 00:00:00 2001 From: arkleselect <166632698+arkleselect@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:19:32 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20macOS=20=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E9=A1=B6=E6=A0=8F=E5=AF=B9=E9=BD=90=E4=B8=8E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=85=8D=E8=89=B2=20(#964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): align workspace titlebar and project identity styling * feat(macOS): 支持三套可切换的快捷键预设 (#946) * feat(macos): add selectable keymap presets * fix(macOS): resolve Eclipse keymap inheritance and Classic Stop binding * fix(ci): allow Rust Core bridge job to finish on macOS --------- Co-authored-by: Xiaoyumuxi <3075514079@qq.com> * 优化 macOS 欢迎页样式与窗口尺寸,对齐 IDEA 社区版 (#949) * style(macos): 对齐欢迎页与 IDEA 社区版样式 * fix(macOS): restore welcome update and stable rollback actions --------- Co-authored-by: Lichenkang <2188718831@qq.com> --------- Co-authored-by: Lichenkang <2188718831@qq.com> Co-authored-by: Xiaoyumuxi <3075514079@qq.com> --- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- macos/Sources/Lithe/Theme/LitheTheme.swift | 2 + macos/Sources/Lithe/Views/App/RootView.swift | 29 ++++- .../Sources/Lithe/Views/App/WelcomeView.swift | 38 +------ .../Lithe/Views/Workbench/WorkbenchView.swift | 102 ++++++++++++++--- .../Workspace/ProjectIdentityAppearance.swift | 106 ++++++++++++++++++ .../Workspace/ProjectSwitcherPopover.swift | 36 +----- .../LitheTests/LitheCoreLogicTests.swift | 68 +++++++++++ shared/platform-feature-matrix.json | 2 +- 10 files changed, 303 insertions(+), 84 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Workspace/ProjectIdentityAppearance.swift diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index bba97ef9b..b280121c5 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -78,7 +78,7 @@ keymap-customization,设置,应用配置,快捷键查看与自定义,已实现, 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 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-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色。,,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-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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 9092dffcd..898919c65 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -176,7 +176,7 @@ | 本地历史 | **快照列表与 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 和设置命令,确认快捷键和不可用命令状态。 | | -| 命令与布局 | **分栏、面板与工具窗布局**
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 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色。 | | +| 命令与布局 | **分栏、面板与工具窗布局**
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 | 打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。 | | diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 02be82623..9916de2fa 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -231,6 +231,7 @@ enum LitheTheme { static var activeTheme: AppColorTheme { AppThemeRuntime.shared.activeTheme } enum ResolvedColorToken { + case titlebar case editor case sidebar case toolHeader @@ -255,6 +256,7 @@ enum LitheTheme { ) -> NSColor { let palette = Palette.make(theme: theme, isDark: isDark) return switch token { + case .titlebar: palette.titlebar.nsColor case .editor: palette.editor.nsColor case .sidebar: palette.sidebar.nsColor case .toolHeader: palette.toolHeader.nsColor diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 0ebfef0e3..723d05fbf 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -479,6 +479,16 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { projectSessions.noteWindowBecameKey() } + func windowDidResize(_ notification: Notification) { + guard let window, notification.object as? NSWindow === window, let layout else { return } + alignWindowButtons(in: window, for: layout) + } + + func windowDidExitFullScreen(_ notification: Notification) { + guard let window, notification.object as? NSWindow === window, let layout else { return } + alignWindowButtons(in: window, for: layout) + } + func windowShouldClose(_ sender: NSWindow) -> Bool { if case .projectCleanupCompleted? = pendingNativeWindowCloseIntent { pendingNativeWindowCloseIntent = nil @@ -594,7 +604,10 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { window.title = "" window.titleVisibility = .hidden } - guard self.layout != layout else { return } + guard self.layout != layout else { + alignWindowButtons(in: window, for: layout) + return + } let shouldAnimate = self.layout != nil && window.isVisible self.layout = layout @@ -618,6 +631,20 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame) } window.setFrame(targetFrame, display: true, animate: shouldAnimate) + alignWindowButtons(in: window, for: layout) + } + + private func alignWindowButtons(in window: NSWindow, for layout: LitheWindowLayout) { + guard !window.styleMask.contains(.fullScreen) else { return } + // AppKit centers these buttons in its native titlebar; the workspace + // draws a taller toolbar underneath them. + for type in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + guard let button = window.standardWindowButton(type), let host = button.superview else { continue } + let headerHeight = layout == .workspace ? LitheTheme.Metrics.toolbarHeight : host.bounds.height + let centeredY = host.bounds.maxY - headerHeight / 2 - button.frame.height / 2 + guard abs(button.frame.minY - centeredY) > 0.5 else { continue } + button.setFrameOrigin(NSPoint(x: button.frame.minX, y: centeredY)) + } } private func defaultWorkspaceFrame(for window: NSWindow, fitting visibleFrame: NSRect) -> NSRect { diff --git a/macos/Sources/Lithe/Views/App/WelcomeView.swift b/macos/Sources/Lithe/Views/App/WelcomeView.swift index 9b8b7d439..47216eac3 100644 --- a/macos/Sources/Lithe/Views/App/WelcomeView.swift +++ b/macos/Sources/Lithe/Views/App/WelcomeView.swift @@ -179,12 +179,12 @@ struct WelcomeView: View { if exists { model.openProject(project.url) } } label: { HStack(spacing: 8) { - Text(initials(for: project.name)) - .font(.system(size: 10, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .frame(width: 20, height: 20) - .background(exists ? color(for: project.name) : hoverColor) - .clipShape(RoundedRectangle(cornerRadius: 4)) + ProjectAvatarBadge( + name: project.name, + colorIndex: ProjectIdentityAppearance.colorIndex(for: project.url), + size: 20, + isEnabled: exists + ) VStack(alignment: .leading, spacing: 3) { Text(project.name) @@ -312,32 +312,6 @@ struct WelcomeView: View { settingsStyle: true ) } - - private func initials(for name: String) -> String { - let words = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) - let characters = words.prefix(2).compactMap(\.first) - return characters.isEmpty ? "LI" : String(characters).uppercased() - } - - private func color(for value: String) -> Color { - let palette: [Color] = [ - Color(red: 0.878, green: 0.533, blue: 0.333), - Color(red: 0.690, green: 0.545, blue: 0.078), - Color(red: 0.631, green: 0.639, blue: 0.349), - Color(red: 0.231, green: 0.573, blue: 0.722), - Color(red: 0.208, green: 0.455, blue: 0.941), - Color(red: 0.584, green: 0.353, blue: 0.878), - Color(red: 0.373, green: 0.678, blue: 0.396), - Color(red: 0.141, green: 0.639, blue: 0.580), - Color(red: 0.784, green: 0.302, blue: 0.561) - ] - var hash: UInt64 = 1_469_598_103_934_665_603 - for byte in value.utf8 { - hash ^= UInt64(byte) - hash &*= 1_099_511_628_211 - } - return palette[Int(hash % UInt64(palette.count))] - } } private struct WelcomeActionStyle: ViewModifier { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 1539b04aa..0b14bbfd0 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -25,17 +25,37 @@ private enum WorkbenchWorkspaceMetrics { static let paneCornerRadius: CGFloat = 10 } +private enum WorkbenchTopBarMetrics { + // IDEA reserves 78pt for macOS window controls, then leaves a small widget gap. + static let leadingInset: CGFloat = 83 + static let projectAvatarSize: CGFloat = 20 + static let projectAvatarLeadingInset: CGFloat = 6 + static let projectAvatarCenterX = leadingInset + projectAvatarLeadingInset + projectAvatarSize / 2 +} + private enum WorkbenchFrameGradient { static let coordinateSpace = "workbenchFrame" - static let radius: CGFloat = 720 - - static func fill(offset: CGPoint = .zero, size: CGFloat = 1) -> RadialGradient { - RadialGradient( - colors: [Color(red: 0.18, green: 0.25, blue: 0.28), LitheTheme.titlebar], - center: UnitPoint(x: -offset.x / size, y: -offset.y / size), - startRadius: 0, - endRadius: radius - ) + static let width: CGFloat = 600 + static let height: CGFloat = 300 + + static func color(glow: Color, background: Color, at point: CGPoint) -> Color { + let center = WorkbenchTopBarMetrics.projectAvatarCenterX + let horizontal = point.x <= center + ? max(0, point.x / center) + : max(0, 1 - (point.x - center) / width) + let vertical = max(0, 1 - point.y / height) + return ProjectIdentityAppearance.blend(background, with: glow, fraction: horizontal * vertical) + } +} + +private struct WorkbenchToolbarGlowKey: EnvironmentKey { + static let defaultValue = LitheTheme.titlebar +} + +private extension EnvironmentValues { + var workbenchToolbarGlow: Color { + get { self[WorkbenchToolbarGlowKey.self] } + set { self[WorkbenchToolbarGlowKey.self] = newValue } } } @@ -258,6 +278,11 @@ struct WorkbenchView: View { showsIDEAFrameGradient: usesIDEAFrameExperiment ) } + .environment( + \.workbenchToolbarGlow, + ProjectIdentityAppearance(colorIndex: currentProjectColorIndex, isDark: colorScheme == .dark) + .toolbarGlow(over: Color(nsColor: LitheTheme.nsColor(.titlebar, isDark: colorScheme == .dark))) + ) .sheet(item: $newBranchReference) { reference in TopBarNewBranchDialog(reference: reference) { name, checkout in Task { @@ -579,6 +604,10 @@ struct WorkbenchView: View { settings.colorTheme == .lithe && colorScheme == .dark && !model.workbenchBackgroundFeature.hasImage } + private var currentProjectColorIndex: Int { + ProjectIdentityAppearance.colorIndex(for: model.workspaceURL) + } + private var frameChromeBackground: Color { model.workbenchBackgroundFeature.hasImage || usesIDEAFrameExperiment ? .clear : LitheTheme.titlebar } @@ -712,8 +741,12 @@ struct WorkbenchView: View { branch: false ) } label: { - HStack(spacing: 8) { - LitheLogo(size: 24) + HStack(spacing: 6) { + ProjectAvatarBadge( + name: model.projectName, + colorIndex: currentProjectColorIndex, + size: WorkbenchTopBarMetrics.projectAvatarSize + ) Text(model.projectName) .font(.system(size: 13, weight: .semibold)) @@ -724,8 +757,9 @@ struct WorkbenchView: View { .font(.system(size: 9, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) } - .padding(.horizontal, 8) - .frame(height: 32) + .padding(.leading, WorkbenchTopBarMetrics.projectAvatarLeadingInset) + .padding(.trailing, 10) + .frame(height: 30) .litheRowHover( isActive: isProjectSwitcherPresented, cornerRadius: 6, @@ -733,7 +767,6 @@ struct WorkbenchView: View { ) } .buttonStyle(.plain) - .lithePointer() .accessibilityIdentifier("project-switcher-\(model.id.uuidString)") .anchorPreference( key: ProjectSwitcherButtonBoundsPreferenceKey.self, @@ -770,7 +803,6 @@ struct WorkbenchView: View { ) } .buttonStyle(.plain) - .lithePointer() .anchorPreference( key: BranchSwitcherButtonBoundsPreferenceKey.self, value: .bounds @@ -791,7 +823,7 @@ struct WorkbenchView: View { backgroundPickerButton } - .padding(.leading, 76) + .padding(.leading, WorkbenchTopBarMetrics.leadingInset) .padding(.trailing, 10) .frame(height: LitheTheme.Metrics.toolbarHeight) .background { @@ -2139,6 +2171,8 @@ private struct WorkbenchPaneChromeModifier: ViewModifier { let surrounding: Color let roundsCorners: Bool let showsFrameGradient: Bool + @Environment(\.colorScheme) private var colorScheme + @Environment(\.workbenchToolbarGlow) private var toolbarGlow @ViewBuilder func body(content: Content) -> some View { @@ -2178,7 +2212,14 @@ private struct WorkbenchPaneChromeModifier: ViewModifier { private func notch(_ corner: WorkbenchPaneCornerGeometry.Corner, offset: CGPoint) -> some View { let shape = WorkbenchPaneCornerNotch(corner: corner) if showsFrameGradient { - shape.fill(WorkbenchFrameGradient.fill(offset: offset, size: WorkbenchWorkspaceMetrics.paneCornerRadius)) + shape.fill(WorkbenchFrameGradient.color( + glow: toolbarGlow, + background: Color(nsColor: LitheTheme.nsColor(.titlebar, isDark: colorScheme == .dark)), + at: CGPoint( + x: offset.x + WorkbenchWorkspaceMetrics.paneCornerRadius / 2, + y: offset.y + WorkbenchWorkspaceMetrics.paneCornerRadius / 2 + ) + )) .frame(width: WorkbenchWorkspaceMetrics.paneCornerRadius, height: WorkbenchWorkspaceMetrics.paneCornerRadius) } else { shape.fill(surrounding) @@ -2312,13 +2353,38 @@ private struct WorkbenchBackgroundImageView: View { let opacity: Double let showsIDEAFrameGradient: Bool @Environment(\.colorScheme) private var colorScheme + @Environment(\.workbenchToolbarGlow) private var toolbarGlow var body: some View { ZStack { LitheTheme.window if showsIDEAFrameGradient { - WorkbenchFrameGradient.fill() + LitheTheme.titlebar + .overlay(alignment: .topLeading) { + HStack(spacing: 0) { + LinearGradient( + colors: [LitheTheme.titlebar, toolbarGlow], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: WorkbenchTopBarMetrics.projectAvatarCenterX) + LinearGradient( + colors: [toolbarGlow, LitheTheme.titlebar], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: WorkbenchFrameGradient.width) + } + .frame(height: WorkbenchFrameGradient.height) + .overlay { + LinearGradient( + colors: [.clear, LitheTheme.titlebar], + startPoint: .top, + endPoint: .bottom + ) + } + } } if let image { diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectIdentityAppearance.swift b/macos/Sources/Lithe/Views/Workspace/ProjectIdentityAppearance.swift new file mode 100644 index 000000000..0bf748b2f --- /dev/null +++ b/macos/Sources/Lithe/Views/Workspace/ProjectIdentityAppearance.swift @@ -0,0 +1,106 @@ +import AppKit +import SwiftUI + +/// IDEA Community Islands theme overrides for recent-project avatars and headers. +struct ProjectIdentityAppearance { + private typealias Colors = (avatarStart: UInt32, avatarEnd: UInt32, darkToolbar: UInt32, lightToolbar: UInt32) + + private static let palette: [Colors] = [ + (0xE08855, 0xE9806F, 0x664832, 0xF5D4C1), + (0xB08B14, 0xBB7F19, 0x5E4724, 0xEEE2BD), + (0xA1A359, 0x87AA59, 0x45522F, 0xDBE7C9), + (0x3B92B8, 0x6183EC, 0x335661, 0xCADFEA), + (0x3574F0, 0x7A64F0, 0x394773, 0xDBD8EF), + (0xC84D8F, 0xA956CF, 0x613861, 0xEED7F5), + (0x955AE0, 0xA84DE0, 0x503D70, 0xDFCCF4), + (0x24A394, 0x279CCD, 0x325959, 0xBEE4E1), + (0x5FAD65, 0x3D968B, 0x365439, 0xCCEBD1) + ] + + let avatarStart: Color + let avatarEnd: Color + let toolbarColor: Color + + init(colorIndex: Int, isDark: Bool) { + let colors = Self.palette[Self.validColorIndex(colorIndex)] + avatarStart = Self.color(colors.avatarStart) + avatarEnd = Self.color(colors.avatarEnd) + toolbarColor = Self.color(isDark ? colors.darkToolbar : colors.lightToolbar) + } + + static func colorIndex(for projectURL: URL?) -> Int { + guard let path = projectURL?.standardizedFileURL.path else { return 0 } + // ponytail: A stable path hash avoids a second project-color store, but cannot retain a + // user-chosen color. Add project-owned metadata when color customization is supported. + let hash = path.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { + ($0 ^ UInt64($1)) &* 1_099_511_628_211 + } + return Int(hash % UInt64(palette.count)) + } + + static func initials(for name: String) -> String { + let words = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + let characters = words.prefix(2).compactMap(\.first) + return characters.isEmpty ? "LI" : String(characters).uppercased() + } + + var avatarGradient: LinearGradient { + LinearGradient(colors: [avatarStart, avatarEnd], startPoint: .topLeading, endPoint: .bottomTrailing) + } + + func toolbarGlow(over background: Color) -> Color { + // IDEA's generated project colors are opaque; its frame painter blends + // them into the header at 85% (custom colors have a lower alpha). + return Self.blend(background, with: toolbarColor, fraction: 0.85) + } + + static func blend(_ background: Color, with foreground: Color, fraction: CGFloat) -> Color { + guard let base = NSColor(background).usingColorSpace(.sRGB), + let color = NSColor(foreground).usingColorSpace(.sRGB) else { return background } + let amount = min(max(fraction, 0), 1) + return Color( + .sRGB, + red: Double(base.redComponent + (color.redComponent - base.redComponent) * amount), + green: Double(base.greenComponent + (color.greenComponent - base.greenComponent) * amount), + blue: Double(base.blueComponent + (color.blueComponent - base.blueComponent) * amount), + opacity: 1 + ) + } + + private static func color(_ hex: UInt32) -> Color { + Color( + red: Double((hex >> 16) & 0xff) / 255, + green: Double((hex >> 8) & 0xff) / 255, + blue: Double(hex & 0xff) / 255 + ) + } + + private static func validColorIndex(_ index: Int) -> Int { + palette.indices.contains(index) ? index : 0 + } +} + +struct ProjectAvatarBadge: View { + let name: String + let colorIndex: Int + let size: CGFloat + var isEnabled = true + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let appearance = ProjectIdentityAppearance(colorIndex: colorIndex, isDark: colorScheme == .dark) + Text(ProjectIdentityAppearance.initials(for: name)) + .font(.system(size: max(10, size * 0.35), weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .frame(width: size, height: size) + .background { + if isEnabled { + appearance.avatarGradient + } else { + LitheTheme.raised + } + } + .clipShape(RoundedRectangle(cornerRadius: size * 0.235, style: .continuous)) + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift index 1bac1ae49..34b090eb1 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift @@ -113,8 +113,7 @@ struct ProjectSwitcherPopover: View { projectRowContent( name: projectModel.projectName, path: projectModel.workspaceURL?.path ?? "", - badge: initials(for: projectModel.projectName), - badgeColor: color(for: projectModel.projectName), + colorIndex: ProjectIdentityAppearance.colorIndex(for: projectModel.workspaceURL), isCurrent: isCurrent ) } @@ -136,8 +135,8 @@ struct ProjectSwitcherPopover: View { projectRowContent( name: project.name, path: project.path, - badge: initials(for: project.name), - badgeColor: exists ? color(for: project.name) : LitheTheme.raised, + colorIndex: ProjectIdentityAppearance.colorIndex(for: project.url), + isEnabled: exists, isCurrent: false ) } @@ -150,17 +149,12 @@ struct ProjectSwitcherPopover: View { private func projectRowContent( name: String, path: String, - badge: String, - badgeColor: Color, + colorIndex: Int, + isEnabled: Bool = true, isCurrent: Bool ) -> some View { HStack(spacing: 10) { - Text(badge) - .font(.system(size: 11.5, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .background(badgeColor) - .clipShape(RoundedRectangle(cornerRadius: 7)) + ProjectAvatarBadge(name: name, colorIndex: colorIndex, size: 30, isEnabled: isEnabled) VStack(alignment: .leading, spacing: 2) { Text(name) @@ -186,22 +180,4 @@ struct ProjectSwitcherPopover: View { .frame(maxWidth: .infinity, minHeight: 46, alignment: .leading) .contentShape(Rectangle()) } - - private func initials(for name: String) -> String { - let words = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) - let characters = words.prefix(2).compactMap(\.first) - return characters.isEmpty ? "LI" : String(characters).uppercased() - } - - private func color(for value: String) -> Color { - let palette: [Color] = [ - Color(red: 0.24, green: 0.49, blue: 0.88), - Color(red: 0.24, green: 0.63, blue: 0.43), - Color(red: 0.86, green: 0.39, blue: 0.20), - Color(red: 0.56, green: 0.34, blue: 0.82), - Color(red: 0.72, green: 0.52, blue: 0.10) - ] - let hash = value.utf8.reduce(0) { ($0 * 31 + Int($1)) & 0x7fffffff } - return palette[hash % palette.count] - } } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index d6d3f0332..8dbe23b07 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2,6 +2,7 @@ import AppKit import Combine import CoreServices import Foundation +import SwiftUI import LitheApplicationKernel @testable import LitheDatabaseModule @testable import LitheGitModule @@ -741,6 +742,73 @@ struct LitheCoreLogicTests { #expect(window.titleVisibility == .hidden) } + @Test + @MainActor + func workspaceTrafficLightsStayCenteredOnTheFortyPointToolbar() throws { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + let coordinator = LitheWindowCoordinator(projectSessions: sessions) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + func buttonCenterFromTop() throws -> CGFloat { + let button = try #require(window.standardWindowButton(.closeButton)) + let host = try #require(button.superview) + return host.bounds.maxY - button.frame.midY + } + + coordinator.attach(to: window, layout: .workspace, title: "Project") + #expect(try abs(buttonCenterFromTop() - LitheTheme.Metrics.toolbarHeight / 2) < 0.5) + + window.setContentSize(NSSize(width: 1000, height: 700)) + #expect(try abs(buttonCenterFromTop() - LitheTheme.Metrics.toolbarHeight / 2) < 0.5) + + coordinator.attach(to: window, layout: .welcome) + let nativeTitlebarHeight = try #require(window.standardWindowButton(.closeButton)?.superview?.bounds.height) + #expect(try abs(buttonCenterFromTop() - nativeTitlebarHeight / 2) < 0.5) + } + + @Test + @MainActor + func generatedProjectToolbarColorUsesIDEABlendStrength() throws { + let appearance = ProjectIdentityAppearance(colorIndex: 3, isDark: true) + let lightAppearance = ProjectIdentityAppearance(colorIndex: 3, isDark: false) + let darkBase = Color(nsColor: LitheTheme.nsColor(.titlebar, theme: .lithe, isDark: true)) + let lightBase = Color(nsColor: LitheTheme.nsColor(.titlebar, theme: .lithe, isDark: false)) + let darkCorner = try #require(NSColor(ProjectIdentityAppearance.blend(darkBase, with: appearance.toolbarColor, fraction: 0)).usingColorSpace(.sRGB)) + let lightCorner = try #require(NSColor(ProjectIdentityAppearance.blend(lightBase, with: appearance.toolbarColor, fraction: 0)).usingColorSpace(.sRGB)) + #expect(darkCorner.redComponent < 0.2) + #expect(lightCorner.redComponent > 0.9) + let avatar = try #require(NSColor(appearance.avatarStart).usingColorSpace(.sRGB)) + #expect(abs(avatar.redComponent - (0x3B / 255.0)) < 0.001) + #expect(abs(avatar.greenComponent - (0x92 / 255.0)) < 0.001) + #expect(abs(avatar.blueComponent - (0xB8 / 255.0)) < 0.001) + let lightAvatar = try #require(NSColor(lightAppearance.avatarStart).usingColorSpace(.sRGB)) + #expect(abs(avatar.redComponent - lightAvatar.redComponent) < 0.001) + #expect(abs(avatar.greenComponent - lightAvatar.greenComponent) < 0.001) + #expect(abs(avatar.blueComponent - lightAvatar.blueComponent) < 0.001) + let color = try #require(NSColor(appearance.toolbarGlow(over: .black)).usingColorSpace(.sRGB)) + #expect(abs(color.redComponent - (0x33 / 255.0 * 0.85)) < 0.001) + #expect(abs(color.greenComponent - (0x56 / 255.0 * 0.85)) < 0.001) + #expect(abs(color.blueComponent - (0x61 / 255.0 * 0.85)) < 0.001) + } + + @Test + func projectAvatarColorsStayStableBeyondPaletteSize() { + let project = URL(fileURLWithPath: "/projects/alpha") + let equivalentProject = URL(fileURLWithPath: "/projects/tmp/../alpha") + #expect(ProjectIdentityAppearance.colorIndex(for: project) == ProjectIdentityAppearance.colorIndex(for: equivalentProject)) + + let colors = (0..<20).map { + ProjectIdentityAppearance.colorIndex(for: URL(fileURLWithPath: "/projects/project-\($0)")) + } + #expect(Set(colors.suffix(11)).count > 1) + #expect(ProjectIdentityAppearance.initials(for: "Lithe-IDEA") == "LI") + } + @Test func workspaceWindowFitsInsideTheVisibleScreen() { let visibleFrame = NSRect(x: 0, y: 24, width: 1280, height: 776) diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 4c6a62a37..977cdf8b9 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -2005,7 +2005,7 @@ "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色。" + "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。" }, { "id": "workbench-activity-tooltips",