diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 143fa6e01..6d670ca60 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -73,9 +73,9 @@ outline-symbols,编辑器,代码结构,当前文件 Outline 与符号树,部分 references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实现,待验证,已实现,待验证,Language Tooling,从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。,,macos/Sources/Lithe/Views/Language/JavaReferencesView.swift,windows/tauri/src/features/references diagnostics-workspace,可观测性,诊断与日志,工作区诊断与问题面板,已实现,待验证,已实现,待验证,Diagnostics,制造多个来源的错误和警告,验证筛选、排序、定位和清除。,,macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift; macos/Sources/Lithe/Views/Language/JavaProblemsView.swift,windows/tauri/src/features/diagnostics; windows/tauri/src/features/logging; windows/tauri/src/ui/dialog.tsx diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实现,待验证,已实现,待验证,Diagnostics,导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。,,macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics -application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift,windows/tauri/src/features/settings +application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift,windows/tauri/src/features/settings keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。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 +plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift; macos/Tests/LitheTests/PluginManagementPresentationTests.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings 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 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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 2493813f2..79a8358d5 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -211,7 +211,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 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。 | | +| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查。 | | | 应用配置 | **快捷键查看与自定义**
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、原生文件对话框和快捷键实际执行验收。 | @@ -221,7 +221,7 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings` | Extensions | 启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。 | | +| 插件与主题 | **插件、语言服务与主题管理**
plugin-extension-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/PluginManagementView.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift`、`macos/Tests/LitheTests/PluginManagementPresentationTests.swift`、`macos/Sources/Lithe/Platform/MacOS/Plugins` | ✅ 已实现
🔍 待验证
`windows/tauri/src/extensions`、`windows/tauri/src/features/settings` | Extensions | macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 | | diff --git a/macos/Resources/Fonts/Inter-LICENSE.txt b/macos/Resources/Fonts/Inter-LICENSE.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/macos/Resources/Fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/macos/Resources/Fonts/Inter-Regular.otf b/macos/Resources/Fonts/Inter-Regular.otf new file mode 100644 index 000000000..84e6a61c3 Binary files /dev/null and b/macos/Resources/Fonts/Inter-Regular.otf differ diff --git a/macos/Resources/Fonts/Inter-SemiBold.otf b/macos/Resources/Fonts/Inter-SemiBold.otf new file mode 100644 index 000000000..daf4c4413 Binary files /dev/null and b/macos/Resources/Fonts/Inter-SemiBold.otf differ diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 329b2bc59..012b3779a 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -9,6 +9,18 @@ "Plugins" = "Plugins"; "Marketplace" = "Marketplace"; "Installed" = "Installed"; +"Installed (%lld of %lld enabled)" = "Installed (%lld of %lld enabled)"; +"Install Plugin from Disk…" = "Install Plugin from Disk…"; +"Install PHP Support from a signed plugin package." = "Install PHP Support from a signed plugin package."; +"Install PHP Support Plugin" = "Install PHP Support Plugin"; +"Select a PHP Support plugin package." = "Select a PHP Support plugin package."; +"Plugin marketplace is not available" = "Plugin marketplace is not available"; +"Plugin settings" = "Plugin settings"; +"Appearance & Behavior" = "Appearance & Behavior"; +"Version Control" = "Version Control"; +"Build, Execution, Deployment" = "Build, Execution, Deployment"; +"Languages & Frameworks" = "Languages & Frameworks"; +"Tools" = "Tools"; "Type / to see options" = "Type / to see options"; "Downloaded (%lld of %lld enabled)" = "Downloaded (%lld of %lld enabled)"; "Install" = "Install"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0ab01e710..da08a78d3 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1330,6 +1330,18 @@ "Plugins" = "插件"; "Marketplace" = "插件市场"; "Installed" = "已安装"; +"Installed (%lld of %lld enabled)" = "已安装(%lld / %lld 个已启用)"; +"Install Plugin from Disk…" = "从磁盘安装插件…"; +"Install PHP Support from a signed plugin package." = "请从已签名的插件包安装 PHP Support。"; +"Install PHP Support Plugin" = "安装 PHP Support 插件"; +"Select a PHP Support plugin package." = "请选择 PHP Support 插件包。"; +"Plugin marketplace is not available" = "插件市场暂不可用"; +"Plugin settings" = "插件设置"; +"Appearance & Behavior" = "外观与行为"; +"Version Control" = "版本控制"; +"Build, Execution, Deployment" = "构建、执行、部署"; +"Languages & Frameworks" = "语言与框架"; +"Tools" = "工具"; "Type / to see options" = "输入 / 查看选项"; "Downloaded (%lld of %lld enabled)" = "已下载(%lld 个,%lld 个已启用)"; "Install" = "安装"; @@ -1341,10 +1353,6 @@ "Uninstall" = "卸载"; "Overview" = "概览"; "No Plugins" = "暂无插件"; -"More Language Support" = "扩展更多语言"; -"%lld languages · %lld enabled" = "%lld 种语言 · 已启用 %lld 个"; -"Expanded" = "已展开"; -"Collapsed" = "已收起"; "Database" = "数据库连接"; "Enabled" = "已启用"; "Disabled" = "已禁用"; diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 078f484c9..aa3baf8e4 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -671,16 +671,13 @@ struct LitheApp: App { .windowStyle(.hiddenTitleBar) Window(settingsWindowTitle(for: settings.language), id: LitheWindowID.settings) { - SettingsWindow( - model: model, - settings: settings - ) + SettingsWindowHost(projectSessions: projectSessions, settings: settings) .tint(LitheTheme.accent) .environmentObject(settings) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) } - .defaultSize(width: 1040, height: 720) + .defaultSize(width: 900, height: 668) .windowResizability(.contentMinSize) .windowStyle(.hiddenTitleBar) @@ -733,15 +730,46 @@ private struct ProjectWindowMissingSessionView: View { } } +private struct SettingsWindowHost: View { + @ObservedObject var projectSessions: ProjectSessionManager + @ObservedObject var settings: AppSettings + + var body: some View { + if let model = projectSessions.settingsModel { + SettingsWindow( + model: model, + settings: settings, + projectSessions: projectSessions, + bindingID: projectSessions.settingsBindingID + ) { + projectSessions.releaseSettings(for: model.id) + } + .id(model.id) + } + } +} + private struct SettingsWindow: View { @ObservedObject var model: AppModel @ObservedObject var settings: AppSettings + let projectSessions: ProjectSessionManager + let bindingID: UUID @StateObject private var windowReference = SettingsWindowReference() @StateObject private var viewState: SettingsViewState + let onDisappear: () -> Void - init(model: AppModel, settings: AppSettings) { + init( + model: AppModel, + settings: AppSettings, + projectSessions: ProjectSessionManager, + bindingID: UUID, + onDisappear: @escaping () -> Void + ) { self.model = model self.settings = settings + self.projectSessions = projectSessions + self.bindingID = bindingID + self.onDisappear = onDisappear _viewState = StateObject(wrappedValue: SettingsViewState( initialCategory: model.requestedSettingsCategory )) @@ -758,21 +786,29 @@ private struct SettingsWindow: View { ) .environmentObject(model) } + .environment(\.lithePointingHandCursorEnabled, false) .background( SettingsWindowAccessor( reference: windowReference, title: settingsWindowTitle(for: settings.language), - themePreference: settings.themePreference + themePreference: settings.themePreference, + closeEnabled: !viewState.isApplyingPluginChanges ) ) .onDisappear { + viewState.pendingPluginEnabledStates.removeAll() model.isSettingsPresented = false + onDisappear() } } private func close() { - model.isSettingsPresented = false - windowReference.window?.performClose(nil) + guard !viewState.isApplyingPluginChanges else { return } + projectSessions.closeSettingsIfCurrent(for: model.id, bindingID: bindingID) { + model.isSettingsPresented = false + windowReference.window?.standardWindowButton(.closeButton)?.isEnabled = true + windowReference.window?.performClose(nil) + } } } @@ -816,6 +852,7 @@ private struct SettingsWindowAccessor: NSViewRepresentable { let reference: SettingsWindowReference let title: String let themePreference: AppThemePreference + let closeEnabled: Bool func makeNSView(context: Context) -> SettingsWindowProbe { let view = SettingsWindowProbe(frame: .zero) @@ -840,15 +877,34 @@ private struct SettingsWindowAccessor: NSViewRepresentable { DispatchQueue.main.async { guard let window = view.window else { return } reference.window = window - SettingsWindowChrome.configure(window, title: title, themePreference: themePreference) + SettingsWindowChrome.configure( + window, + title: title, + themePreference: themePreference, + closeEnabled: closeEnabled + ) } } } +@MainActor enum SettingsWindowChrome { - static func configure(_ window: NSWindow, title: String, themePreference: AppThemePreference) { + static weak var ownerWindow: NSWindow? + static weak var settingsWindow: NSWindow? + + static func configure( + _ window: NSWindow, + title: String, + themePreference: AppThemePreference, + closeEnabled: Bool? = nil + ) { + settingsWindow = window window.title = title window.level = .normal + if let ownerWindow, ownerWindow !== window, window.parent !== ownerWindow { + window.parent?.removeChildWindow(window) + ownerWindow.addChildWindow(window, ordered: .above) + } let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { window.appearance = windowAppearance @@ -866,6 +922,9 @@ enum SettingsWindowChrome { applySettingsSurface(toTitlebarOf: window, color: settingsSurface) window.standardWindowButton(.miniaturizeButton)?.isEnabled = false window.standardWindowButton(.zoomButton)?.isEnabled = true + if let closeEnabled { + window.standardWindowButton(.closeButton)?.isEnabled = closeEnabled + } } private static func applySettingsSurface(toTitlebarOf window: NSWindow, color: NSColor) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index e86b33b02..fe0628bdb 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -10,6 +10,25 @@ extension AppModel { services.pluginManager.issues } + func installPHPPluginPackage() { + guard let packageURL = platformUI.chooseDirectory( + title: NSLocalizedString("Install PHP Support Plugin", comment: "PHP plugin package picker title"), + prompt: "Install" + ) else { return } + do { + let manifestURL = packageURL.appendingPathComponent("plugin.json") + let manifest = try JSONDecoder().decode(PluginManifest.self, from: Data(contentsOf: manifestURL)) + guard manifest.id == OfficialPluginCatalog.phpPluginID else { + showNotification(NSLocalizedString("Select a PHP Support plugin package.", comment: "Wrong plugin package selected")) + return + } + try services.pluginManager.installPackage(at: packageURL) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + func applyPluginEnabledChanges(_ changes: [PluginID: Bool]) async -> Set { let snapshotsByID = Dictionary(uniqueKeysWithValues: pluginSnapshots.map { ($0.id, $0) }) let closesDatabase = changes.contains { pluginID, enabled in @@ -36,19 +55,6 @@ extension AppModel { return appliedPluginIDs } - func installPluginPackage() { - guard let packageURL = platformUI.chooseDirectory( - title: "Install Plugin Package", - prompt: "Install" - ) else { return } - do { - try services.pluginManager.installPackage(at: packageURL) - objectWillChange.send() - } catch { - showNotification(error.localizedDescription) - } - } - func rollbackPlugin(_ pluginID: PluginID) { do { try services.pluginManager.rollback(pluginID) @@ -58,14 +64,4 @@ extension AppModel { } } - func uninstallPlugin(_ pluginID: PluginID) { - Task { @MainActor in - do { - try await services.pluginManager.uninstall(pluginID) - objectWillChange.send() - } catch { - showNotification(error.localizedDescription) - } - } - } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 27b70633f..a7a18e913 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -15,6 +15,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case git = "Git" case updates = "Updates" case diagnostics = "Diagnostics" + case plugins = "Plugins" var id: String { rawValue } var title: String { self == .project ? "Project · JDK & Maven" : rawValue } @@ -33,6 +34,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .git: "arrow.triangle.branch" case .updates: "arrow.down.circle" case .diagnostics: "stethoscope" + case .plugins: "puzzlepiece.extension" } } } diff --git a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index 1d1d2cbf1..ca25d9d6f 100644 --- a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -32,6 +32,9 @@ final class ProjectSessionManager: ObservableObject { @Published private(set) var activeSessionIDs: [ProjectWindowScope: UUID] = [:] /// Window that currently owns menu-bar commands and focus-driven actions. @Published private(set) var focusedScope: ProjectWindowScope = .primary + @Published private(set) var settingsModel: AppModel? + /// Invalidates close callbacks from an earlier opening of the shared settings window. + private(set) var settingsBindingID = UUID() @Published var pendingProjectOpen: PendingProjectOpen? private let settings: AppSettings @@ -119,6 +122,21 @@ final class ProjectSessionManager: ObservableObject { sessions.first(where: { $0.id == id }) } + func bindSettings(to sessionID: UUID) { + settingsBindingID = UUID() + settingsModel = session(for: sessionID) + } + + func closeSettingsIfCurrent(for sessionID: UUID, bindingID: UUID, close: () -> Void) { + guard settingsModel?.id == sessionID, settingsBindingID == bindingID else { return } + close() + } + + func releaseSettings(for sessionID: UUID) { + guard settingsModel?.id == sessionID else { return } + settingsModel = nil + } + func sessions(in scope: ProjectWindowScope) -> [AppModel] { sessions.filter { sessionScopes[$0.id] == scope } } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift index 0f734e1fb..156556472 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift @@ -7,7 +7,9 @@ enum MacBundledFontRegistry { (resource: "JetBrainsMono-Regular", postScriptName: "JetBrainsMono-Regular"), (resource: "JetBrainsMono-Italic", postScriptName: "JetBrainsMono-Italic"), (resource: "JetBrainsMono-Bold", postScriptName: "JetBrainsMono-Bold"), - (resource: "JetBrainsMono-BoldItalic", postScriptName: "JetBrainsMono-BoldItalic") + (resource: "JetBrainsMono-BoldItalic", postScriptName: "JetBrainsMono-BoldItalic"), + (resource: "Inter-Regular", postScriptName: "Inter-Regular"), + (resource: "Inter-SemiBold", postScriptName: "Inter-SemiBold") ] static func registerFonts(bundle: Bundle = .main) { @@ -23,12 +25,13 @@ enum MacBundledFontRegistry { } for font in fonts where NSFont(name: font.postScriptName, size: 13) == nil { + let fileExtension = font.resource.hasPrefix("Inter-") ? "otf" : "ttf" guard let url = bundle.url( forResource: font.resource, - withExtension: "ttf", + withExtension: fileExtension, subdirectory: "Fonts" ) else { - reporter("Lithe font registration: Missing bundled font: \(font.resource).ttf\n") + reporter("Lithe font registration: Missing bundled font: \(font.resource).\(fileExtension)\n") continue } @@ -36,7 +39,7 @@ enum MacBundledFontRegistry { guard CTFontManagerRegisterFontsForURL(url as CFURL, .process, ®istrationError) else { let detail = registrationError?.takeRetainedValue().localizedDescription ?? "Unknown CoreText error" - reporter("Lithe font registration: Could not register \(font.resource).ttf: \(detail)\n") + reporter("Lithe font registration: Could not register \(font.resource).\(fileExtension): \(detail)\n") continue } } diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 9916de2fa..2c66c82ad 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -285,16 +285,28 @@ enum LitheTheme { } static func settingsSurfaceNSColor(for appearance: NSAppearance) -> NSColor { let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - return isDark - ? NSColor(srgbRed: 0.157, green: 0.161, blue: 0.173, alpha: 1) - : NSColor(srgbRed: 0.910, green: 0.922, blue: 0.937, alpha: 1) + return nsColor(.sidebar, isDark: isDark) + } + static var settingsListSurface: Color { settingsSurface } + static var settingsFont: Font { .custom("Inter-Regular", size: 13) } + static var settingsStrongFont: Font { .custom("Inter-SemiBold", size: 13) } + static var settingsSearchBorder: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 78.0 / 255, green: 81.0 / 255, blue: 87.0 / 255, alpha: 1) + : NSColor(srgbRed: 201.0 / 255, green: 204.0 / 255, blue: 214.0 / 255, alpha: 1) + }) } static var settingsPrimaryAction: Color { accent } - static let settingsSelection = Color( - red: 43.0 / 255.0, - green: 66.0 / 255.0, - blue: 113.0 / 255.0 - ) + static var settingsSelection: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 46.0 / 255, green: 67.0 / 255, blue: 110.0 / 255, alpha: 1) + : NSColor(srgbRed: 212.0 / 255, green: 226.0 / 255, blue: 255.0 / 255, alpha: 1) + }) + } static var settingsControlBackground: Color { Color(nsColor: NSColor(name: nil) { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua @@ -303,6 +315,14 @@ enum LitheTheme { : NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) }) } + static var settingsSelectBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 57.0 / 255, green: 59.0 / 255, blue: 64.0 / 255, alpha: 1) + : .white + }) + } static var toolHeader: Color { adaptive(\.toolHeader) } static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) } static var sidebar: Color { adaptive(\.sidebar) } @@ -535,14 +555,16 @@ private struct LitheRowHoverModifier: ViewModifier { struct LithePrimaryButtonStyle: ButtonStyle { var backgroundColor = LitheTheme.accent var restingOpacity = 0.92 + var horizontalPadding: CGFloat = 18 + var height: CGFloat = 30 @State private var isHovering = false func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) - .padding(.horizontal, 18) - .frame(height: 30) + .padding(.horizontal, horizontalPadding) + .frame(height: height) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .fill(backgroundColor.opacity(configuration.isPressed ? 0.78 : (isHovering ? 1 : restingOpacity))) @@ -571,7 +593,7 @@ struct LitheSecondaryButtonStyle: ButtonStyle { ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + .strokeBorder(LitheTheme.panelBorder, lineWidth: 1) } .contentShape(Rectangle()) .onHover { isHovering = $0 } @@ -579,18 +601,33 @@ struct LitheSecondaryButtonStyle: ButtonStyle { } } +private struct LithePointingHandCursorKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var lithePointingHandCursorEnabled: Bool { + get { self[LithePointingHandCursorKey.self] } + set { self[LithePointingHandCursorKey.self] = newValue } + } +} + private struct LithePointerModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled + @Environment(\.lithePointingHandCursorEnabled) private var pointingHandCursorEnabled @State private var cursor = LithePointerCursor() func body(content: Content) -> some View { content .onHover { isInside in cursor.isHovered = isInside - cursor.update(isPointing: isInside && isEnabled) + cursor.update(isPointing: isInside && isEnabled && pointingHandCursorEnabled) } .onChange(of: isEnabled) { _ in - cursor.update(isPointing: cursor.isHovered && isEnabled) + cursor.update(isPointing: cursor.isHovered && isEnabled && pointingHandCursorEnabled) + } + .onChange(of: pointingHandCursorEnabled) { _ in + cursor.update(isPointing: cursor.isHovered && isEnabled && pointingHandCursorEnabled) } .onDisappear { cursor.isHovered = false diff --git a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift index eb0ad34dd..fa35ee630 100644 --- a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift @@ -15,7 +15,7 @@ struct GitExecutionSettingsView: View { GitSettingsRow("Git executable") { HStack(spacing: 8) { TextField("Use Git from PATH", text: $executable) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() .frame(maxWidth: .infinity) Button("Save") { settings.gitExecutable = executable.trimmingCharacters(in: .whitespacesAndNewlines) } .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.settingsPrimaryAction, restingOpacity: 1)) @@ -112,12 +112,13 @@ private struct GitExecutionConfigurationPane: View { subtitle: "Choose whether this setting belongs to the open repository or to Git everywhere." ) GitSettingsRow("Configuration scope") { - Picker("Configuration scope", selection: $scope) { - Text("Current repository").tag("local") - Text("Global Git configuration").tag("global") - } - .labelsHidden() - .pickerStyle(.segmented) + LitheSettingsSegmentedControl( + selection: $scope, + options: ["local", "global"], + width: 340, + title: { $0 == "local" ? "Current repository" : "Global Git configuration" } + ) + .accessibilityLabel("Configuration scope") } GitBranchContextView(feature: feature, remoteURL: remoteURL) diff --git a/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift b/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift index 5560fad46..e030e0a4f 100644 --- a/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitFetchSettingsView.swift @@ -6,7 +6,7 @@ struct GitFetchSettingsView: View { var body: some View { GitSettingsCard { GitSettingsHeader(icon: "arrow.down.circle", title: "Fetch defaults", subtitle: "Set the defaults used by ordinary Fetch in every project.") - GitFetchPolicyControls(options: $options) + GitFetchPolicyControls(options: $options, usesSettingsControls: true) Text("Credentials use your existing Git helper and SSH configuration.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) Button("Reset Fetch defaults") { options = GitFetchOptions() }.lithePointer() @@ -16,26 +16,62 @@ struct GitFetchSettingsView: View { struct GitFetchPolicyControls: View { @Binding var options: GitFetchOptions + var usesSettingsControls = false var body: some View { VStack(alignment: .leading, spacing: 9) { GitSettingsRow("Prune") { Toggle("Prune stale remote-tracking references", isOn: $options.prune) } GitSettingsRow("Fetch submodules") { - Picker("Fetch submodules", selection: $options.submodules) { - Text("Use Git configuration").tag(GitFetchSubmodules.inherit) - Text("Do not fetch submodules").tag(GitFetchSubmodules.no) - Text("Fetch submodules on demand").tag(GitFetchSubmodules.onDemand) - Text("Fetch all submodules").tag(GitFetchSubmodules.yes) - }.labelsHidden() + if usesSettingsControls { + LitheSettingsSelect( + selection: $options.submodules, + options: GitFetchSubmodules.allCases, + width: 260, + accessibilityLabel: "Fetch submodules", + title: { option in + switch option { + case .inherit: "Use Git configuration" + case .no: "Do not fetch submodules" + case .onDemand: "Fetch submodules on demand" + case .yes: "Fetch all submodules" + } + } + ) + } else { + Picker("Fetch submodules", selection: $options.submodules) { + Text("Use Git configuration").tag(GitFetchSubmodules.inherit) + Text("Do not fetch submodules").tag(GitFetchSubmodules.no) + Text("Fetch submodules on demand").tag(GitFetchSubmodules.onDemand) + Text("Fetch all submodules").tag(GitFetchSubmodules.yes) + }.labelsHidden() + } } GitSettingsRow("Fetch tags") { - Picker("Fetch tags", selection: $options.tags) { - Text("Use Git configuration").tag(GitFetchTags.inherit) - Text("Fetch all tags").tag(GitFetchTags.all) - Text("Do not fetch tags").tag(GitFetchTags.none) - Text("Synchronize tags and remove local tags missing from the remote").tag(GitFetchTags.prune) - }.labelsHidden() + if usesSettingsControls { + LitheSettingsSelect( + selection: $options.tags, + options: GitFetchTags.allCases, + width: 260, + accessibilityLabel: "Fetch tags", + title: { option in + switch option { + case .inherit: "Use Git configuration" + case .all: "Fetch all tags" + case .none: "Do not fetch tags" + case .prune: "Synchronize tags and remove local tags missing from the remote" + } + }, + expandsToFitOptions: true + ) + } else { + Picker("Fetch tags", selection: $options.tags) { + Text("Use Git configuration").tag(GitFetchTags.inherit) + Text("Fetch all tags").tag(GitFetchTags.all) + Text("Do not fetch tags").tag(GitFetchTags.none) + Text("Synchronize tags and remove local tags missing from the remote").tag(GitFetchTags.prune) + }.labelsHidden() + } } } .onChange(of: options.tags) { tags in if tags == .prune { options.prune = true } } diff --git a/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift b/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift index 222939cc2..63187a952 100644 --- a/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitIdentitySettingsView.swift @@ -29,10 +29,14 @@ private struct GitIdentitySettingsPane: View { Text("Commit identity").font(.system(size: 15, weight: .semibold)) Text("Git records this name and email in new commits. These settings do not change existing commits.") .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) - Picker("Configuration scope", selection: $scope) { - Text("Current repository").tag(GitIdentityScope.local) - Text("Global Git configuration").tag(GitIdentityScope.global) - }.pickerStyle(.segmented).disabled(editor.isBusy) + LitheSettingsSegmentedControl( + selection: $scope, + options: GitIdentityScope.allCases, + width: 340, + title: { $0 == .local ? "Current repository" : "Global Git configuration" } + ) + .accessibilityLabel("Configuration scope") + .disabled(editor.isBusy) Text(scope == .global ? LocalizedStringKey("Global identity applies to other repositories unless they override it.") : LocalizedStringKey("Repository identity overrides inherited global values. Clear an override to use inherited settings.")) @@ -69,7 +73,7 @@ private struct GitIdentitySettingsPane: View { VStack(alignment: .leading, spacing: 7) { Text(title).font(.system(size: 12, weight: .medium)) HStack { - TextField(title, text: draft).textFieldStyle(.roundedBorder) + TextField(title, text: draft).litheSettingsTextField() Button("Save") { save(field) } .disabled(draft.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || draft.wrappedValue == configured) .lithePointer() diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index 54b968366..4e6fa6720 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -2,18 +2,27 @@ import SwiftUI import LitheModuleAPI struct PluginManagementView: View { + static let minimumWidth: CGFloat = listMinimumWidth + SplitHandleView.thickness + detailMinimumWidth + static let listMinimumWidth: CGFloat = 200 + private static let detailMinimumWidth: CGFloat = 160 @EnvironmentObject private var model: AppModel + @ObservedObject var settingsState: SettingsViewState @State private var searchText = "" @State private var selectedPluginID: PluginID? @State private var hoveredPluginID: PluginID? - @State private var pendingEnabledStates: [PluginID: Bool] = [:] - @State private var isApplyingChanges = false - @State private var isLanguageExtensionsExpanded = false + @AppStorage("lithe.settings.pluginListWidth") private var pluginListWidth = 320.0 + + private var pendingEnabledStates: [PluginID: Bool] { settingsState.pendingPluginEnabledStates } + private var isApplyingChanges: Bool { settingsState.isApplyingPluginChanges } + + private var installedPlugins: [PluginManagementSnapshot] { + PluginManagementListContent(plugins: model.pluginSnapshots).plugins + } private var filteredPlugins: [PluginManagementSnapshot] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !query.isEmpty else { return model.pluginSnapshots } - return model.pluginSnapshots.filter { + guard !query.isEmpty else { return installedPlugins } + return installedPlugins.filter { $0.manifest.displayName.lowercased().contains(query) || $0.manifest.vendor.displayName.lowercased().contains(query) } @@ -23,160 +32,93 @@ struct PluginManagementView: View { filteredPlugins.first { $0.id == selectedPluginID } ?? filteredPlugins.first } - private var listContent: PluginManagementListContent { - PluginManagementListContent(plugins: filteredPlugins) - } - - private var isSearching: Bool { - !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var showsLanguageExtensions: Bool { - isLanguageExtensionsExpanded || isSearching + private var availablePHPManifest: PluginManifest? { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let manifest = PluginManagementListContent(plugins: model.pluginSnapshots).availablePHPManifest, + query.isEmpty || manifest.displayName.lowercased().contains(query) else { return nil } + return manifest } private var enabledPluginCount: Int { - model.pluginSnapshots.filter { effectiveEnabledState(for: $0) }.count + installedPlugins.filter { effectiveEnabledState(for: $0) }.count } var body: some View { VStack(spacing: 0) { header - HStack(spacing: 0) { - sidebar - Rectangle().fill(LitheTheme.divider).frame(width: 1) - detail + GeometryReader { geometry in + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(pluginListWidth), + minimum: Self.listMinimumWidth, + maximum: max(Self.listMinimumWidth, geometry.size.width - SplitHandleView.thickness - Self.detailMinimumWidth), + flexibleMinimum: Self.detailMinimumWidth, + highlightsOnHover: false, + onCommit: { pluginListWidth = Double($0) }, + sized: { sidebar }, + flexible: { detail } + ) + } + if !pendingEnabledStates.isEmpty || isApplyingChanges { + footer } - footer } - .frame(minWidth: 820, minHeight: 560) - .litheWorkbenchSurface(LitheTheme.window) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LitheTheme.settingsSurface) .onAppear { - let initialContent = PluginManagementListContent(plugins: model.pluginSnapshots) - selectedPluginID = initialContent.standalonePlugins.first?.id - ?? initialContent.languageExtensions.first?.id - } - .onChange(of: searchText) { newValue in - if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - isLanguageExtensionsExpanded = true - } + selectedPluginID = installedPlugins.first?.id } } private var header: some View { HStack(spacing: 24) { - Text(LocalizedStringKey("Plugins")).font(.system(size: 17, weight: .semibold)) + Text(LocalizedStringKey("Plugins")).font(LitheTheme.settingsStrongFont) Spacer() - Text(LocalizedStringKey("Marketplace")).foregroundStyle(LitheTheme.secondaryText) - HStack(spacing: 7) { - Text(LocalizedStringKey("Installed")) - Text("\(model.pluginSnapshots.count)") - .font(.system(size: 11, weight: .bold)) - .frame(width: 20, height: 20) - .background(LitheTheme.selection) - .clipShape(Circle()) - } - .padding(.horizontal, 12).padding(.vertical, 7) - .background(LitheTheme.selection.opacity(0.45)) + Text(LocalizedStringKey("Marketplace")) + .foregroundStyle(LitheTheme.secondaryText) + .help("Plugin marketplace is not available") + Text(LocalizedStringKey("Installed")) + .font(LitheTheme.settingsFont) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(LitheTheme.settingsSelection) .clipShape(RoundedRectangle(cornerRadius: 7)) - Image(systemName: "gearshape").foregroundStyle(LitheTheme.secondaryText) } - .padding(.horizontal, 20) - .frame(height: 52) - .litheWorkbenchSurface(LitheTheme.toolHeader) + .padding(.horizontal, 16) + .frame(height: 42) + .background(LitheTheme.settingsSurface) } private var sidebar: some View { VStack(spacing: 0) { HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(LitheTheme.secondaryText) - TextField(LocalizedStringKey("Type / to see options"), text: $searchText) - .textFieldStyle(.plain) - Image(systemName: "ellipsis").foregroundStyle(LitheTheme.secondaryText) + LitheSettingsSearchField("Type / to see options", text: $searchText) } - .padding(.horizontal, 14).frame(height: 48) + .padding(.horizontal, 8).frame(height: 44) Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack { - Text(LocalizedStringKey("Downloaded (\(model.pluginSnapshots.count) of \(enabledPluginCount) enabled)")) - .font(.system(size: 13, weight: .medium)) + Text(LocalizedStringKey("Installed (\(enabledPluginCount) of \(installedPlugins.count) enabled)")) + .font(LitheTheme.settingsFont) Spacer() - Button(LocalizedStringKey("Install")) { model.installPluginPackage() } - .buttonStyle(.borderless).foregroundStyle(LitheTheme.accent) } - .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.raised) + .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.settingsListSurface) ScrollView { LazyVStack(spacing: 0) { - ForEach(listContent.standalonePlugins) { plugin in + ForEach(filteredPlugins) { plugin in pluginRow(plugin) } - if !listContent.languageExtensions.isEmpty { - languageExtensionsDisclosure - if showsLanguageExtensions { - ForEach(listContent.languageExtensions) { plugin in - pluginRow(plugin, isNested: true) - } - } + if let manifest = availablePHPManifest { + availablePluginRow(manifest) } } } } - .frame(width: 320) - .litheWorkbenchSurface(LitheTheme.sidebar) - } - - private var languageExtensionsDisclosure: some View { - let plugins = listContent.languageExtensions - let enabledCount = plugins.filter { effectiveEnabledState(for: $0) }.count - return Button { - toggleLanguageExtensions() - } label: { - HStack(spacing: 10) { - Image(systemName: showsLanguageExtensions ? "chevron.down" : "chevron.right") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .frame(width: 14) - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(LitheTheme.accent) - .frame(width: 30, height: 30) - VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey("More Language Support")) - .font(.system(size: 13, weight: .semibold)) - Text(LocalizedStringKey("\(plugins.count) languages · \(enabledCount) enabled")) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - } - Spacer() - Text("\(plugins.count)") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(LitheTheme.raised) - .clipShape(Capsule()) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityValue(showsLanguageExtensions ? Text("Expanded") : Text("Collapsed")) - .lithePointer() + .frame(maxWidth: .infinity) + .background(LitheTheme.settingsListSurface) } - private func toggleLanguageExtensions() { - guard !isSearching else { return } - if isLanguageExtensionsExpanded, - let selectedPluginID, - listContent.languageExtensions.contains(where: { $0.id == selectedPluginID }) { - self.selectedPluginID = listContent.standalonePlugins.first?.id - } - isLanguageExtensionsExpanded.toggle() - } - - private func pluginRow(_ plugin: PluginManagementSnapshot, isNested: Bool = false) -> some View { - let presentation = presentation(for: plugin) + private func pluginRow(_ plugin: PluginManagementSnapshot) -> some View { + let presentation = phpPresentation let isSelected = selectedPlugin?.id == plugin.id let isHovered = hoveredPluginID == plugin.id return HStack(spacing: 10) { @@ -194,14 +136,14 @@ struct PluginManagementView: View { Image(systemName: effectiveEnabledState(for: plugin) ? "checkmark.square.fill" : "square") .foregroundStyle(effectiveEnabledState(for: plugin) ? LitheTheme.accent : LitheTheme.secondaryText) } - .padding(.leading, isNested ? 32 : 14) + .padding(.leading, 14) .padding(.trailing, 14) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected - ? LitheTheme.selection - : (isHovered ? LitheTheme.raised : Color.clear) + ? LitheTheme.settingsSelection + : (isHovered ? LitheTheme.hoverBackground : Color.clear) ) .contentShape(Rectangle()) .onTapGesture(count: 1) { @@ -216,12 +158,32 @@ struct PluginManagementView: View { } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) - .lithePointer() + } + + private func availablePluginRow(_ manifest: PluginManifest) -> some View { + HStack(spacing: 10) { + Image(systemName: phpPresentation.systemImage) + .font(.system(size: 22)) + .foregroundStyle(phpPresentation.tint) + .frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(LocalizedStringKey(manifest.displayName)) + .font(.system(size: 13, weight: .semibold)) + Text(LocalizedStringKey("Not installed")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.settingsSelection) } @ViewBuilder private var detail: some View { if let plugin = selectedPlugin { - let presentation = presentation(for: plugin) + let presentation = phpPresentation let isEnabled = effectiveEnabledState(for: plugin) let hasPendingChange = pendingEnabledStates[plugin.id] != nil VStack(alignment: .leading, spacing: 0) { @@ -244,11 +206,6 @@ struct PluginManagementView: View { .buttonStyle(.borderedProminent) .tint(LitheTheme.accent) .disabled(isApplyingChanges || plugin.isRequired) - if plugin.origin == .marketplace { - Button(LocalizedStringKey("Uninstall"), role: .destructive) { model.uninstallPlugin(plugin.id) } - .buttonStyle(.bordered) - .disabled(isApplyingChanges) - } }.padding(24) Text(LocalizedStringKey("Overview")).font(.system(size: 15, weight: .semibold)).padding(.horizontal, 24) VStack(alignment: .leading, spacing: 12) { @@ -265,6 +222,22 @@ struct PluginManagementView: View { .padding(24) Spacer() } + .background(LitheTheme.settingsSurface) + } else if let manifest = availablePHPManifest { + VStack(alignment: .leading, spacing: 16) { + Text(LocalizedStringKey(manifest.displayName)) + .font(.system(size: 22, weight: .bold)) + Text(LocalizedStringKey("Install PHP Support from a signed plugin package.")) + .foregroundStyle(LitheTheme.secondaryText) + Button(LocalizedStringKey("Install Plugin from Disk…")) { + model.installPHPPluginPackage() + } + .buttonStyle(.borderedProminent) + .disabled(isApplyingChanges) + Spacer() + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack(spacing: 10) { Image(systemName: "puzzlepiece.extension") @@ -281,32 +254,32 @@ struct PluginManagementView: View { private var footer: some View { HStack { Spacer() - if !pendingEnabledStates.isEmpty || isApplyingChanges { - Text(LocalizedStringKey("Pending plugin changes: \(pendingEnabledStates.count)")) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - Button(LocalizedStringKey("Cancel")) { - pendingEnabledStates.removeAll() + Text(LocalizedStringKey("Pending plugin changes: \(pendingEnabledStates.count)")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + Button(LocalizedStringKey("Cancel")) { + settingsState.pendingPluginEnabledStates.removeAll() + } + .buttonStyle(.bordered) + .disabled(isApplyingChanges) + Button { + Task { @MainActor in + _ = await settingsState.applyPluginChanges(model.applyPluginEnabledChanges) } - .buttonStyle(.bordered) - .disabled(isApplyingChanges) - Button { - applyPendingChanges() - } label: { - if isApplyingChanges { - ProgressView().controlSize(.small) - } else { - Text(LocalizedStringKey("Confirm")) - } + } label: { + if isApplyingChanges { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey("Confirm")) } - .buttonStyle(.borderedProminent) - .tint(LitheTheme.accent) - .disabled(isApplyingChanges) } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(isApplyingChanges) } .padding(.horizontal, 14) .frame(height: 58) - .litheWorkbenchSurface(LitheTheme.toolHeader) + .background(LitheTheme.settingsSurface) .animation(.easeOut(duration: 0.15), value: pendingEnabledStates.isEmpty) } @@ -316,76 +289,31 @@ struct PluginManagementView: View { private func stageEnabledState(_ enabled: Bool, for plugin: PluginManagementSnapshot) { if enabled == plugin.isEnabled { - pendingEnabledStates.removeValue(forKey: plugin.id) + settingsState.pendingPluginEnabledStates.removeValue(forKey: plugin.id) } else { - pendingEnabledStates[plugin.id] = enabled + settingsState.pendingPluginEnabledStates[plugin.id] = enabled } } - private func applyPendingChanges() { - let changes = pendingEnabledStates - guard !changes.isEmpty, !isApplyingChanges else { return } - isApplyingChanges = true - Task { @MainActor in - let appliedPluginIDs = await model.applyPluginEnabledChanges(changes) - for pluginID in appliedPluginIDs { - pendingEnabledStates.removeValue(forKey: pluginID) - } - isApplyingChanges = false - } - } - - private func presentation(for plugin: PluginManagementSnapshot) -> PluginPresentation { - let id = plugin.id.rawValue - if id == "dev.lithe.plugin.go-support" { - return PluginPresentation( - systemImage: "g.circle.fill", - tint: LitheTheme.accent, - summary: "Adds Go language-server integration, formatting, running, and test support." - ) - } - - let languageID = plugin.manifest.languageSupports?.first?.id ?? "" - let supportsExecution = plugin.manifest.modules.contains { - $0.manifest.providedCapabilities.contains(.languageExecutionExtension(languageID)) - } - let summary = supportsExecution - ? "Adds language-server integration, formatting, running, and test support." - : "Adds language-server integration and formatting support." - - switch languageID { - case "python": return .init(systemImage: "chevron.left.forwardslash.chevron.right", tint: LitheTheme.warning, summary: summary) - case "node": return .init(systemImage: "hexagon.fill", tint: LitheTheme.success, summary: summary) - case "rust": return .init(systemImage: "gearshape.2.fill", tint: Color.orange, summary: summary) - case "swift": return .init(systemImage: "swift", tint: Color.orange, summary: summary) - case "clangd", "csharp", "fsharp", "kotlin", "scala", "groovy", "zig", "solidity": - return .init(systemImage: "chevron.left.forwardslash.chevron.right", tint: LitheTheme.accent, summary: summary) - case "html", "css", "vue", "svelte", "astro", "php": - return .init(systemImage: "globe", tint: LitheTheme.success, summary: summary) - case "json", "yaml", "xml", "toml", "graphql", "protobuf", "prisma": - return .init(systemImage: "curlybraces.square.fill", tint: Color.cyan, summary: summary) - case "markdown": return .init(systemImage: "doc.richtext.fill", tint: LitheTheme.secondaryText, summary: summary) - case "sql": return .init(systemImage: "cylinder.fill", tint: LitheTheme.warning, summary: summary) - case "dockerfile": return .init(systemImage: "shippingbox.fill", tint: Color.cyan, summary: summary) - case "terraform": return .init(systemImage: "square.3.layers.3d", tint: Color.indigo, summary: summary) - case "shell", "powershell", "make", "cmake": - return .init(systemImage: "terminal.fill", tint: LitheTheme.secondaryText, summary: summary) - default: return .init(systemImage: "puzzlepiece.extension.fill", tint: LitheTheme.accent, summary: summary) - } + private var phpPresentation: PluginPresentation { + PluginPresentation( + systemImage: "globe", + tint: LitheTheme.success, + summary: "Adds PHP language-server integration, formatting, running, and test support." + ) } } struct PluginManagementListContent { - let standalonePlugins: [PluginManagementSnapshot] - let languageExtensions: [PluginManagementSnapshot] + let plugins: [PluginManagementSnapshot] + + var availablePHPManifest: PluginManifest? { + guard plugins.isEmpty else { return nil } + return OfficialPluginCatalog.manifests.first { $0.id == OfficialPluginCatalog.phpPluginID } + } init(plugins: [PluginManagementSnapshot]) { - standalonePlugins = plugins.filter { plugin in - plugin.manifest.languageSupports?.isEmpty != false - } - languageExtensions = plugins.filter { plugin in - plugin.manifest.languageSupports?.isEmpty == false - } + self.plugins = plugins.filter { $0.id == OfficialPluginCatalog.phpPluginID } } } diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 723d05fbf..4fb5b0565 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import LitheLocalHistoryModule import SwiftUI @@ -207,8 +208,18 @@ private struct ActiveSessionChrome: View { title: windowTitle ) ) - .onReceive(session.workbenchFeature.$isSettingsPresented) { isPresented in - guard isPresented else { return } + .onReceive(session.workbenchFeature.$settingsCategoryRequest.dropFirst()) { _ in + SettingsWindowChrome.ownerWindow = NSApp.orderedWindows.first { + ($0.delegate as? LitheWindowCoordinator)?.projectSessions.windowScope == scope + } + projectSessions.bindSettings(to: session.id) + if let settingsWindow = SettingsWindowChrome.settingsWindow { + SettingsWindowChrome.configure( + settingsWindow, + title: settingsWindow.title, + themePreference: session.settings.themePreference + ) + } openWindow(id: LitheWindowID.settings) } .sheet(isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index fe00f9ebe..13349cde0 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -16,24 +16,46 @@ final class SettingsViewState: ObservableObject { @Published var isFormatPickerPresented = false @Published var detectedTerminalShells: [String] = [] @Published var knownTerminalShells: [String] = [] + @Published var pendingPluginEnabledStates: [PluginID: Bool] = [:] + @Published private(set) var isApplyingPluginChanges = false init(initialCategory: SettingsCategory) { selection = initialCategory } + + func applyPluginChanges( + _ apply: ([PluginID: Bool]) async -> Set + ) async -> Bool { + guard !isApplyingPluginChanges else { return false } + guard !pendingPluginEnabledStates.isEmpty else { return true } + isApplyingPluginChanges = true + let applied = await apply(pendingPluginEnabledStates) + for pluginID in applied { + pendingPluginEnabledStates.removeValue(forKey: pluginID) + } + isApplyingPluginChanges = false + return pendingPluginEnabledStates.isEmpty + } } struct SettingsView: View { + @FocusState private var isSearchFocused: Bool @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @EnvironmentObject private var model: AppModel @EnvironmentObject private var updateChecker: UpdateChecker @ObservedObject var settings: AppSettings @ObservedObject var viewState: SettingsViewState + @AppStorage("lithe.settings.categorySidebarWidth") private var categorySidebarWidth = 234.0 @State private var missingTerminalShellPath: String? + @State private var expandedSidebarGroups: Set = [] let initialCategory: SettingsCategory /// Changes with every category request; see `WorkbenchFeatureModel.settingsCategoryRequest`. let categoryRequest: Int private let onDismiss: (() -> Void)? + static let categoryMinimumWidth: CGFloat = 234 + static let contentMinimumWidth: CGFloat = 500 private static let footerActionLabelWidth: CGFloat = 52 init( @@ -52,20 +74,32 @@ struct SettingsView: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: 0) { - categories - Rectangle().fill(LitheTheme.divider).frame(width: 1) - content + GeometryReader { geometry in + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(categorySidebarWidth), + minimum: Self.categoryMinimumWidth, + maximum: max(Self.categoryMinimumWidth, geometry.size.width - SplitHandleView.thickness - Self.contentMinimumWidth), + flexibleMinimum: Self.contentMinimumWidth, + highlightsOnHover: false, + onCommit: { categorySidebarWidth = Double($0) }, + sized: { categories }, + flexible: { + content.simultaneousGesture(TapGesture().onEnded { isSearchFocused = false }) + } + ) } Rectangle().fill(LitheTheme.divider).frame(height: 1) footer } - .frame(minWidth: 820, minHeight: 620) + .frame(minWidth: 900, minHeight: 668) .background { LitheTheme.settingsSurface .ignoresSafeArea() } .onAppear { + expandGroup(containing: viewState.selection) syncVisibilityDrafts() model.refreshAIConfigurations() syncAIProviderDraft() @@ -81,6 +115,10 @@ struct SettingsView: View { .onChange(of: categoryRequest) { _ in viewState.searchQuery = "" viewState.selection = initialCategory + expandGroup(containing: initialCategory) + } + .onChange(of: viewState.selection) { category in + expandGroup(containing: category) } .onChange(of: viewState.searchQuery) { _ in guard !filteredCategories.contains(viewState.selection), @@ -88,6 +126,7 @@ struct SettingsView: View { viewState.selection = firstMatch } .environment(\.locale, settings.language.locale) + .font(LitheTheme.settingsFont) .alert("Shell not found", isPresented: Binding( get: { missingTerminalShellPath != nil }, set: { if !$0 { missingTerminalShellPath = nil } } @@ -107,15 +146,20 @@ struct SettingsView: View { private var categories: some View { VStack(spacing: 0) { settingsSearchField - .padding(12) - - Rectangle().fill(LitheTheme.divider).frame(height: 1) + .padding(.horizontal, 8) + .padding(.top, 11) + .padding(.bottom, 8) ScrollView { - VStack(spacing: 1) { - ForEach(filteredCategories) { category in - categoryButton(category) - } + VStack(spacing: 0) { + categoryGroup("Appearance & Behavior", categories: [.general, .updates]) + categoryButton(.keymap) + categoryButton(.editor) + categoryButton(.plugins) + categoryGroup("Version Control", categories: [.git]) + categoryGroup("Build, Execution, Deployment", categories: [.project, .run]) + categoryGroup("Languages & Frameworks", categories: [.lsp]) + categoryGroup("Tools", categories: [.terminal, .ai, .providers, .diagnostics]) if filteredCategories.isEmpty { VStack(spacing: 8) { @@ -129,41 +173,90 @@ struct SettingsView: View { .padding(.top, 28) } } - .padding(8) } .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } - .frame(width: 244) + .frame(maxWidth: .infinity) .frame(maxHeight: .infinity) .background(LitheTheme.settingsSurface) } private var settingsSearchField: some View { - LitheSettingsSearchField("Search settings", text: $viewState.searchQuery) + LitheSettingsSearchField("", text: $viewState.searchQuery, focus: $isSearchFocused) + .accessibilityLabel("Search settings") } - private func categoryButton(_ category: SettingsCategory) -> some View { - let isSelected = viewState.selection == category - return Button { - viewState.selection = category - } label: { - HStack(spacing: 10) { - Image(systemName: category.icon) - .font(.system(size: 12.5, weight: .medium)) - .frame(width: 18) - Text(LocalizedStringKey(category.title)) - .font(.system(size: 12.5, weight: .regular)) - Spacer(minLength: 8) + @ViewBuilder + private func categoryGroup(_ title: String, categories: [SettingsCategory]) -> some View { + let visible = categories.filter { filteredCategories.contains($0) } + if !visible.isEmpty { + let expanded = expandedSidebarGroups.contains(title) || !viewState.searchQuery.isEmpty + Button { + isSearchFocused = false + if expandedSidebarGroups.contains(title) { + expandedSidebarGroups.remove(title) + } else { + expandedSidebarGroups.insert(title) + } + } label: { + HStack(spacing: 8) { + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .medium)) + .frame(width: 10) + Text(LocalizedStringKey(title)) + .font(LitheTheme.settingsStrongFont) + Spacer(minLength: 0) + } + .padding(.leading, 16) + .padding(.trailing, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.primaryText) + if expanded { + ForEach(visible) { category in + categoryButton(category, nested: true) + } } - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: LitheTheme.Metrics.treeRowHeight) - .background(isSelected ? LitheTheme.settingsSelection : .clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) - .contentShape(Rectangle()) } - .buttonStyle(LitheTreeRowButtonStyle()) - .foregroundStyle(isSelected ? Color.white : LitheTheme.primaryText) + } + + @ViewBuilder + private func categoryButton(_ category: SettingsCategory, nested: Bool = false) -> some View { + if filteredCategories.contains(category) { + let isSelected = viewState.selection == category + Button { + isSearchFocused = false + viewState.selection = category + } label: { + HStack(spacing: 8) { + Color.clear.frame(width: 10) + Text(LocalizedStringKey(category.title)) + .font(nested ? LitheTheme.settingsFont : LitheTheme.settingsStrongFont) + Spacer(minLength: 0) + } + .padding(.leading, nested ? 30 : 16) + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 24) + .background(isSelected ? LitheTheme.settingsSelection : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .foregroundStyle(LitheTheme.primaryText) + } + } + + private func expandGroup(containing category: SettingsCategory) { + switch category { + case .general, .updates: expandedSidebarGroups.insert("Appearance & Behavior") + case .git: expandedSidebarGroups.insert("Version Control") + case .project, .run: expandedSidebarGroups.insert("Build, Execution, Deployment") + case .lsp: expandedSidebarGroups.insert("Languages & Frameworks") + case .terminal, .ai, .providers, .diagnostics: expandedSidebarGroups.insert("Tools") + case .keymap, .editor, .plugins: break + } } private var filteredCategories: [SettingsCategory] { @@ -204,6 +297,8 @@ struct SettingsView: View { ["Updates", "Application version", "Update status", "Check for Updates"] case .diagnostics: ["Diagnostics", "Diagnostics bundle", "Export logs", "Bug report"] + case .plugins: + ["Plugins", "Installed", "Marketplace", "Language support"] } } @@ -243,6 +338,9 @@ struct SettingsView: View { language: settings.language ) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewState.selection == .plugins { + PluginManagementView(settingsState: viewState) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(alignment: .leading, spacing: 8) { @@ -270,6 +368,7 @@ struct SettingsView: View { .frame(maxWidth: 760, alignment: .leading) case .updates: updatesSettings case .diagnostics: diagnosticsSettings + case .plugins: EmptyView() } } .padding(.horizontal, 28) @@ -441,26 +540,12 @@ struct SettingsView: View { Text("Directories") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $viewState.hiddenDirectoriesDraft) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 66) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 66) Text("File patterns") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $viewState.hiddenFilePatternsDraft) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 52) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 52) HStack(spacing: 8) { Spacer() @@ -509,7 +594,7 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay { RoundedRectangle(cornerRadius: 6) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(LitheTheme.inputBorder, lineWidth: 1) } HStack(spacing: 6) { @@ -959,14 +1044,7 @@ struct SettingsView: View { Text("Custom instructions") .font(.system(size: 11.5, weight: .medium)) TextEditor(text: $settings.commitMessageAI.customInstructions) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 92) - .padding(5) - .litheRoundedControlBackground(LitheTheme.inputBackground) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 92) } Text("Low effort and a small output limit are recommended for fast commit-message generation.") @@ -975,13 +1053,13 @@ struct SettingsView: View { } group("Pull request description generation") { - Picker("Description format", selection: $settings.commitMessageAI.pullRequestFormat) { - ForEach(PullRequestDescriptionFormat.allCases) { format in - Text(LocalizedStringKey(format.title)).tag(format) - } - } - .frame(maxWidth: 260, alignment: .leading) - .lithePointer() + LitheSettingsSelect( + selection: $settings.commitMessageAI.pullRequestFormat, + options: PullRequestDescriptionFormat.allCases, + width: 260, + accessibilityLabel: "Description format", + title: \.title + ) if settings.commitMessageAI.pullRequestFormat == .custom { HStack { @@ -998,15 +1076,7 @@ struct SettingsView: View { } TextEditor(text: $settings.commitMessageAI.pullRequestCustomTemplate) - .font(.system(size: 12, design: .monospaced)) - .frame(height: 150) - .padding(5) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + .litheSettingsTextEditor(height: 150) Text("Supported placeholders: {summary}, {changes}, {testing}, {risks}.") .font(LitheTheme.smallFont) @@ -1066,7 +1136,7 @@ struct SettingsView: View { .background(viewState.isFormatPickerPresented ? LitheTheme.inputBackground.opacity(0.9) : LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke( + .strokeBorder( viewState.isFormatPickerPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, lineWidth: 1 ) @@ -1212,7 +1282,7 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(LitheTheme.inputBorder, lineWidth: 1) } .id(settings.commitMessageAI.format) .transition(.opacity.combined(with: .move(edge: .top))) @@ -1470,24 +1540,34 @@ struct SettingsView: View { private var footer: some View { HStack { Button("Restore Defaults") { settings.restoreDefaults() } - .buttonStyle(LitheSecondaryButtonStyle()) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 28)) Spacer() HStack(spacing: 10) { Button { closeSettings() } label: { Text("Cancel") .frame(minWidth: Self.footerActionLabelWidth) } - .buttonStyle(LitheSecondaryButtonStyle()) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 28)) .keyboardShortcut(.cancelAction) - Button { closeSettings() } label: { + .disabled(viewState.isApplyingPluginChanges) + Button { + Task { @MainActor in + if await viewState.applyPluginChanges(model.applyPluginEnabledChanges) { + closeSettings() + } + } + } label: { Text("OK") .frame(minWidth: Self.footerActionLabelWidth) } .buttonStyle(LithePrimaryButtonStyle( backgroundColor: LitheTheme.settingsPrimaryAction, - restingOpacity: 1 + restingOpacity: 1, + horizontalPadding: 10, + height: 28 )) .keyboardShortcut(.defaultAction) + .disabled(viewState.isApplyingPluginChanges) } } .padding(.horizontal, 16) diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 3e573460d..b4def1276 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI private enum SettingsSelectMetrics { - static let fontSize: CGFloat = 12.5 + static let fontSize: CGFloat = 13 static let checkmarkWidth: CGFloat = 14 static let itemSpacing: CGFloat = 8 static let itemHorizontalPadding: CGFloat = 8 @@ -11,6 +11,8 @@ private enum SettingsSelectMetrics { } struct LitheSettingsSearchField: View { + @FocusState private var isFocused: Bool + private let externalFocus: FocusState.Binding? private let placeholder: LocalizedStringKey @Binding private var text: String private let onTextChanged: ((String) -> Void)? @@ -18,22 +20,30 @@ struct LitheSettingsSearchField: View { init( _ placeholder: LocalizedStringKey, text: Binding, + focus: FocusState.Binding? = nil, onTextChanged: ((String) -> Void)? = nil ) { self.placeholder = placeholder _text = text + externalFocus = focus self.onTextChanged = onTextChanged } var body: some View { HStack(spacing: 7) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.tertiaryText) + HStack(spacing: 1) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .medium)) + Image(systemName: "chevron.down") + .font(.system(size: 6, weight: .medium)) + } + .foregroundStyle(LitheTheme.tertiaryText) + .accessibilityHidden(true) TextField(placeholder, text: $text) .textFieldStyle(.plain) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) + .focused(externalFocus ?? $isFocused) if !text.isEmpty { Button { @@ -50,11 +60,10 @@ struct LitheSettingsSearchField: View { } .padding(.horizontal, 9) .frame(height: 28) - .background(Color.clear) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .litheRoundedControlBackground(LitheTheme.settingsSurface) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder((externalFocus?.wrappedValue ?? isFocused) ? LitheTheme.inputFocusBorder : LitheTheme.settingsSearchBorder, lineWidth: 1) } .onChange(of: text) { value in onTextChanged?(value) @@ -108,7 +117,7 @@ struct LitheSettingsSelect: View { } label: { HStack(spacing: 8) { Text(LocalizedStringKey(title(selection))) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) .foregroundStyle(isAvailable(selection) ? LitheTheme.primaryText : LitheTheme.tertiaryText) .lineLimit(1) @@ -123,11 +132,11 @@ struct LitheSettingsSelect: View { .frame(width: width, height: 30, alignment: .leading) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.settingsControlBackground) + .fill(LitheTheme.settingsSelectBackground) ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(isPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(isPresented ? LitheTheme.inputFocusBorder : LitheTheme.settingsSearchBorder, lineWidth: 1) } .contentShape(Rectangle()) } @@ -153,7 +162,7 @@ struct LitheSettingsSelect: View { .opacity(selection == option ? 1 : 0) Text(LocalizedStringKey(title(option))) - .font(.system(size: SettingsSelectMetrics.fontSize)) + .font(LitheTheme.settingsFont) .foregroundStyle(isAvailable(option) ? LitheTheme.primaryText : LitheTheme.tertiaryText) .lineLimit(expandsToFitOptions ? nil : 1) .fixedSize(horizontal: false, vertical: true) @@ -183,7 +192,8 @@ struct LitheSettingsSelect: View { private var preferredPopupWidth: CGFloat { guard expandsToFitOptions else { return width } - let font = NSFont.systemFont(ofSize: SettingsSelectMetrics.fontSize) + let font = NSFont(name: "Inter-Regular", size: SettingsSelectMetrics.fontSize) + ?? NSFont.systemFont(ofSize: SettingsSelectMetrics.fontSize) let titleWidth = options.reduce(CGFloat.zero) { widest, option in let text = String(localized: String.LocalizationValue(title(option)), locale: locale) return max(widest, (text as NSString).size(withAttributes: [.font: font]).width) @@ -224,8 +234,8 @@ struct LitheSettingsSegmentedControl: View { selection = option } label: { Text(LocalizedStringKey(title(option))) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(selection == option ? Color.white : LitheTheme.secondaryText) + .font(selection == option ? LitheTheme.settingsStrongFont : LitheTheme.settingsFont) + .foregroundStyle(selection == option ? LitheTheme.primaryText : LitheTheme.secondaryText) .frame(maxWidth: .infinity, minHeight: 26) .contentShape(Rectangle()) .litheRowHover( @@ -246,7 +256,7 @@ struct LitheSettingsSegmentedControl: View { ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(LitheTheme.inputBorder, lineWidth: 1) } } } @@ -277,7 +287,7 @@ struct LitheSettingsCheckbox: View { RoundedRectangle(cornerRadius: 4) .fill(isOn ? LitheTheme.accent : LitheTheme.inputBackground) RoundedRectangle(cornerRadius: 4) - .stroke(isOn ? LitheTheme.accent : LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(isOn ? LitheTheme.accent : LitheTheme.inputBorder, lineWidth: 1) Image(systemName: "checkmark") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color.white) @@ -326,14 +336,14 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V var body: some View { HStack(spacing: 0) { Text(title(value)) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) .foregroundStyle(LitheTheme.primaryText) .monospacedDigit() .frame(maxWidth: .infinity, alignment: .trailing) .padding(.horizontal, 8) Rectangle() - .fill(LitheTheme.inputBorder) + .fill(LitheTheme.settingsSearchBorder) .frame(width: 1, height: 20) stepButton(systemImage: "minus", isDisabled: value <= range.lowerBound) { @@ -347,11 +357,11 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V .frame(width: width, height: 30) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) + .fill(LitheTheme.settingsControlBackground) ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(LitheTheme.settingsSearchBorder, lineWidth: 1) } .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) .accessibilityElement(children: .contain) @@ -379,27 +389,52 @@ struct LitheSettingsStepper: View where Value: Strideable & Comparable, V private struct LitheSettingsTextFieldModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled + @FocusState private var isFocused: Bool func body(content: Content) -> some View { content .textFieldStyle(.plain) - .font(.system(size: 12.5)) + .font(LitheTheme.settingsFont) + .focused($isFocused) .padding(.horizontal, 9) .frame(height: 30) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) + .fill(LitheTheme.settingsControlBackground) ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) + .strokeBorder(isFocused ? LitheTheme.inputFocusBorder : LitheTheme.settingsSearchBorder, lineWidth: 1) } .opacity(isEnabled ? 1 : 0.55) } } +private struct LitheSettingsTextEditorModifier: ViewModifier { + @FocusState private var isFocused: Bool + let height: CGFloat + + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .font(.system(size: 12, design: .monospaced)) + .focused($isFocused) + .frame(height: height) + .padding(5) + .litheRoundedControlBackground(LitheTheme.settingsControlBackground) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .strokeBorder(isFocused ? LitheTheme.inputFocusBorder : LitheTheme.settingsSearchBorder, lineWidth: 1) + } + } +} + extension View { func litheSettingsTextField() -> some View { modifier(LitheSettingsTextFieldModifier()) } + + func litheSettingsTextEditor(height: CGFloat) -> some View { + modifier(LitheSettingsTextEditorModifier(height: height)) + } } diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift index 36c75d8b5..8eb67851b 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -24,6 +24,7 @@ struct LitheSplitPaneView: View { let clipsSizedPane: Bool let trackBackground: Color let showsIdleDivider: Bool + let highlightsOnHover: Bool /// Called with the final size when a drag ends. Hosts that persist the size /// write it here; the container then defers to `defaultSize` again so the /// persisted value is the single source of truth. @@ -45,6 +46,7 @@ struct LitheSplitPaneView: View { clipsSizedPane: Bool = false, trackBackground: Color = .clear, showsIdleDivider: Bool = true, + highlightsOnHover: Bool = true, onCommit: ((CGFloat) -> Void)? = nil, @ViewBuilder sized: () -> Sized, @ViewBuilder flexible: () -> Flexible @@ -58,6 +60,7 @@ struct LitheSplitPaneView: View { self.clipsSizedPane = clipsSizedPane self.trackBackground = trackBackground self.showsIdleDivider = showsIdleDivider + self.highlightsOnHover = highlightsOnHover self.onCommit = onCommit self.sized = sized() self.flexible = flexible() @@ -131,6 +134,7 @@ struct LitheSplitPaneView: View { axis: axis, trackBackground: trackBackground, showsIdleDivider: showsIdleDivider, + highlightsOnHover: highlightsOnHover, onDragStarted: { dragStart = size }, onDragChanged: { translation in draggedSize = resolved(from: translation) diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 835fff7e8..9b5e77495 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -17,6 +17,7 @@ struct SplitHandleView: View { let axis: LitheSplitAxis let trackBackground: Color let showsIdleDivider: Bool + let highlightsOnHover: Bool let onDragStarted: () -> Void let onDragChanged: (CGFloat) -> Void let onDragEnded: (CGFloat) -> Void @@ -30,6 +31,7 @@ struct SplitHandleView: View { axis: LitheSplitAxis, trackBackground: Color = .clear, showsIdleDivider: Bool = true, + highlightsOnHover: Bool = true, onDragStarted: @escaping () -> Void, onDragChanged: @escaping (CGFloat) -> Void, onDragEnded: @escaping (CGFloat) -> Void @@ -37,6 +39,7 @@ struct SplitHandleView: View { self.axis = axis self.trackBackground = trackBackground self.showsIdleDivider = showsIdleDivider + self.highlightsOnHover = highlightsOnHover self.onDragStarted = onDragStarted self.onDragChanged = onDragChanged self.onDragEnded = onDragEnded @@ -64,6 +67,7 @@ struct SplitHandleView: View { SplitHandleInteraction( axis: axis, onHoverChanged: { isInside in + guard highlightsOnHover else { return } guard isInside != isHovering else { return } isHovering = isInside }, @@ -114,8 +118,8 @@ struct SplitHandleView: View { @ViewBuilder private var dividerLine: some View { if showsIdleDivider { - let color = isDragging ? LitheTheme.primaryText - : (isHovering ? LitheTheme.secondaryText : LitheTheme.divider) + let color = isDragging && highlightsOnHover ? LitheTheme.primaryText + : (isHovering && highlightsOnHover ? LitheTheme.secondaryText : LitheTheme.divider) if axis == .horizontal { Rectangle() .fill(color) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 0b14bbfd0..691b91842 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -237,7 +237,6 @@ struct WorkbenchView: View { @State private var pendingTopBarPushReference: GitReference? @State private var pendingTopBarDeleteReference: GitReference? @State private var isProjectSwitcherPresented = false - @State private var isPluginPanelPresented = false @State private var isNotificationCenterPresented = false @State private var didRestoreLayout = false @State private var hoveredProjectTabID: UUID? @@ -1392,8 +1391,9 @@ struct WorkbenchView: View { ideaAssetPath: "expui/nodes/plugin.svg", help: "Plugins", tooltipPlacement: .leading, - isSelected: isPluginPanelPresented, - action: { isPluginPanelPresented.toggle() } + isSelected: model.workbenchFeature.isSettingsPresented + && model.requestedSettingsCategory == .plugins, + action: { model.showSettings(category: .plugins) } ) ForEach(model.rightSidebarContributions) { contribution in @@ -1546,10 +1546,7 @@ struct WorkbenchView: View { }, editor: { Group { - if isPluginPanelPresented { - PluginManagementView() - .environmentObject(model) - } else if model.workbenchFeature.selectedSidebar == .pullRequests { + if model.workbenchFeature.selectedSidebar == .pullRequests { if LitheFeatureAvailability.githubPullRequests { GitHubPullRequestDetailView() } else { diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 63bb2db64..f579620f3 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -211,6 +211,7 @@ public enum BuiltInPluginCatalog { public enum OfficialPluginCatalog { private static let goLanguageID = "go" private static let phpLanguageID = "php" + public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") public static let manifests: [PluginManifest] = [ PluginManifest( @@ -268,7 +269,7 @@ public enum OfficialPluginCatalog { )] ), PluginManifest( - id: PluginID("dev.lithe.plugin.php-support"), + id: phpPluginID, displayName: "PHP Support", version: BuiltInPluginCatalog.hostVersion, hostCompatibility: PluginHostCompatibility( diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index d49e6ca59..4856d363f 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -203,16 +203,11 @@ struct AppLocalizationTests { } @Test - func simplifiedChineseResourcesCoverPluginLanguageGrouping() throws { + func simplifiedChineseResourcesCoverPHPPluginManagement() throws { let translations = try simplifiedChineseTranslations() - #expect(translations["More Language Support"] == "扩展更多语言") - #expect( - translations["%lld languages · %lld enabled"] - == "%lld 种语言 · 已启用 %lld 个" - ) - #expect(translations["Expanded"] == "已展开") - #expect(translations["Collapsed"] == "已收起") + #expect(translations["PHP Support"] == "PHP 支持") + #expect(translations["Installed (%lld of %lld enabled)"] == "已安装(%lld / %lld 个已启用)") } @Test diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 8dbe23b07..74791fa93 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -237,6 +237,81 @@ struct LitheCoreLogicTests { #expect(sessions.requestCloseActiveSessionCallCount == 0) } + @Test + @MainActor + func settingsStayBoundToOpeningSessionUntilAnotherSessionReopensThem() async throws { + let store = MutableKeyValueStore() + let settings = AppSettings(store: store) + settings.projectOpenBehavior = .newWindow + var presentedWindowIDs: [UUID] = [] + let manager = ProjectSessionManager( + settings: settings, + modelFactory: { + AppModel( + settings: settings, + services: MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode, + javaMavenOperations: NoProjectJavaOperations() + ).services + ) + }, + projectWindowPresenter: { presentedWindowIDs.append($0) } + ) + + let primaryID = manager.activeSessionID(in: .primary) + manager.openStartupProject(URL(fileURLWithPath: "/tmp/lithe-settings-primary")) + manager.requestOpenProject( + URL(fileURLWithPath: "/tmp/lithe-settings-dedicated"), + from: primaryID + ) + let dedicatedWindowID = try #require(presentedWindowIDs.first) + let dedicatedID = manager.activeSessionID(in: .dedicated(dedicatedWindowID)) + + manager.bindSettings(to: primaryID) + let firstBindingID = manager.settingsBindingID + manager.noteWindowBecameKey(.dedicated(dedicatedWindowID)) + #expect(manager.activeSessionID == dedicatedID) + #expect(manager.settingsModel?.id == primaryID) + + let firstDraft = SettingsViewState(initialCategory: .plugins) + let secondDraft = SettingsViewState(initialCategory: .plugins) + let pluginID = OfficialPluginCatalog.phpPluginID + firstDraft.pendingPluginEnabledStates[pluginID] = true + let applied = await firstDraft.applyPluginChanges { _ in + await withCheckedContinuation { continuation in + manager.bindSettings(to: dedicatedID) + secondDraft.pendingPluginEnabledStates[pluginID] = false + continuation.resume(returning: [pluginID]) + } + } + #expect(applied) + var didCloseSettings = false + manager.closeSettingsIfCurrent(for: primaryID, bindingID: firstBindingID) { + didCloseSettings = true + } + #expect(!didCloseSettings) + #expect(manager.settingsModel?.id == dedicatedID) + #expect(secondDraft.pendingPluginEnabledStates[pluginID] == false) + + manager.releaseSettings(for: primaryID) + #expect(manager.settingsModel?.id == dedicatedID) + let secondBindingID = manager.settingsBindingID + manager.closeSettingsIfCurrent(for: dedicatedID, bindingID: secondBindingID) { + didCloseSettings = true + } + #expect(didCloseSettings) + didCloseSettings = false + manager.bindSettings(to: dedicatedID) + manager.closeSettingsIfCurrent(for: dedicatedID, bindingID: secondBindingID) { + didCloseSettings = true + } + #expect(!didCloseSettings) + manager.releaseSettings(for: dedicatedID) + #expect(manager.settingsModel == nil) + } + @Test @MainActor func openingAProjectInANewWindowCreatesADedicatedSession() throws { diff --git a/macos/Tests/LitheTests/PluginManagementPresentationTests.swift b/macos/Tests/LitheTests/PluginManagementPresentationTests.swift index 2bd625566..8225db7d7 100644 --- a/macos/Tests/LitheTests/PluginManagementPresentationTests.swift +++ b/macos/Tests/LitheTests/PluginManagementPresentationTests.swift @@ -5,30 +5,51 @@ import LitheModuleAPI @Suite("Plugin management presentation") struct PluginManagementPresentationTests { @Test - func languagePluginsAreGroupedSeparatelyFromStandalonePlugins() throws { + func onlyOfficialPHPPluginAppearsInSettings() throws { + let phpManifest = try #require( + OfficialPluginCatalog.manifests.first { $0.id == OfficialPluginCatalog.phpPluginID } + ) + let goManifest = try #require( + OfficialPluginCatalog.manifests.first { $0.id != OfficialPluginCatalog.phpPluginID } + ) let pythonManifest = try #require( BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "python" } ) - let rustManifest = try #require( - BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "rust" } - ) let content = PluginManagementListContent(plugins: [ + snapshot(phpManifest), + snapshot(goManifest), snapshot(pythonManifest), - snapshot(rustManifest) ]) - #expect(content.standalonePlugins.isEmpty) - #expect(content.languageExtensions.map(\.id) == [pythonManifest.id, rustManifest.id]) + #expect(content.plugins.map(\.id) == [phpManifest.id]) } @Test - func everyBundledLanguagePluginUsesTheLanguageExtensionGroup() { - let content = PluginManagementListContent( - plugins: BundledLanguagePluginCatalog.manifests.map(snapshot) - ) + func cleanInstallShowsPHPInstallEntry() { + let content = PluginManagementListContent(plugins: []) + + #expect(content.plugins.isEmpty) + #expect(content.availablePHPManifest?.id == OfficialPluginCatalog.phpPluginID) + } + + @MainActor + @Test + func settingsOKKeepsFailedPluginChangesForRetry() async { + let state = SettingsViewState(initialCategory: .plugins) + let pluginID = OfficialPluginCatalog.phpPluginID + state.pendingPluginEnabledStates[pluginID] = true + + let shouldCloseAfterFailure = await state.applyPluginChanges { changes in + #expect(changes == [pluginID: true]) + return [] + } + #expect(!shouldCloseAfterFailure) + #expect(state.pendingPluginEnabledStates[pluginID] == true) + #expect(!state.isApplyingPluginChanges) - #expect(content.standalonePlugins.isEmpty) - #expect(content.languageExtensions.count == BundledLanguagePluginCatalog.manifests.count) + let shouldCloseAfterRetry = await state.applyPluginChanges { _ in [pluginID] } + #expect(shouldCloseAfterRetry) + #expect(state.pendingPluginEnabledStates.isEmpty) } private func snapshot(_ manifest: PluginManifest) -> PluginManagementSnapshot { diff --git a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift index 4bd1056ff..ee6688916 100644 --- a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift +++ b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -4,7 +4,42 @@ import Testing @testable import Lithe @MainActor +@Suite(.serialized) struct SettingsAppearanceContainerTests { + @Test + func settingsInputRolesUseDistinctIDEAStyleSurfaces() throws { + for (appearanceName, expected) in [ + (NSAppearance.Name.darkAqua, [0x2B2D30, 0x393B40]), + (.aqua, [0xFFFFFF, 0xFFFFFF]) + ] { + let appearance = try #require(NSAppearance(named: appearanceName)) + var colors: [NSColor?] = [] + appearance.performAsCurrentDrawingAppearance { + colors = [LitheTheme.settingsControlBackground, LitheTheme.settingsSelectBackground] + .map { NSColor($0).usingColorSpace(.sRGB) } + } + for (color, hex) in zip(colors, expected) { + let color = try #require(color) + #expect(abs(color.redComponent - CGFloat((hex >> 16) & 0xff) / 255) < 0.005) + #expect(abs(color.greenComponent - CGFloat((hex >> 8) & 0xff) / 255) < 0.005) + #expect(abs(color.blueComponent - CGFloat(hex & 0xff) / 255) < 0.005) + } + } + } + + @Test + func settingsSurfaceMatchesWorkbenchLightAndDarkColors() throws { + for appearanceName in [NSAppearance.Name.darkAqua, .aqua] { + let appearance = try #require(NSAppearance(named: appearanceName)) + let isDark = appearanceName == .darkAqua + let actual = try #require( + LitheTheme.settingsSurfaceNSColor(for: appearance).usingColorSpace(.sRGB) + ) + let expected = try #require(LitheTheme.nsColor(.sidebar, isDark: isDark).usingColorSpace(.sRGB)) + #expect(actual == expected) + } + } + @Test func changingAppearanceKeepsTheContentIdentity() { let recorder = SettingsContentIdentityRecorder() @@ -49,6 +84,49 @@ struct SettingsAppearanceContainerTests { #expect(reopened !== window) } + @Test + func settingsWindowFollowsItsOwningWorkbenchWindow() { + let (firstOwner, _) = makeWindow(theme: .dark) + let (secondOwner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + firstOwner.close() + secondOwner.close() + } + + SettingsWindowChrome.ownerWindow = firstOwner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === firstOwner) + #expect(settingsWindow.level == .normal) + + SettingsWindowChrome.ownerWindow = secondOwner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === secondOwner) + #expect(firstOwner.childWindows?.contains(settingsWindow) != true) + } + + @Test + func applyingPluginChangesBlocksNativeWindowClose() { + let (window, _) = makeWindow(theme: .dark) + defer { window.close() } + + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark, closeEnabled: false) + window.performClose(nil) + #expect(window.isVisible) + + // Reconfiguring ownership or appearance must not reenable closing mid-apply. + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark) + #expect(window.standardWindowButton(.closeButton)?.isEnabled == false) + + SettingsWindowChrome.configure(window, title: "Settings", themePreference: .dark, closeEnabled: true) + window.performClose(nil) + #expect(!window.isVisible) + } + private func makeWindow(theme: AppThemePreference) -> (NSWindow, NSHostingView) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 240), diff --git a/macos/Tests/LitheTests/SplitHandleViewTests.swift b/macos/Tests/LitheTests/SplitHandleViewTests.swift index fef75c220..7692e2ba9 100644 --- a/macos/Tests/LitheTests/SplitHandleViewTests.swift +++ b/macos/Tests/LitheTests/SplitHandleViewTests.swift @@ -5,6 +5,16 @@ import SwiftUI @Suite("Split handle updates") struct SplitHandleViewTests { + @MainActor + @Test + func settingsPluginDividersHaveUsableTravelAtMinimumWindowWidth() { + let availableTravel = 900 - SettingsView.categoryMinimumWidth + - SplitHandleView.thickness - SettingsView.contentMinimumWidth + #expect(availableTravel >= 150) + #expect(SettingsView.contentMinimumWidth >= PluginManagementView.minimumWidth) + #expect(PluginManagementView.listMinimumWidth < 320) + } + @Test func pendingDragUpdatesCoalesceToNewestTranslation() { var buffer = FrameCoalescedDragUpdateBuffer() diff --git a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift index 995f51434..230574c14 100644 --- a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift +++ b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift @@ -33,6 +33,18 @@ struct WorkbenchFeatureModelTests { #expect(model.isSettingsPresented) } + @Test + func pluginsOpenInSettingsWhileMavenRemainsVisible() { + let model = WorkbenchFeatureModel() + model.setVisibility(.maven, isVisible: true) + + model.presentSettings(category: .plugins) + + #expect(model.isSettingsPresented) + #expect(model.requestedSettingsCategory == .plugins) + #expect(model.isVisible(.maven)) + } + @Test func mavenNavigationDoesNotReplaceBottomTools() { let model = WorkbenchFeatureModel() diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 5a28d9bcd..2b37a65f9 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1865,9 +1865,18 @@ "macos": { "evidence": [ "macos/Sources/Lithe/LitheApp.swift", + "macos/Sources/Lithe/Views/App/RootView.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Sources/Lithe/Views/App/PluginManagementView.swift", + "macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift", + "macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift", + "macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift", "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift", - "macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift" + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Sources/Lithe/Theme/LitheTheme.swift", + "macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift", + "macos/Tests/LitheTests/PluginManagementPresentationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -1880,7 +1889,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。" + "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查。" }, { "id": "keymap-customization", @@ -1917,6 +1926,9 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/App/PluginManagementView.swift", + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift", + "macos/Tests/LitheTests/PluginManagementPresentationTests.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins" ], "implementationStatus": "implemented", @@ -1931,7 +1943,7 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。" + "verification": "macOS 全新安装时打开插件页,确认显示 PHP Support 未安装占位项,并可选择已签名的 PHP 插件包安装;选择非 PHP 包应被拒绝,安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。" }, { "id": "onboarding-welcome",