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..b280121c5 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -74,11 +74,11 @@ 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 +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 @@ -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 2e1c35bc8..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 | 打开工作区,检查左右活动栏与窗口外沿齐平,外侧没有留白、圆角或边框;缩放窗口并切换活动面板后确认布局仍贴边。 | | @@ -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、原生文件对话框和快捷键实际执行验收。 | @@ -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/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/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/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/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 51fe67798..723d05fbf 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 } @@ -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 6a0473ebd..47216eac3 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) { - Text(initials(for: project.name)) - .font(.system(size: 12, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .frame(width: 34, height: 34) - .background(exists ? color(for: project.name) : LitheTheme.raised) - .clipShape(RoundedRectangle(cornerRadius: 8)) + HStack(spacing: 8) { + ProjectAvatarBadge( + name: project.name, + colorIndex: ProjectIdentityAppearance.colorIndex(for: project.url), + size: 20, + isEnabled: exists + ) 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,26 +267,79 @@ struct WelcomeView: View { } } - 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.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) + 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 ] - var hash: UInt64 = 1_469_598_103_934_665_603 - for byte in value.utf8 { - hash ^= UInt64(byte) - hash &*= 1_099_511_628_211 + 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) } + } + )) } - return palette[Int(hash % UInt64(palette.count))] + 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 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)) } } 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/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/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/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 18b534c04..977cdf8b9 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、原生文件对话框和快捷键实际执行验收。" }, { @@ -1951,7 +1951,7 @@ "verificationStatus": "pending" }, "owner": "Onboarding", - "verification": "清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。" + "verification": "清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。" }, { "id": "command-palette", @@ -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", @@ -2221,7 +2221,7 @@ "verificationStatus": "pending" }, "owner": "Release", - "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从菜单或欢迎页手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。" + "verification": "使用 preview 更新清单验证检查、下载、校验、安装和重启;从应用菜单或欢迎页左下角齿轮菜单手动检查到新版本时直接弹出更新详情并可一键安装,设置页检查到新版本时在检查按钮旁显示安装按钮,启动时的自动检查只在标题栏提示。" }, { "id": "updates-failure-rollback",