From ce90a8f84d1fac364bd5de4bf864f72c018fd861 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 1 Nov 2026 13:03:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(macos):=20=E5=A2=9E=E5=8A=A0=E5=BD=A9?= =?UTF-8?q?=E8=89=B2=20LSP=20=E7=8A=B6=E6=80=81=E8=8F=9C=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E6=99=AE=E9=80=9A=E6=97=A5=E5=BF=97=E6=A0=87=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...guage-tooling-and-lsp-runtime-ownership.md | 31 ++ .../2026-10-12-editor-tab-split-docking.md | 4 + macos/Resources/en.lproj/Localizable.strings | 28 ++ .../zh-Hans.lproj/Localizable.strings | 28 ++ .../Features/EditorSessionCoordinator.swift | 10 +- .../Features/EditorTabOrderFeatureModel.swift | 2 +- .../LSPControlCenterPresentation.swift | 37 ++ .../Features/LanguageServerLogsModel.swift | 57 +++ .../LanguageToolingFeatureModel.swift | 62 ++++ .../AppModel/AppModel+FeatureState.swift | 1 + .../AppModel+LanguageServerRuntime.swift | 191 +++++++++- .../Models/AppModel/AppModel+Media.swift | 2 + .../Models/AppModel/AppModel+Terminal.swift | 1 + .../AppModel/AppModel+WorkbenchClose.swift | 5 + .../Lithe/Models/AppModel/AppModel.swift | 157 +-------- .../Lithe/Models/Editor/EditorTabItem.swift | 1 + .../Lithe/Views/Editor/EditorAreaView.swift | 35 +- .../Views/Language/LSPControlCenterView.swift | 9 +- .../Language/LanguageServerLogsView.swift | 100 ++++++ .../Workbench/LanguageServerStatusView.swift | 98 ++++++ .../Lithe/Views/Workbench/WorkbenchView.swift | 2 + .../LanguageServerStatusTests.swift | 328 ++++++++++++++++++ shared/i18n/locales/en-US.json | 16 +- shared/i18n/locales/zh-CN.json | 16 +- shared/i18n/messages.json | 154 ++++++++ .../features/lsp-status-logs.json | 29 ++ 26 files changed, 1239 insertions(+), 165 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift create mode 100644 macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift create mode 100644 macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift create mode 100644 macos/Tests/LitheTests/LanguageServerStatusTests.swift create mode 100644 shared/platform-feature-matrix/features/lsp-status-logs.json diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index cd1531cb8b..f5123c6fa6 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -36,6 +36,31 @@ ## 决策 +### 状态栏只投影会话,日志面板不接管协议 + +macOS 状态栏从会话管理器的状态和服务器自报版本生成列表,不扫描工具、不启动 +服务器,也不把代码诊断错误当作服务器故障。没有启动、已经停止或工作区停用的 +服务器隐藏;启动失败仍保留入口,供用户查看日志和重试。颜色使用现有主题的 +成功、强调、警告和错误色,与 LSP 设置页一致。 + +单个或全部服务器的停止与重启只影响当前会话,不改工作区启用偏好。停止同时 +使该提供方已排队的激活请求失效;下一次打开、切换或编辑匹配文档仍可按需启动。 +专属准备任务通过在依赖组装处登记的取消回调结束,通用状态菜单不按语言名称分支。 +重启先停止,再重新同步匹配的打开文档;没有打开文档时也重启同一个预热提供方, +不把“重启”静默变成“停止”。 + +“查看日志”打开或聚焦普通编辑器标签页中的 LSP 日志,不新增底部工具窗口。 +日志标签复用混合标签顺序、拖动分组、选择和关闭机制;切换服务器仍使用同一标签。它直接观察现有的最多 100 条会话事件 +和服务器输出,按提供方与最低级别筛选;日志更新不经整个 AppModel 广播。 +“清空视图”只记住所选提供方当前可见缓冲条目的身份,不删除持久诊断日志, +也不影响其他提供方。标记随缓冲淘汰,不建立另一个无界日志存储。 + +正确做法是按当前会话事件显示真实内容。不要在 Swift 再实现 JSON-RPC(语言服务器 +使用的请求响应协议)抓包器,或把现有文本日志复制成 Server Trace、RPC Messages +三个假通道。当前 Core 没有提供原始跟踪/消息采集,日志标签的工具栏明确提示不可用; +将来确需采集时,应在 Core 既有协议所有者处设计显式启用、脱敏和预算,再扩展契约。 +本次没有新缓存目录、下载或安装资源写入,运行时只读发行基线不变。 + LSP 子进程、stdio、JSON-RPC 状态机、文档版本、请求 deadline、诊断和 结果归一化统一收归 Rust Core([`rust/lithe-core/src/lsp/`](../../../../rust/lithe-core/src/lsp/)), 平台 adapter 只负责可执行文件与运行环境发现。Swift 只保留 UI 投影和 @@ -295,6 +320,12 @@ capability 为准。 ## 验证 +macOS 状态栏投影、停止/重启的偏好边界、过期激活身份、按服务器过滤与清空由 +`macos/Tests/LitheTests/LanguageServerStatusTests.swift` 覆盖。使用 +`./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter LanguageServerStatusTests` +记录逐项计时,并运行 `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`。 +这些组件回归不替代真实多语言服务、明暗主题及编辑器标签交互验收。 + 本次等待策略由 `jdt_progress.rs` 的受控时钟测试和 `engine.rs` 的模拟服务测试 覆盖:45 秒静默仍可就绪、警告去重、变化后再次警告、持续进展也不能越过总期限。 共享示例位于 `shared/fixtures/lsp/jdt-readiness-v1.json`。Windows 的 diff --git a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md index f936eae464..bafa911043 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md @@ -24,6 +24,10 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 关闭或移走最后一个标签后合并空组;新标签进入当前聚焦的组。文件和会话生命周期不交给布局树。 分隔器继续使用 `LitheSplitPaneView`,尺寸变化留在局部视图,只在释放后提交比例。 分组是当前项目会话状态,本次不增加重启恢复编辑分屏或跨窗口传输。 +LSP 日志同样是普通编辑器标签,不登记底部工具窗口。单个工作区只打开一个日志标签, +再次查看另一服务器时复用标签并更新筛选;标签可参与重排和分组。日志不拥有文件缓冲区, +选择它应清除活动文件/媒体/终端投影,关闭后通过混合标签选择恢复原内容,不能关闭未保存文件。 +文件通过功能模型直接打开时,也必须让日志标签让出活动选择;工作区重置移除日志标签。 与数据库 PR #17 集成后,数据库标签继续使用其共享工作区和查询状态,统一保留在同一个编辑分组,只允许组内重排;不能直接开放跨组移动,否则两个分组会显示同一数据库上下文。数据库工具窗口及 Services 工具窗口仍支持停靠分组。 ### Git Diff 也属于普通编辑标签 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index d77bb0887a..ff4222c51c 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -690,6 +690,7 @@ "Clear Logs" = "Clear Logs"; "Clear Override" = "Clear Override"; "Clear Project History…" = "Clear Project History…"; +"Clear View" = "Clear View"; "Clear branch filter" = "Clear branch filter"; "Clear build output" = "Clear build output"; "Clear collected logs?" = "Clear collected logs?"; @@ -697,6 +698,7 @@ "Clear date filter" = "Clear date filter"; "Clear debug output" = "Clear debug output"; "Clear log search" = "Clear log search"; +"Clear only the selected server's visible logs. Collected diagnostic logs are kept." = "Clear only the selected server's visible logs. Collected diagnostic logs are kept."; "Clear path filter" = "Clear path filter"; "Clear run output" = "Clear run output"; "Clear search" = "Clear search"; @@ -1799,6 +1801,7 @@ "Increase Font Size" = "Increase Font Size"; "Indentation" = "Indentation"; "Index and working tree" = "Index and working tree"; +"Info" = "Info"; "Information" = "Information"; "Inherit" = "Inherit"; "Inherit project JDK" = "Inherit project JDK"; @@ -1948,10 +1951,13 @@ "Keymap…" = "Keymap…"; "Korean characters" = "Korean characters"; "Kotlin Support" = "Kotlin Support"; +"LSP" = "LSP"; +"LSP Logs" = "LSP Logs"; "Labels" = "Labels"; "Labels and assignees" = "Labels and assignees"; "Language" = "Language"; "Language Server" = "Language Server"; +"Language Server Settings..." = "Language Server Settings..."; "Language Service" = "Language Service"; "Language Services" = "Language Services"; "Language edit targets a file outside the workspace." = "Language edit targets a file outside the workspace."; @@ -2456,6 +2462,7 @@ "No matching commits in the loaded history." = "No matching commits in the loaded history."; "No matching conversations" = "No matching conversations"; "No matching items" = "No matching items"; +"No matching language server logs" = "No matching language server logs"; "No matching logs" = "No matching logs"; "No matching messages" = "No matching messages"; "No matching plugins" = "No matching plugins"; @@ -3184,12 +3191,14 @@ "Response" = "Response"; "Responses API" = "Responses API"; "Restart" = "Restart"; +"Restart All Servers" = "Restart All Servers"; "Restart Language Servers" = "Restart Language Servers"; "Restart Lithe" = "Restart Lithe"; "Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace." = "Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace."; "Restart Lithe to finish uninstalling %@, then install this plugin." = "Restart Lithe to finish uninstalling %@, then install this plugin."; "Restart Lithe to load the updated page." = "Restart Lithe to load the updated page."; "Restart Lithe to use %@." = "Restart Lithe to use %@."; +"Restart Server" = "Restart Server"; "Restart required" = "Restart required"; "Restart run" = "Restart run"; "Restart service" = "Restart service"; @@ -3463,6 +3472,7 @@ "Sensitive files are not sent to an AI provider." = "Sensitive files are not sent to an AI provider."; "Separate arguments with spaces; quote an argument that contains spaces." = "Separate arguments with spaces; quote an argument that contains spaces."; "Server" = "Server"; +"Server Logs" = "Server Logs"; "Server URL" = "Server URL"; "Server address" = "Server address"; "Server default" = "Server default"; @@ -3576,6 +3586,7 @@ "Showing available matches." = "Showing available matches."; "Showing the previous Java entries while the Java language service prepares the project." = "Showing the previous Java entries while the Java language service prepares the project."; "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar." = "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar."; +"Shows session events and server output. Server Trace and RPC message capture are not available." = "Shows session events and server output. Server Trace and RPC message capture are not available."; "Side-by-side" = "Side-by-side"; "Side-by-side view" = "Side-by-side view"; "Sign in again" = "Sign in again"; @@ -3706,6 +3717,7 @@ "Starting commit (optional)" = "Starting commit (optional)"; "Starting module..." = "Starting module..."; "Starting the Agent…" = "Starting the Agent…"; +"Starts again when a matching document is opened or edited." = "Starts again when a matching document is opened or edited."; "Starts no programs." = "Starts no programs."; "Starts on demand when a matching file is opened" = "Starts on demand when a matching file is opened"; "Stash" = "Stash"; @@ -3730,6 +3742,7 @@ "Sticky scroll" = "Sticky scroll"; "Stop" = "Stop"; "Stop %@" = "Stop %@"; +"Stop All Servers" = "Stop All Servers"; "Stop Camera" = "Stop Camera"; "Stop Debug" = "Stop Debug"; "Stop Loading" = "Stop Loading"; @@ -3737,6 +3750,7 @@ "Stop Microphone" = "Stop Microphone"; "Stop Recording" = "Stop Recording"; "Stop Run" = "Stop Run"; +"Stop Server" = "Stop Server"; "Stop Tracking Branch" = "Stop Tracking Branch"; "Stop active execution" = "Stop active execution"; "Stop all services" = "Stop all services"; @@ -5370,6 +5384,7 @@ "native.clearDebugOutput" = "Clear debug output"; "native.clearGitConsole" = "Clear Git console"; "native.clearLogSearch" = "Clear log search"; +"native.clearLogView" = "Clear View"; "native.clearLogs" = "Clear Logs"; "native.clearOverride" = "Clear Override"; "native.clearPathFilter" = "Clear path filter"; @@ -6730,6 +6745,7 @@ "native.languageServerReturnedAnInvalidTextRange" = "Language server returned an invalid text range."; "native.languageServerReturnedOverlappingTextEdits" = "Language server returned overlapping text edits."; "native.languageServerSessionStoppedBeforeTheRequestCompleted" = "Language server session stopped before the request completed."; +"native.languageServerSettingsAction" = "Language Server Settings..."; "native.languageServerToolConfigurationChanged" = "Language server tool configuration changed"; "native.languageServers" = "Language servers"; "native.languageService" = "Language Service"; @@ -6943,6 +6959,7 @@ "native.log.variant2" = "Log"; "native.logDirectory" = "Log directory"; "native.logFilter" = "Log filter"; +"native.logInfoLevel" = "Info"; "native.logLevel" = "Log level"; "native.logLocations" = "Log locations"; "native.logMessage" = "Log message"; @@ -6958,6 +6975,12 @@ "native.low" = "Low"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "Low effort and a small output limit are recommended for fast commit-message generation."; "native.lowRecommended" = "Low (recommended)"; +"native.lspAvailableLogChannels" = "Shows session events and server output. Server Trace and RPC message capture are not available."; +"native.lspClearViewHint" = "Clear only the selected server's visible logs. Collected diagnostic logs are kept."; +"native.lspLogsTitle" = "LSP Logs"; +"native.lspServerLogs" = "Server Logs"; +"native.lspStatusLabel" = "LSP"; +"native.lspStopOnDemandHint" = "Starts again when a matching document is opened or edited."; "native.luaSupport" = "Lua Support"; "native.macosCouldNotPrepareTheUpdateDiskImageDownloadTheReleaseManuallyAndTryAgain" = "macOS could not prepare the update disk image. Download the release manually and try again."; "native.macosCouldNotPrepareTheUpdateDiskImageOpenTheReleasePageAndInstallItManually" = "macOS could not prepare the update disk image. Open the Release page and install it manually."; @@ -7267,6 +7290,7 @@ "native.noMatchingConversations" = "No matching conversations"; "native.noMatchingHooks" = "No matching Hooks."; "native.noMatchingItems" = "No matching items"; +"native.noMatchingLanguageServerLogs" = "No matching language server logs"; "native.noMatchingLogs" = "No matching logs"; "native.noMatchingMcpServers" = "No matching MCP servers"; "native.noMatchingMessages" = "No matching messages"; @@ -7990,6 +8014,7 @@ "native.response" = "Response"; "native.responsesAPI" = "Responses API"; "native.restart" = "Restart"; +"native.restartAllServers" = "Restart All Servers"; "native.restartLanguageServers" = "Restart Language Servers"; "native.restartLithe" = "Restart Lithe"; "native.restartLitheToFinishUninstallingThenInstallTheCommunityPluginFromTheMarketplace" = "Restart Lithe to finish uninstalling %1$@, then install the community plugin %2$@ from the Marketplace."; @@ -7998,6 +8023,7 @@ "native.restartLitheToUseArg1" = "Restart Lithe to use %1$@."; "native.restartRequired" = "Restart required"; "native.restartRun" = "Restart run"; +"native.restartServer" = "Restart Server"; "native.restartService" = "Restart service"; "native.restore" = "Restore"; "native.restoreAllDefaults" = "Restore All Defaults"; @@ -8508,6 +8534,7 @@ "native.stepOut" = "Step out"; "native.stepOver" = "Step over"; "native.stopActiveExecution" = "Stop active execution"; +"native.stopAllServers" = "Stop All Servers"; "native.stopAllServices" = "Stop all services"; "native.stopAndClose" = "Stop and Close"; "native.stopAndQuit" = "Stop and Quit"; @@ -8524,6 +8551,7 @@ "native.stopRun" = "Stop Run"; "native.stopRun.variant2" = "Stop run"; "native.stopSelected" = "Stop selected"; +"native.stopServer" = "Stop Server"; "native.stopService" = "Stop service"; "native.stopSessionNamed" = "Stop %1$@"; "native.stopTestRun" = "Stop test run"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f716ce8e46..80f752e7d1 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -690,6 +690,7 @@ "Clear Logs" = "清空日志"; "Clear Override" = "清除覆盖"; "Clear Project History…" = "清空项目历史…"; +"Clear View" = "清空视图"; "Clear branch filter" = "清除分支筛选"; "Clear build output" = "清除构建输出"; "Clear collected logs?" = "清空已收集的日志?"; @@ -697,6 +698,7 @@ "Clear date filter" = "清除日期筛选"; "Clear debug output" = "清除调试输出"; "Clear log search" = "清除日志搜索"; +"Clear only the selected server's visible logs. Collected diagnostic logs are kept." = "仅清空所选服务器的可见日志,保留已采集的诊断日志。"; "Clear path filter" = "清除路径筛选"; "Clear run output" = "清除运行输出"; "Clear search" = "清除搜索"; @@ -1799,6 +1801,7 @@ "Increase Font Size" = "增大字号"; "Indentation" = "缩进"; "Index and working tree" = "暂存区和工作树"; +"Info" = "信息"; "Information" = "信息"; "Inherit" = "继承"; "Inherit project JDK" = "继承项目 JDK"; @@ -1948,10 +1951,13 @@ "Keymap…" = "快捷键…"; "Korean characters" = "韩文字符"; "Kotlin Support" = "Kotlin 支持"; +"LSP" = "LSP"; +"LSP Logs" = "LSP 日志"; "Labels" = "标签"; "Labels and assignees" = "标签和负责人"; "Language" = "语言"; "Language Server" = "语言服务器"; +"Language Server Settings..." = "语言服务器设置..."; "Language Service" = "语言服务"; "Language Services" = "语言服务"; "Language edit targets a file outside the workspace." = "语言编辑指向工作区之外的文件。"; @@ -2456,6 +2462,7 @@ "No matching commits in the loaded history." = "已加载的历史中没有匹配的提交。"; "No matching conversations" = "没有匹配的会话"; "No matching items" = "没有匹配项"; +"No matching language server logs" = "没有匹配的语言服务器日志"; "No matching logs" = "没有匹配的日志"; "No matching messages" = "没有匹配的消息"; "No matching plugins" = "没有匹配的插件"; @@ -3184,12 +3191,14 @@ "Response" = "回复"; "Responses API" = "Responses API"; "Restart" = "重新启动"; +"Restart All Servers" = "重启所有服务器"; "Restart Language Servers" = "重启语言服务器"; "Restart Lithe" = "重启 Lithe"; "Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace." = "请重启 Lithe 以完成 %@ 的卸载,然后从插件市场安装社区插件 %@。"; "Restart Lithe to finish uninstalling %@, then install this plugin." = "请重启 Lithe 以完成 %@ 的卸载,然后再安装此插件。"; "Restart Lithe to load the updated page." = "重启 Lithe 以加载更新后的页面。"; "Restart Lithe to use %@." = "重启 Lithe 后即可使用 %@。"; +"Restart Server" = "重启服务器"; "Restart required" = "需要重启 Lithe"; "Restart run" = "重新运行"; "Restart service" = "重启服务"; @@ -3463,6 +3472,7 @@ "Sensitive files are not sent to an AI provider." = "敏感文件不会发送给 AI 服务商。"; "Separate arguments with spaces; quote an argument that contains spaces." = "参数之间用空格分隔;包含空格的参数请加引号。"; "Server" = "服务器"; +"Server Logs" = "服务器日志"; "Server URL" = "服务器 URL"; "Server address" = "服务器地址"; "Server default" = "服务器默认"; @@ -3576,6 +3586,7 @@ "Showing available matches." = "仍显示可用的匹配项。"; "Showing the previous Java entries while the Java language service prepares the project." = "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。"; "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar." = "显示在整个工作台内,包括工具栏、项目文件树、编辑区、标签栏、终端和状态栏。"; +"Shows session events and server output. Server Trace and RPC message capture are not available." = "显示会话事件和服务器输出。暂不支持服务器跟踪和 RPC 消息采集。"; "Side-by-side" = "并排"; "Side-by-side view" = "并排视图"; "Sign in again" = "重新登录"; @@ -3706,6 +3717,7 @@ "Starting commit (optional)" = "起始提交(可选)"; "Starting module..." = "正在启动模块…"; "Starting the Agent…" = "正在启动 Agent…"; +"Starts again when a matching document is opened or edited." = "打开、切换到或编辑匹配的文档时将再次启动。"; "Starts no programs." = "不启动任何程序。"; "Starts on demand when a matching file is opened" = "按需启动,打开对应文件时运行"; "Stash" = "暂存"; @@ -3730,6 +3742,7 @@ "Sticky scroll" = "固定显示作用域标题"; "Stop" = "停止"; "Stop %@" = "停止 %@"; +"Stop All Servers" = "停止所有服务器"; "Stop Camera" = "停止摄像头"; "Stop Debug" = "停止调试"; "Stop Loading" = "停止加载"; @@ -3737,6 +3750,7 @@ "Stop Microphone" = "停止麦克风"; "Stop Recording" = "停止录制"; "Stop Run" = "停止运行"; +"Stop Server" = "停止服务器"; "Stop Tracking Branch" = "停止跟踪分支"; "Stop active execution" = "停止当前执行"; "Stop all services" = "停止所有服务"; @@ -5370,6 +5384,7 @@ "native.clearDebugOutput" = "清除调试输出"; "native.clearGitConsole" = "清空 Git 控制台"; "native.clearLogSearch" = "清除日志搜索"; +"native.clearLogView" = "清空视图"; "native.clearLogs" = "清空日志"; "native.clearOverride" = "清除覆盖"; "native.clearPathFilter" = "清除路径筛选"; @@ -6730,6 +6745,7 @@ "native.languageServerReturnedAnInvalidTextRange" = "语言服务器返回了无效的文本范围。"; "native.languageServerReturnedOverlappingTextEdits" = "语言服务器返回了重叠的文本编辑。"; "native.languageServerSessionStoppedBeforeTheRequestCompleted" = "语言服务器会话在请求完成前已停止。"; +"native.languageServerSettingsAction" = "语言服务器设置..."; "native.languageServerToolConfigurationChanged" = "语言服务器工具配置已更改"; "native.languageServers" = "语言服务器"; "native.languageService" = "语言服务"; @@ -6943,6 +6959,7 @@ "native.log.variant2" = "日志"; "native.logDirectory" = "日志目录"; "native.logFilter" = "日志筛选"; +"native.logInfoLevel" = "信息"; "native.logLevel" = "日志级别"; "native.logLocations" = "日志位置"; "native.logMessage" = "日志消息"; @@ -6958,6 +6975,12 @@ "native.low" = "低"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。"; "native.lowRecommended" = "低(推荐)"; +"native.lspAvailableLogChannels" = "显示会话事件和服务器输出。暂不支持服务器跟踪和 RPC 消息采集。"; +"native.lspClearViewHint" = "仅清空所选服务器的可见日志,保留已采集的诊断日志。"; +"native.lspLogsTitle" = "LSP 日志"; +"native.lspServerLogs" = "服务器日志"; +"native.lspStatusLabel" = "LSP"; +"native.lspStopOnDemandHint" = "打开、切换到或编辑匹配的文档时将再次启动。"; "native.luaSupport" = "Lua 支持"; "native.macosCouldNotPrepareTheUpdateDiskImageDownloadTheReleaseManuallyAndTryAgain" = "macOS 无法准备更新磁盘映像,请手动下载版本后重试。"; "native.macosCouldNotPrepareTheUpdateDiskImageOpenTheReleasePageAndInstallItManually" = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。"; @@ -7267,6 +7290,7 @@ "native.noMatchingConversations" = "没有匹配的会话"; "native.noMatchingHooks" = "没有匹配的 Hook。"; "native.noMatchingItems" = "没有匹配项"; +"native.noMatchingLanguageServerLogs" = "没有匹配的语言服务器日志"; "native.noMatchingLogs" = "没有匹配的日志"; "native.noMatchingMcpServers" = "没有匹配的 MCP 服务器"; "native.noMatchingMessages" = "没有匹配的消息"; @@ -7990,6 +8014,7 @@ "native.response" = "回复"; "native.responsesAPI" = "Responses API"; "native.restart" = "重新启动"; +"native.restartAllServers" = "重启所有服务器"; "native.restartLanguageServers" = "重启语言服务器"; "native.restartLithe" = "重启 Lithe"; "native.restartLitheToFinishUninstallingThenInstallTheCommunityPluginFromTheMarketplace" = "请重启 Lithe 以完成 %1$@ 的卸载,然后从插件市场安装社区插件 %2$@。"; @@ -7998,6 +8023,7 @@ "native.restartLitheToUseArg1" = "重启 Lithe 后即可使用 %1$@。"; "native.restartRequired" = "需要重启 Lithe"; "native.restartRun" = "重新运行"; +"native.restartServer" = "重启服务器"; "native.restartService" = "重启服务"; "native.restore" = "恢复"; "native.restoreAllDefaults" = "全部恢复默认"; @@ -8508,6 +8534,7 @@ "native.stepOut" = "单步跳出"; "native.stepOver" = "单步跳过"; "native.stopActiveExecution" = "停止当前执行"; +"native.stopAllServers" = "停止所有服务器"; "native.stopAllServices" = "停止所有服务"; "native.stopAndClose" = "停止并关闭"; "native.stopAndQuit" = "停止并退出"; @@ -8524,6 +8551,7 @@ "native.stopRun" = "停止运行"; "native.stopRun.variant2" = "停止运行"; "native.stopSelected" = "停止所选配置"; +"native.stopServer" = "停止服务器"; "native.stopService" = "停止服务"; "native.stopSessionNamed" = "停止 %1$@"; "native.stopTestRun" = "停止测试运行"; diff --git a/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift b/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift index 3ba6e6ce5b..d835c96c36 100644 --- a/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift @@ -6,6 +6,7 @@ import Foundation final class EditorSessionCoordinator { private let document: DocumentFeatureModel private let media: MediaDocumentFeatureModel + private let tabOrder: EditorTabOrderFeatureModel private var observations: [AnyCancellable] init( @@ -16,11 +17,15 @@ final class EditorSessionCoordinator { ) { self.document = document self.media = media + self.tabOrder = tabOrder observations = [ - document.$activeDocumentID.dropFirst().sink { [weak media, weak terminalPlacement] id in - guard id != nil else { return } + document.$activeDocumentID.dropFirst().sink { [weak media, weak terminalPlacement, weak tabOrder] id in + guard let id else { return } terminalPlacement?.activateDocument() media?.deactivate() + // File opens can select directly on DocumentFeatureModel rather + // than through AppModel. A utility tab must not mask that open. + if tabOrder?.focusedItem == .languageLogs { tabOrder?.focus(.document(id)) } }, document.$openDocuments.map { $0.map(\.id) }.removeDuplicates() .sink { [weak tabOrder] ids in @@ -34,6 +39,7 @@ final class EditorSessionCoordinator { } func resetContent() { + tabOrder.remove(.languageLogs) document.reset() media.reset() } diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index bb52448012..8b6e9b2e9e 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -256,7 +256,7 @@ final class EditorTabOrderFeatureModel: ObservableObject { reorderedExistingIDs.indices.contains(existingIndex) else { continue } next.append(.document(reorderedExistingIDs[existingIndex])) existingIndex += 1 - case .terminal, .media, .repositoryDiff, .agentConversation: + case .terminal, .media, .repositoryDiff, .agentConversation, .languageLogs: next.append(item) } } diff --git a/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift b/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift index 9155f417bd..e9302ad6d3 100644 --- a/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift +++ b/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift @@ -10,6 +10,14 @@ enum LSPServerStatus: Equatable, Sendable { case error } +/// A projection of session state, never a second lifecycle owner. +struct LSPStatusBarServer: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let status: LSPServerStatus + let version: String? +} + enum LSPCapabilityPresentationState: Equatable, Sendable { case unknown case unsupported @@ -67,6 +75,35 @@ enum LSPControlCenterPresenter { return version } + static func statusBarServers( + descriptors: [LanguageProviderDescriptor], + states: [String: LanguageServerSessionState], + infos: [String: LanguageServerInfo], + disabledProviderIDs: Set + ) -> [LSPStatusBarServer] { + descriptors.filter { $0.capabilities.contains(.languageServer) }.compactMap { descriptor in + let status = serverStatus( + isDisabled: disabledProviderIDs.contains(descriptor.id), + sessionState: states[descriptor.id] + ) + guard status != .stopped, status != .disabled else { return nil } + return LSPStatusBarServer( + id: descriptor.id, displayName: descriptor.displayName, status: status, + version: status == .active ? reportedServerVersion(infos[descriptor.id]) : nil + ) + }.sorted { + let order = $0.displayName.localizedStandardCompare($1.displayName) + return order == .orderedSame ? $0.id < $1.id : order == .orderedAscending + } + } + + /// A failed server must remain discoverable even when another is healthy. + static func aggregateStatus(_ servers: [LSPStatusBarServer]) -> LSPServerStatus? { + [.error, .starting, .initializing, .stopping, .active].first { status in + servers.contains { $0.status == status } + } + } + static func integrationState( isAvailable: Bool, isActive: Bool = false diff --git a/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift new file mode 100644 index 0000000000..dc134e2ae6 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift @@ -0,0 +1,57 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule + +/// Observes the session's bounded log buffer without relaying every log through AppModel. +@MainActor +final class LanguageServerLogsModel: ObservableObject { + enum MinimumLevel: CaseIterable { + case info, warning, error + + func includes(_ level: LanguageServerLogLevel) -> Bool { + switch self { + case .info: true + case .warning: level != .info + case .error: level == .error + } + } + } + + @Published var providerID = "" + @Published var minimumLevel: MinimumLevel = .info + @Published private(set) var entries: [LanguageServerLogEntry] = [] + @Published private var clearedIDs: Set = [] + private var observation: AnyCancellable? + + var visibleEntries: [LanguageServerLogEntry] { + entries.reversed().filter { + $0.providerID == providerID && minimumLevel.includes($0.level) && !clearedIDs.contains($0.id) + } + } + + func bind(_ sessions: LanguageToolingSessionManager) { + observation = sessions.$languageServerLogs.sink { [weak self] entries in + self?.replace(entries) + } + } + + func replace(_ entries: [LanguageServerLogEntry]) { + // The session retains at most 100 entries. Clearing is a presentation + // watermark, not deletion of persistent diagnostics or another server's logs. + let retainedIDs = Set(entries.map(\.id)) + clearedIDs.formIntersection(retainedIDs) + self.entries = entries + } + + func clear() { + clearedIDs.formUnion(entries.filter { $0.providerID == providerID }.map(\.id)) + } + + func reset() { + providerID = "" + minimumLevel = .info + entries = [] + clearedIDs = [] + } +} diff --git a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index caa9569bff..6910f6ab40 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift @@ -13,8 +13,11 @@ final class LanguageToolingFeatureModel: ObservableObject { @Published private(set) var catalogSnapshot: LanguageProviderCatalogSnapshot @Published private(set) var disabledProviderIDs: Set = [] @Published private(set) var projectPreparation: ProjectPreparationSnapshot? + @Published private(set) var statusBarServers: [LSPStatusBarServer] = [] let projectUsage = LSPProjectUsageModel() + let logs = LanguageServerLogsModel() private var preparationObservation: AnyCancellable? + private var statusObservation: AnyCancellable? private(set) var startupFailures: [String: String] = [:] private let catalogSource: any LanguageProviderCatalogSource @@ -24,6 +27,27 @@ final class LanguageToolingFeatureModel: ObservableObject { private var documentsProvider: (@MainActor () -> [EditorDocument])? private var activateDocument: (@MainActor (EditorDocument) -> Bool)? private var notify: (@MainActor (String) -> Void)? + private var preparationCancellations: [String: @MainActor () -> Void] = [:] + private var activationEpoch = UUID() + private var activationGenerations: [String: UUID] = [:] + + struct ActivationToken: Equatable { + let epoch: UUID + let generation: UUID? + } + + func activationToken(providerID: String) -> ActivationToken { + ActivationToken(epoch: activationEpoch, generation: activationGenerations[providerID]) + } + + func registerPreparationCancellation(providerID: String, cancel: @escaping @MainActor () -> Void) { + preparationCancellations[providerID] = cancel + } + + private func prepareToStop(providerID: String) { + activationGenerations[providerID] = UUID() + preparationCancellations[providerID]?() + } init( catalogSource: any LanguageProviderCatalogSource, @@ -55,12 +79,24 @@ final class LanguageToolingFeatureModel: ObservableObject { } func bindProjectPreparation(_ sessions: LanguageToolingSessionManager) { + logs.bind(sessions) + statusObservation = Publishers.CombineLatest4( + sessions.$languageServerStates, sessions.$languageServerInfos, $catalog, $disabledProviderIDs + ).map { states, infos, catalog, disabled in + LSPControlCenterPresenter.statusBarServers( + descriptors: catalog.descriptors, states: states, infos: infos, disabledProviderIDs: disabled + ) + }.removeDuplicates().sink { [weak self] in self?.statusBarServers = $0 } preparationObservation = sessions.$projectPreparation.removeDuplicates().sink { [weak self] snapshot in self?.projectPreparation = snapshot } } func resetWorkspaceState() { + activationEpoch = UUID() + activationGenerations.removeAll() + logs.reset() + statusBarServers = [] projectPreparation = nil startupFailures.removeAll() } @@ -127,6 +163,7 @@ final class LanguageToolingFeatureModel: ObservableObject { if enabled { synchronizeOpenDocuments(providerID: providerID) } else { + prepareToStop(providerID: providerID) sessionsProvider()?.recordLanguageServerLog( providerID: providerID, level: .warning, @@ -138,6 +175,7 @@ final class LanguageToolingFeatureModel: ObservableObject { } func toolConfigurationDidChange(providerID: String) { + prepareToStop(providerID: providerID) startupFailures[providerID] = nil sessionsProvider()?.stopLanguageServer(providerID: providerID) sessionsProvider()?.recordLanguageServerLog( @@ -148,6 +186,30 @@ final class LanguageToolingFeatureModel: ObservableObject { ) } + /// Session-only controls leave the workspace's persistent enablement alone. + func stop(providerID: String) { + prepareToStop(providerID: providerID) + sessionsProvider()?.stopLanguageServer(providerID: providerID) + } + + func restart(providerID: String) { + guard !isDisabled(providerID), let workspaceURL, + let descriptor = catalog.descriptors.first(where: { + $0.id == providerID && $0.capabilities.contains(.languageServer) + }), let sessions = sessionsProvider() else { return } + stop(providerID: providerID) + startupFailures[providerID] = nil + let documents = (documentsProvider?() ?? []).filter { catalog.provider(for: $0.url)?.id == providerID } + if documents.isEmpty { + // A prewarmed server can outlive its last editor tab. Restart that + // same provider instead of silently turning Restart into Stop. + do { try sessions.startLanguageServer(providerID: providerID, rootURL: workspaceURL) } + catch { markActivationFailed(providerID: providerID, descriptor: descriptor, error: error) } + } else { + for document in documents { _ = activateDocument?(document) } + } + } + func markActivationSucceeded(providerID: String) { startupFailures[providerID] = nil } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index d84a8a64e8..0804f28fb3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -151,6 +151,7 @@ extension AppModel { func selectEditorTab(_ item: EditorTabItem) { editorTabOrderFeature.focus(item) switch item { + case .languageLogs: selectLanguageServerLogsTab() case .agentConversation(let tab): showAgentChatsInEditor(tab: tab) case .repositoryDiff(let id): selectRepositoryDiffTab(id) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index e73638c5c4..becf89eadf 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -24,10 +24,6 @@ extension AppModel { } func setLanguageServerEnabled(_ enabled: Bool, providerID: String) { - if providerID == "java", !enabled { - javaLanguageServerPreparationCoordinator.cancelMavenConfigurationSynchronization() - cancelJavaLanguageServerPreparation() - } languageToolingFeature.setEnabled(enabled, providerID: providerID) if !enabled { runFeatureIfActive?.unregisterDependencySource(languageID: providerID) @@ -44,6 +40,193 @@ extension AppModel { setLanguageServerEnabled(false, providerID: providerID) } + var statusBarLanguageServers: [LSPStatusBarServer] { + languageToolingFeature.statusBarServers + } + + func showLanguageServerLogs(providerID: String) { + languageToolingFeature.logs.providerID = providerID + if !editorTabOrderFeature.contains(.languageLogs) { editorTabOrderFeature.moveToEnd(.languageLogs) } + selectLanguageServerLogsTab() + } + + func selectLanguageServerLogsTab() { + guard editorTabOrderFeature.contains(.languageLogs) else { return } + terminalPlacementFeature.activateDocument() + mediaFeature.deactivate() + editorTabOrderFeature.repositoryDiffSelected = false + activeDocumentID = nil + editorNavigationTarget = nil + editorTabOrderFeature.focus(.languageLogs) + } + + func closeLanguageServerLogsTab() { + let order = editorTabOrderFeature + let wasFocused = order.focusedItem == .languageLogs + order.remove(.languageLogs) + if wasFocused, let fallback = order.focusedItem ?? order.items.last { selectEditorTab(fallback) } + } + + @discardableResult + func activateCurrentDocumentLanguageServerIfAvailable() -> Bool { + guard let activeDocument else { return false } + return activateLanguageServerIfAvailable(for: activeDocument) + } + + @discardableResult + func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { + guard let identity = currentWorkspaceIdentity, + let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } + let workspaceURL = identity.url + let activationToken = languageToolingFeature.activationToken(providerID: descriptor.id) + guard !languageToolingFeature.isDisabled(descriptor.id) else { + languageToolingSessionsIfActive?.recordLanguageServerLog( + providerID: descriptor.id, + level: .info, + message: L10n.string("Language server activation skipped"), + detail: L10n.string("Disabled in this workspace") + ) + return false + } + if isModuleRuntimeShuttingDown { + Task { @MainActor [weak self, weak document] in + guard let self else { return } + await self.awaitModuleRuntimeShutdown() + guard let document, !Task.isCancelled, self.isCurrentWorkspace(identity), + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } + return false + } + if descriptor.id == "java" { + switch prepareJavaLanguageServerRuntimeIfNeeded(for: document) { + case .ready: break + case .preparing: return false + } + } + if let ownership = services.pluginCatalog.languageSupport(for: document.url), + let moduleID = ownership.declaration.languageServerModuleID { + let support = ownership.declaration + // Static plugin metadata may exist before a native plugin is loaded. + // Only a registered and enabled module may enter LSP activation. + guard let snapshot = try? services.moduleRuntime.snapshot(for: moduleID), + snapshot.state != .disabled else { + return false + } + let capabilityID = ModuleCapabilityID.languageServerExtension(support.id) + if services.moduleRuntime.capability(capabilityID) == nil { + Task { [weak self, weak document] in + guard let self, let document else { return } + do { + await self.awaitModuleRuntimeShutdown() + guard !Task.isCancelled, self.isCurrentWorkspace(identity), + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = try await self.services.moduleRuntime.activateCapability(capabilityID) + guard self.isCurrentWorkspace(identity), !Task.isCancelled, + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } catch { + // Only an activation that is still wanted may report a failure. + guard self.isCurrentWorkspace(identity), + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + self.languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: error + ) + } + } + return false + } + if let provider = services.moduleRuntime.capability(capabilityID) + as? any LanguageServerExtensionProviding, + provider.lifecycle.isRetired { + // The module is being stopped; its capability is still + // published until the stop completes. + return false + } + if let provider = services.moduleRuntime.capability(capabilityID) + as? any LanguageServerExtensionProviding, + let sessions = languageToolingSessionsIfActive, + !sessions.registerLanguageServerExtension(provider, support: support) { + languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: LanguageExtensionRegistrationError.invalidLanguageServerProvider( + support.displayName + ) + ) + return false + } + // A plugin enabled after launch registers its server after the + // startup probe ran, so probe it now instead of reporting the + // executable as missing before any check happened. + if let sessions = languageToolingSessionsIfActive, + let tools = languageServerToolsIfActive, + let registered = sessions.languageServerExtensionDescriptor(languageID: support.id), + tools.needsCandidateValidation(for: registered) { + Task { [weak self, weak document] in + await tools.refreshCandidates(for: registered) + guard let self, let document, self.isCurrentWorkspace(identity), !Task.isCancelled, + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } + return false + } + registerLanguageDependencySourceIfAvailable(support: support) + } + if let snapshot = try? services.moduleRuntime.snapshot(for: .languageIntelligence), + snapshot.state != .active, + snapshot.state != .idle { + Task { [weak self] in + guard let self else { return } + do { + _ = try await self.languageIntelligenceModuleCoordinator.activate() + guard self.isCurrentWorkspace(identity), !Task.isCancelled, + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } catch { + guard self.isCurrentWorkspace(identity), + self.languageToolingFeature.activationToken(providerID: descriptor.id) == activationToken, + self.openDocuments.contains(where: { $0 === document }) else { return } + self.languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: error + ) + } + } + return false + } + do { + guard let languageToolingSessions = languageToolingSessionsIfActive else { return false } + try languageToolingSessions.synchronizeLanguageServer( + for: document.url, + text: document.text, + rootURL: workspaceURL, + changes: document.takePendingLanguageServerChanges() + ) + languageToolingFeature.markActivationSucceeded(providerID: descriptor.id) + if let moduleID = services.pluginCatalog.languageSupport(for: document.url)? + .declaration.languageServerModuleID { + // A successful sync is the plugin LSP's latest activity. The + // idle policy can stop it after the user leaves the document + // untouched, while subsequent edits refresh this timestamp. + try? services.moduleRuntime.markIdle(moduleID) + } + return languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) + } catch { + languageToolingFeature.markActivationFailed(providerID: descriptor.id, descriptor: descriptor, error: error) + return false + } + } + func prepareJavaLanguageServerRuntimeIfNeeded( for document: EditorDocument ) -> JavaLanguageServerActivationReadiness { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift index 8cfc83b6b1..9b61feb31d 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift @@ -54,6 +54,7 @@ extension AppModel { activateWhenReady: activateWhenReady ) editorTabOrderFeature.moveToEnd(.media(media.id)) + if activateWhenReady { editorTabOrderFeature.focus(.media(media.id)) } } func selectMediaDocument(_ media: MediaDocument) { @@ -87,6 +88,7 @@ extension AppModel { guard wasActive else { return } switch fallbackItem { + case .languageLogs: selectLanguageServerLogsTab() case .agentConversation(let tab): showAgentChatsInEditor(tab: tab) case .repositoryDiff(let id): selectRepositoryDiffTab(id) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 6235423301..e3618035fc 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -279,6 +279,7 @@ extension AppModel { editorTabOrderFeature.repositoryDiffSelected = false terminalPlacementFeature.moveToEditor(sessionID) editorTabOrderFeature.moveToEnd(.terminal(sessionID)) + editorTabOrderFeature.focus(.terminal(sessionID)) terminalPlacementFeature.reorderEditorSessions( orderedIDs: editorTabOrderFeature.terminalIDs ) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift index 6f7f3337ca..fc301449ce 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift @@ -16,6 +16,10 @@ extension AppModel { closeLanguageNavigationResults() return true } + if editorTabOrderFeature.focusedItem == .languageLogs { + closeLanguageServerLogsTab() + return true + } if let tab = focusedAgentEditorTab { closeAgentEditorTab(tab) return true @@ -38,6 +42,7 @@ extension AppModel { } guard let item = editorTabItems.last else { return false } switch item { + case .languageLogs: closeLanguageServerLogsTab() case .agentConversation(let tab): closeAgentEditorTab(tab) case .repositoryDiff(let id): closeRepositoryDiffTab(id) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3c342d6ef9..a35e667164 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -160,6 +160,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { private var documentLanguageCoordinator: DocumentLanguageCoordinator? private var sidebarRefreshTask: Task? private var moduleSessionCoordinator: ModuleSessionCoordinator? + var isModuleRuntimeShuttingDown: Bool { moduleSessionCoordinator?.isShuttingDown == true } private(set) var isProjectSessionClosed = false private var featureObservationBinder: AppModelObservationBinder? private var fileVisibilityRulesObserverID: UUID? @@ -549,6 +550,10 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { languageToolingFeature.configureSessions { [weak self] in self?.languageToolingSessionsIfActive } + languageToolingFeature.registerPreparationCancellation(providerID: "java") { [weak self] in + self?.javaLanguageServerPreparationCoordinator.cancelMavenConfigurationSynchronization() + self?.cancelJavaLanguageServerPreparation() + } services.connectBuildProviderLanguageSessions? { [weak self] in self?.languageToolingSessionsIfActive } @@ -1377,158 +1382,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { documentFeature.documentDidChange(document) } - @discardableResult - func activateCurrentDocumentLanguageServerIfAvailable() -> Bool { - guard let activeDocument else { return false } - return activateLanguageServerIfAvailable(for: activeDocument) - } - - @discardableResult - func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { - guard let identity = currentWorkspaceIdentity, - let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } - let workspaceURL = identity.url - guard !languageToolingFeature.isDisabled(descriptor.id) else { - languageToolingSessionsIfActive?.recordLanguageServerLog( - providerID: descriptor.id, - level: .info, - message: L10n.string("Language server activation skipped"), - detail: L10n.string("Disabled in this workspace") - ) - return false - } - if moduleSessionCoordinator?.isShuttingDown == true { - Task { @MainActor [weak self, weak document] in - guard let self else { return } - await self.awaitModuleRuntimeShutdown() - guard let document, !Task.isCancelled, self.isCurrentWorkspace(identity), - self.openDocuments.contains(where: { $0 === document }) else { return } - _ = self.activateLanguageServerIfAvailable(for: document) - } - return false - } - if descriptor.id == "java" { - switch prepareJavaLanguageServerRuntimeIfNeeded(for: document) { - case .ready: break - case .preparing: return false - } - } - if let ownership = services.pluginCatalog.languageSupport(for: document.url), - let moduleID = ownership.declaration.languageServerModuleID { - let support = ownership.declaration - // Static plugin metadata may exist before a native plugin is loaded. - // Only a registered and enabled module may enter LSP activation. - guard let snapshot = try? services.moduleRuntime.snapshot(for: moduleID), - snapshot.state != .disabled else { - return false - } - let capabilityID = ModuleCapabilityID.languageServerExtension(support.id) - if services.moduleRuntime.capability(capabilityID) == nil { - Task { [weak self, weak document] in - guard let self, let document else { return } - do { - await self.awaitModuleRuntimeShutdown() - guard !Task.isCancelled, self.isCurrentWorkspace(identity), - self.openDocuments.contains(where: { $0 === document }) else { return } - _ = try await self.services.moduleRuntime.activateCapability(capabilityID) - guard self.isCurrentWorkspace(identity), !Task.isCancelled, - self.openDocuments.contains(where: { $0 === document }) else { return } - _ = self.activateLanguageServerIfAvailable(for: document) - } catch { - // Only an activation that is still wanted may report a failure. - guard self.isCurrentWorkspace(identity), - self.openDocuments.contains(where: { $0 === document }) else { return } - self.languageToolingFeature.markActivationFailed( - providerID: descriptor.id, - descriptor: descriptor, - error: error - ) - } - } - return false - } - if let provider = services.moduleRuntime.capability(capabilityID) - as? any LanguageServerExtensionProviding, - provider.lifecycle.isRetired { - // The module is being stopped; its capability is still - // published until the stop completes. - return false - } - if let provider = services.moduleRuntime.capability(capabilityID) - as? any LanguageServerExtensionProviding, - let sessions = languageToolingSessionsIfActive, - !sessions.registerLanguageServerExtension(provider, support: support) { - languageToolingFeature.markActivationFailed( - providerID: descriptor.id, - descriptor: descriptor, - error: LanguageExtensionRegistrationError.invalidLanguageServerProvider( - support.displayName - ) - ) - return false - } - // A plugin enabled after launch registers its server after the - // startup probe ran, so probe it now instead of reporting the - // executable as missing before any check happened. - if let sessions = languageToolingSessionsIfActive, - let tools = languageServerToolsIfActive, - let registered = sessions.languageServerExtensionDescriptor(languageID: support.id), - tools.needsCandidateValidation(for: registered) { - Task { [weak self, weak document] in - await tools.refreshCandidates(for: registered) - guard let self, let document, self.isCurrentWorkspace(identity), !Task.isCancelled, - self.openDocuments.contains(where: { $0 === document }) else { return } - _ = self.activateLanguageServerIfAvailable(for: document) - } - return false - } - registerLanguageDependencySourceIfAvailable(support: support) - } - if let snapshot = try? services.moduleRuntime.snapshot(for: .languageIntelligence), - snapshot.state != .active, - snapshot.state != .idle { - Task { [weak self] in - guard let self else { return } - do { - _ = try await self.languageIntelligenceModuleCoordinator.activate() - guard self.isCurrentWorkspace(identity), !Task.isCancelled, - self.openDocuments.contains(where: { $0 === document }) else { return } - _ = self.activateLanguageServerIfAvailable(for: document) - } catch { - guard self.isCurrentWorkspace(identity), - self.openDocuments.contains(where: { $0 === document }) else { return } - self.languageToolingFeature.markActivationFailed( - providerID: descriptor.id, - descriptor: descriptor, - error: error - ) - } - } - return false - } - do { - guard let languageToolingSessions = languageToolingSessionsIfActive else { return false } - try languageToolingSessions.synchronizeLanguageServer( - for: document.url, - text: document.text, - rootURL: workspaceURL, - changes: document.takePendingLanguageServerChanges() - ) - languageToolingFeature.markActivationSucceeded(providerID: descriptor.id) - if let moduleID = services.pluginCatalog.languageSupport(for: document.url)? - .declaration.languageServerModuleID { - // A successful sync is the plugin LSP's latest activity. The - // idle policy can stop it after the user leaves the document - // untouched, while subsequent edits refresh this timestamp. - try? services.moduleRuntime.markIdle(moduleID) - } - return languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) - } catch { - languageToolingFeature.markActivationFailed(providerID: descriptor.id, descriptor: descriptor, error: error) - return false - } - } - func commitStagedChanges() async { await commitWorkflow.commit() } diff --git a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift index e5ed6e9976..2f10c54099 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift @@ -6,6 +6,7 @@ enum EditorTabItem: Hashable, Identifiable { case terminal(UUID) case media(UUID) case repositoryDiff(UUID) + case languageLogs /// One Agent conversation tab. The UUID names the tab, not the session: a new /// tab exists before the Agent has created its session. case agentConversation(UUID) diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 2eef418500..bee37358fd 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -178,6 +178,7 @@ struct EditorAreaView: View { private var activeItem: EditorTabItem? { + if model.editorTabOrderFeature.focusedItem == .languageLogs { return .languageLogs } if let tab = model.focusedAgentEditorTab { return .agentConversation(tab) } if let id = model.selectedRepositoryDiffID { return .repositoryDiff(id) } return model.activeEditorTerminalSession.map { .terminal($0.id) } @@ -209,6 +210,10 @@ struct EditorAreaView: View { } private var paneDiffSelected: Bool { paneDiffID != nil } private func focusRepositoryDiffPaneIfNeeded() { + if groupID != nil, paneItem == .languageLogs, model.editorTabOrderFeature.focusedItem != .languageLogs { + model.selectLanguageServerLogsTab() + return + } guard groupID != nil, let id = paneDiffID, model.selectedRepositoryDiffID != id else { return } model.selectRepositoryDiffTab(id) } @@ -341,6 +346,8 @@ struct EditorAreaView: View { ForEach(displayedTabItems) { item in Group { switch item { + case .languageLogs: + languageServerLogsTab case .agentConversation(let tab): agentConversationTab(tab) case .repositoryDiff(let id): @@ -412,6 +419,28 @@ struct EditorAreaView: View { .zIndex(tabDragState.draggedItem == item ? 1 : 0) } + private var languageServerLogsTab: some View { + let item = EditorTabItem.languageLogs + let selected = paneItem == item + return HStack(spacing: 0) { + Text("LSP Logs") + .font(LitheTheme.uiFont(size: 13)) + .padding(.leading, 8).frame(height: 28).contentShape(Rectangle()) + .onTapGesture { model.selectEditorTab(item) } + .accessibilityAddTraits(.isButton) + .accessibilityAction { model.selectEditorTab(item) } + LitheToolWindowTabCloseButton { model.closeLanguageServerLogsTab() } + .opacity(selected || hoveredTabItem == item ? 1 : 0) + .allowsHitTesting(selected || hoveredTabItem == item) + } + .foregroundStyle(selected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .modifier(LitheToolWindowTabStyle(isSelected: selected, isActive: editorGroupIsActive)) + .padding(.horizontal, 4).padding(.vertical, 4) + .onHover { updateHoveredTab(item, isHovering: $0) } + .background { editorTabFrameReader(for: item) } + .zIndex(tabDragState.draggedItem == item ? 1 : 0) + } + private func repositoryDiffTab(_ id: UUID) -> some View { let item = EditorTabItem.repositoryDiff(id) let selected = paneDiffID == id @@ -759,6 +788,7 @@ struct EditorAreaView: View { private func closeEditorTab(_ item: EditorTabItem) { switch item { + case .languageLogs: model.closeLanguageServerLogsTab() case .agentConversation(let tab): model.closeAgentEditorTab(tab) case .repositoryDiff(let id): model.closeRepositoryDiffTab(id) @@ -802,6 +832,7 @@ struct EditorAreaView: View { @ViewBuilder private func tabDragLabel(_ item: EditorTabItem) -> some View { switch item { + case .languageLogs: Text("LSP Logs") case .agentConversation(let tab): AgentEditorTabTitle(model: model, tab: tab) case .document(let id): if let document = model.openDocuments.first(where: { $0.id == id }) { @@ -1135,7 +1166,9 @@ struct EditorAreaView: View { .allowsHitTesting(paneDiffID == id) .accessibilityHidden(paneDiffID != id) } - if case .agentConversation(let tab) = paneItem { + if paneItem == .languageLogs { + LanguageServerLogsView(feature: model.languageToolingFeature.logs) + } else if case .agentConversation(let tab) = paneItem { if tab == model.focusedAgentEditorTab { AgentConversationView(model: model, isEditor: true) } else { diff --git a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift index 2f53c9343c..90196bd43b 100644 --- a/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -107,7 +107,7 @@ struct LSPControlCenterContent: View { return VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { Circle() - .fill(statusColor(status)) + .fill(status.indicatorColor) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 3) { Text(descriptor.displayName) @@ -307,8 +307,11 @@ struct LSPControlCenterContent: View { } } - private func statusColor(_ status: LSPServerStatus) -> Color { - switch status { +} + +extension LSPServerStatus { + var indicatorColor: Color { + switch self { case .starting, .initializing: LitheTheme.accent case .active: LitheTheme.success case .stopping: LitheTheme.warning diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift new file mode 100644 index 0000000000..e703a46525 --- /dev/null +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift @@ -0,0 +1,100 @@ +import LitheModuleAPI +import SwiftUI + +struct LanguageServerLogsView: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var feature: LanguageServerLogsModel + + private var providerIDs: [String] { + Set(feature.entries.map(\.providerID) + model.statusBarLanguageServers.map(\.id) + + (feature.providerID.isEmpty ? [] : [feature.providerID])).sorted() + } + + var body: some View { + VStack(spacing: 0) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + serverPicker + logChannel + levelPicker + Spacer(minLength: 0) + clearButton + } + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { serverPicker; Spacer(minLength: 0); clearButton } + HStack(spacing: 8) { logChannel; Spacer(minLength: 0); levelPicker } + } + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + OutputTextView( + output: logOutput, + searchRoots: [], + fileExists: { _ in false }, + emptyMessage: L10n.string("No matching language server logs"), + fontSize: 12, + onOpenLocation: { _, _, _ in } + ) + // Replacing a filtered buffer is a new viewport, not an append to + // another server's output. Native selection/scroll stays view-local. + .id(feature.providerID + ":" + String(describing: feature.minimumLevel)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .background(LitheTheme.editor) + .onAppear { selectInitialProvider() } + .onChange(of: providerIDs) { _ in selectInitialProvider() } + } + + private var serverPicker: some View { + LitheSettingsSelect(selection: $feature.providerID, options: providerIDs, width: 180, + accessibilityLabel: "Language Server", title: providerName, localizesTitles: false) + } + + private var levelPicker: some View { + LitheSettingsSelect(selection: $feature.minimumLevel, + options: LanguageServerLogsModel.MinimumLevel.allCases, width: 120, + accessibilityLabel: "Minimum level", title: levelTitle) + } + + private var logChannel: some View { + HStack(spacing: 4) { + Text("Server Logs").foregroundStyle(LitheTheme.accent) + LitheInfoTip("Shows session events and server output. Server Trace and RPC message capture are not available.") + } + } + + private var clearButton: some View { + Button("Clear View") { feature.clear() } + .buttonStyle(.litheNoPress) + .help("Clear only the selected server's visible logs. Collected diagnostic logs are kept.") + } + + private func selectInitialProvider() { + if feature.providerID.isEmpty, let first = providerIDs.first { feature.providerID = first } + } + + private func providerName(_ id: String) -> String { + model.languageProviderCatalog.descriptors.first { $0.id == id }?.displayName ?? id + } + + private func levelTitle(_ level: LanguageServerLogsModel.MinimumLevel) -> String { + switch level { + case .info: "Info" + case .warning: "Warning" + case .error: "Error" + } + } + + private var logOutput: String { + feature.visibleEntries.map { entry in + let timestamp = entry.timestamp.formatted(date: .omitted, time: .standard) + // These are structured log identifiers. The shared console colors + // their prefixes; upstream messages and details remain unchanged. + let level = entry.level == .warning ? "WARN" : entry.level.rawValue.uppercased() + let detail = entry.detail.map { "\n" + $0 } ?? "" + return "[\(level)] \(timestamp) \(entry.message)\(detail)\n" + } + .joined() + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift new file mode 100644 index 0000000000..d6d033e5fd --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift @@ -0,0 +1,98 @@ +import LitheModuleAPI +import SwiftUI + +struct LanguageServerStatusView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + let servers = model.statusBarLanguageServers + if let status = LSPControlCenterPresenter.aggregateStatus(servers) { + LitheMenu(opensUpward: true) { + LanguageServerStatusMenu.items( + servers: servers, + viewLogs: model.showLanguageServerLogs, + restart: model.languageToolingFeature.restart, + stop: model.languageToolingFeature.stop, + settings: { model.showSettings(category: .lsp) } + ) + } label: { + HStack(spacing: 5) { + Circle().fill(status.indicatorColor).frame(width: 7, height: 7) + Text("LSP") + if servers.count > 1 { Text("\(servers.count)").monospacedDigit() } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Language Services")) + .accessibilityValue(Text(servers.map { "\($0.displayName): \($0.status.localizedTitle)" }.joined(separator: ", "))) + } + .buttonStyle(.litheNoPress) + .fixedSize() + .help("Language Services") + } + } +} + +@MainActor +enum LanguageServerStatusMenu { + private static var restartIcon: AnyView { + AnyView(LitheIDEAIcon(resourcePath: "debugger/rerun.svg", size: 16, preservesOriginalColors: true)) + } + + private static var stopIcon: AnyView { + AnyView(LitheIDEAIcon(resourcePath: "expui/run/stop.svg", size: 16, preservesOriginalColors: true)) + } + + static func items( + servers: [LSPStatusBarServer], + viewLogs: @escaping (String) -> Void, + restart: @escaping (String) -> Void, + stop: @escaping (String) -> Void, + settings: @escaping () -> Void + ) -> [LitheContextMenuItem] { + [.heading("Language Services")] + + servers.map { serverItem($0, viewLogs: viewLogs, restart: restart, stop: stop) } + + [ + .separator, + .action("Restart All Servers", icon: restartIcon) { for server in servers { restart(server.id) } }, + .action("Stop All Servers", icon: stopIcon) { for server in servers { stop(server.id) } }, + .separator, + .action("Language Server Settings...", icon: AnyView( + LitheIDEAIcon(resourcePath: "expui/general/settings.svg", size: 16, preservesOriginalColors: true) + ), action: settings) + ] + } + + private static func serverItem( + _ server: LSPStatusBarServer, + viewLogs: @escaping (String) -> Void, + restart: @escaping (String) -> Void, + stop: @escaping (String) -> Void + ) -> LitheContextMenuItem { + var item = LitheContextMenuItem.submenu(server.displayName, icon: AnyView( + Circle().fill(server.status.indicatorColor).frame(width: 8, height: 8) + )) { + LitheContextMenuItem.action("View logs") { viewLogs(server.id) } + LitheContextMenuItem.action("Restart Server", icon: restartIcon) { restart(server.id) } + LitheContextMenuItem.action("Stop Server", description: L10n.string("Starts again when a matching document is opened or edited."), icon: stopIcon) { + stop(server.id) + } + } + item.localizesTitle = false + item.description = [server.status.localizedTitle, server.version].compactMap { $0 }.joined(separator: " · ") + return item + } +} + +extension LSPServerStatus { + var localizedTitle: String { + switch self { + case .starting: L10n.string("Starting") + case .initializing: L10n.string("Initializing project index") + case .active: L10n.string("Running") + case .stopping: L10n.string("Stopping") + case .stopped: L10n.string("Starts on demand when a matching file is opened") + case .disabled: L10n.string("Off") + case .error: L10n.string(key: "native.inline.failedToStartCheckTheLanguageServerConfiguration") + } + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 8c1777af1a..3a92f74f31 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1831,6 +1831,7 @@ struct WorkbenchView: View { model.activeDocument?.isReadOnly == true ? "Read-only document" : "Save" )) ProjectPreparationStatusView(compact: true) + LanguageServerStatusView() MemoryUsageStatusView() FrameRateStatusView() gitStatus @@ -1841,6 +1842,7 @@ struct WorkbenchView: View { HStack(spacing: 10) { EditorCaretPositionLabel(chrome: model.editorChrome) { model.showGoToLine() } ProjectPreparationStatusView(compact: true) + LanguageServerStatusView() MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Tests/LitheTests/LanguageServerStatusTests.swift b/macos/Tests/LitheTests/LanguageServerStatusTests.swift new file mode 100644 index 0000000000..52f73fbc90 --- /dev/null +++ b/macos/Tests/LitheTests/LanguageServerStatusTests.swift @@ -0,0 +1,328 @@ +import Combine +import AppKit +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing +import SwiftUI +@testable import Lithe + +@Suite("Language server status and logs") +@MainActor +struct LanguageServerStatusTests { + @Test func statusListHidesDormantAndDisabledProvidersAndKeepsFailures() { + let descriptors = [descriptor("z", "Same"), descriptor("a", "Same"), descriptor("b", "Alpha"), + descriptor("d", "Disabled"), descriptor("s", "Stopped"), descriptor("n", "Never started")] + let servers = LSPControlCenterPresenter.statusBarServers( + descriptors: descriptors, + states: ["z": .ready, "a": .initializing, "b": .failed(.init(exitCode: 1, message: "failure")), + "d": .ready, "s": .stopped], + infos: ["z": .init(name: "server", version: " 1.2 "), "a": .init(name: "old", version: "stale")], + disabledProviderIDs: ["d"] + ) + #expect(servers.map(\.id) == ["b", "a", "z"]) + #expect(servers.map(\.version) == [nil, nil, "1.2"]) + #expect(LSPControlCenterPresenter.aggregateStatus(servers) == .error) + #expect(LSPControlCenterPresenter.aggregateStatus([]) == nil) + #expect(LSPControlCenterPresenter.aggregateStatus(Array(servers.dropFirst())) == .initializing) + } + + @Test func statusProjectionPublishesOnlyWhenSessionChromeChanges() throws { + let harness = Harness() + defer { harness.sessions.stopAllLanguageServers() } + harness.feature.bindProjectPreparation(harness.sessions) + var changes = 0 + let observation = harness.feature.$statusBarServers.dropFirst().sink { _ in changes += 1 } + defer { observation.cancel() } + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + #expect(harness.feature.statusBarServers.first?.status == .active) + let beforeLog = changes + harness.sessions.recordLanguageServerLog(providerID: "example", level: .info, message: "server output") + #expect(changes == beforeLog) + harness.feature.stop(providerID: "example") + #expect(harness.feature.statusBarServers.isEmpty) + } + + @Test func restartStopsBeforeReopeningOnlyMatchingDocumentsAndPreservesPreferences() throws { + let harness = Harness() + defer { harness.sessions.stopAllLanguageServers() } + let matching = EditorDocument(url: harness.workspace.appendingPathComponent("a.example"), text: "", modificationDate: nil) + let other = EditorDocument(url: harness.workspace.appendingPathComponent("README.txt"), text: "", modificationDate: nil) + var activated: [URL] = [] + harness.feature.configure(documentsProvider: { [other, matching] }, activateDocument: { document in + #expect(harness.session.stopCount == 1) + activated.append(document.url) + return true + }, notify: { _ in }) + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + let preferences = harness.store.values + let token = harness.feature.activationToken(providerID: "example") + harness.feature.restart(providerID: "example") + #expect(activated == [matching.url]) + #expect(harness.feature.activationToken(providerID: "example") != token) + #expect(harness.store.values.keys.sorted() == preferences.keys.sorted()) + #expect(!harness.feature.isDisabled("example")) + } + + @Test func restartWithoutDocumentsRestartsTheSameProviderAndDisabledRestartDoesNothing() throws { + let harness = Harness() + defer { harness.sessions.stopAllLanguageServers() } + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + harness.feature.restart(providerID: "example") + #expect(harness.session.startCount == 2) + #expect(harness.session.stopCount == 1) + harness.feature.setEnabled(false, providerID: "example") + harness.feature.restart(providerID: "example") + #expect(harness.session.startCount == 2) + #expect(harness.feature.isDisabled("example")) + } + + @Test func stopInvalidatesPendingActivationAndCallsRegisteredPreparationOwner() { + let harness = Harness() + var cancelled = 0 + harness.feature.registerPreparationCancellation(providerID: "example") { cancelled += 1 } + let old = harness.feature.activationToken(providerID: "example") + let other = harness.feature.activationToken(providerID: "other") + harness.feature.stop(providerID: "example") + #expect(cancelled == 1) + #expect(harness.feature.activationToken(providerID: "example") != old) + #expect(harness.feature.activationToken(providerID: "other") == other) + harness.feature.resetWorkspaceState() + #expect(harness.feature.activationToken(providerID: "other") != other) + } + + @Test func logFiltersAndClearArePerProviderAndNewEntriesRemainVisible() { + let logs = LanguageServerLogsModel() + let first = LanguageServerLogEntry(providerID: "a", level: .info, message: "one") + let second = LanguageServerLogEntry(providerID: "b", level: .warning, message: "two") + let third = LanguageServerLogEntry(providerID: "a", level: .error, message: "three") + logs.replace([third, second, first]) + logs.providerID = "a" + #expect(logs.visibleEntries.map(\.id) == [first.id, third.id]) + logs.minimumLevel = .warning + #expect(logs.visibleEntries.map(\.id) == [third.id]) + logs.clear() + logs.minimumLevel = .info + #expect(logs.visibleEntries.isEmpty) + logs.providerID = "b" + #expect(logs.visibleEntries.map(\.id) == [second.id]) + let newer = LanguageServerLogEntry(providerID: "a", level: .warning, message: "new") + logs.replace([newer, third, second, first]) + logs.providerID = "a" + #expect(logs.visibleEntries.map(\.id) == [newer.id]) + logs.reset() + #expect(logs.entries.isEmpty) + #expect(logs.providerID.isEmpty) + } + + @Test func logsTabReusesSelectionAndClosesBackToTheUnsavedDocument() async throws { + let store = StatusTestStore() + let settings = AppSettings(store: store) + let container = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode) + defer { container.moduleLifecycleCoordinator.stop() } + let model = AppModel(settings: settings, services: container.services) + try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in + model.documentFeature.openVirtualDocument(URL(string: "lithe-test://lsp/source.txt")!, text: "unsaved", displayPath: nil) + let document = try #require(model.activeDocument) + let file = EditorTabItem.document(document.id) + model.showLanguageServerLogs(providerID: "a") + #expect(model.editorTabItems == [file, .languageLogs]) + #expect(model.editorTabOrderFeature.focusedItem == .languageLogs) + #expect(model.activeDocument == nil) + model.showLanguageServerLogs(providerID: "b") + #expect(model.editorTabItems == [file, .languageLogs]) + #expect(model.languageToolingFeature.logs.providerID == "b") + model.selectEditorTab(file) + #expect(model.activeDocument === document) + model.selectEditorTab(.languageLogs) + model.documentFeature.openVirtualDocument(URL(string: "lithe-test://lsp/another.txt")!, text: "second", displayPath: nil) + #expect(model.editorTabOrderFeature.focusedItem != .languageLogs) + model.selectEditorTab(.languageLogs) + #expect(model.requestCloseActiveWorkbenchItem()) + #expect(!model.editorTabItems.contains(.languageLogs)) + #expect(model.activeDocument != nil) + #expect(document.text == "unsaved") + model.showLanguageServerLogs(providerID: "a") + model.openMediaFile(URL(fileURLWithPath: "/example/image.png"), kind: .image) + let media = try #require(model.activeMediaDocument) + #expect(model.editorTabOrderFeature.focusedItem == .media(media.id)) + model.closeMediaDocument(media) + model.showLanguageServerLogs(providerID: "a") + model.featureGraph.editorSession.resetContent() + #expect(!model.editorTabItems.contains(.languageLogs)) + } + } + + @Test func logsTabUsesSharedReorderingAndSplitGroupLifecycle() throws { + let order = EditorTabOrderFeatureModel() + let file = EditorTabItem.document(UUID()) + order.moveToEnd(file) + order.moveToEnd(.languageLogs) + order.focus(.languageLogs) + let group = try #require(order.groupLayout.groups.first) + #expect(order.drop(.languageLogs, into: group, side: .right)) + #expect(order.groupLayout.groups.count == 2) + order.reconcileDocuments(orderedIDs: [order.documentIDs[0]]) + #expect(order.contains(.languageLogs)) + order.remove(.languageLogs) + #expect(order.groupLayout.groups.count == 1) + #expect(order.items == [file]) + } + + @Test func menuActionsTargetTheirServerAndBulkActionsUseTheSnapshot() throws { + let servers = ["a", "b"].map { LSPStatusBarServer(id: $0, displayName: $0, status: .active, version: "1") } + var viewed: [String] = [], restarted: [String] = [], stopped: [String] = [] + var openedSettings = false + let items = LanguageServerStatusMenu.items(servers: servers, + viewLogs: { viewed.append($0) }, restart: { restarted.append($0) }, stop: { stopped.append($0) }, + settings: { openedSettings = true }) + let server = try #require(items.first { $0.title == "b" }) + #expect(!server.localizesTitle) + #expect(server.icon != nil) + guard case .submenu(let actions) = server.kind else { + Issue.record("Server row must expose a standard submenu") + return + } + actions.forEach { $0.action() } + #expect(viewed == ["b"]) + #expect(restarted == ["b"]) + #expect(stopped == ["b"]) + try #require(items.first { $0.title == "Restart All Servers" }).action() + try #require(items.first { $0.title == "Stop All Servers" }).action() + try #require(items.first { $0.title == "Language Server Settings..." }).action() + #expect(restarted == ["b", "a", "b"]) + #expect(stopped == ["b", "a", "b"]) + #expect(openedSettings) + } + + @Test(arguments: [ColorScheme.light, .dark], [CGFloat(360), 900]) + func logPanelRendersInNarrowAndWideDocks(scheme: ColorScheme, width: CGFloat) async throws { + let settings = AppSettings(store: StatusTestStore()) + let container = MacServiceContainer(store: StatusTestStore(), settings: settings, moduleLaunchMode: .safeMode) + defer { container.moduleLifecycleCoordinator.stop() } + let model = AppModel(settings: settings, services: container.services) + try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in + let logs = model.languageToolingFeature.logs + logs.providerID = "example" + logs.replace([ + LanguageServerLogEntry(timestamp: Date(timeIntervalSince1970: 100), providerID: "example", level: .error, + message: "Server exited", detail: "Example failure detail"), + LanguageServerLogEntry(timestamp: Date(timeIntervalSince1970: 99), providerID: "example", level: .warning, + message: "Server warning"), + LanguageServerLogEntry(timestamp: Date(timeIntervalSince1970: 98), providerID: "example", level: .info, + message: "Server ready") + ]) + let host = NSHostingView(rootView: LanguageServerLogsView(feature: logs) + .environmentObject(model).environmentObject(settings).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 320), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + #expect(host.frame.width == width) + func textView(in view: NSView) -> NSTextView? { + if let text = view as? NSTextView { return text } + return view.subviews.lazy.compactMap { textView(in: $0) }.first + } + let nativeText = try #require(textView(in: host)) + #expect(nativeText.string.contains("Server ready")) + #expect(nativeText.enclosingScrollView?.frame.width == width) + #expect(nativeText.isEditable == false) + #expect(nativeText.isSelectable) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + #expect(bitmap.pixelsWide > 0 && bitmap.pixelsHigh > 0) + if let directory = ProcessInfo.processInfo.environment["LITHE_LSP_SNAPSHOT_DIR"] { + let output = URL(fileURLWithPath: directory) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: output.appendingPathComponent("logs-\(scheme)-\(Int(width)).png")) + } + } + } + + private func descriptor(_ id: String, _ name: String) -> LanguageProviderDescriptor { + LanguageProviderDescriptor(id: id, displayName: name, fileExtensions: [id], + capabilities: .languageServer, activationPolicy: .onDemand) + } +} + +@MainActor +private final class Harness { + let workspace = URL(fileURLWithPath: "/example/workspace") + let session = StatusTestSession() + let store = StatusTestStore() + let sessions: LanguageToolingSessionManager + let feature: LanguageToolingFeatureModel + + init() { + let descriptor = LanguageProviderDescriptor(id: "example", displayName: "Example", fileExtensions: ["example"], + capabilities: .languageServer, activationPolicy: .onDemand) + let source = StatusTestCatalog(catalog: LanguageProviderCatalog(descriptors: [descriptor])) + let sessions = LanguageToolingSessionManager(catalog: source.catalog, + runtimes: [StatusTestRuntime(descriptor: descriptor, session: session)]) + self.sessions = sessions + feature = LanguageToolingFeatureModel(catalogSource: source, catalogSnapshot: source.load(workspaceURL: nil), + preferences: MacWorkspaceLanguageServerPreferencesStore(store: store), sessionsProvider: { sessions }) + feature.reloadCatalog(for: workspace) + } +} + +private struct StatusTestCatalog: LanguageProviderCatalogSource { + let catalog: LanguageProviderCatalog + func load(workspaceURL: URL?) -> LanguageProviderCatalogSnapshot { + .init(catalog: catalog, schemaVersion: nil, origin: .builtin, issues: []) + } +} + +private final class StatusTestStore: KeyValueStore { + private(set) var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +@MainActor +private final class StatusTestRuntime: LanguageProviderRuntime { + let descriptor: LanguageProviderDescriptor + let session: StatusTestSession + let supportsLanguageServerSession = true + init(descriptor: LanguageProviderDescriptor, session: StatusTestSession) { + self.descriptor = descriptor + self.session = session + } + func makeLanguageServerSession() -> (any LanguageServerSession)? { session } +} + +@MainActor +private final class StatusTestSession: LanguageServerSession { + var isRunning = false + var javaTestRunnerURL: URL? { nil } + var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? + var onStateChange: ((LanguageServerSessionState) -> Void)? + var startCount = 0 + var stopCount = 0 + func start(rootURL: URL, workspaceFingerprint: String?) throws { + isRunning = true + startCount += 1 + onStateChange?(.ready) + } + func stop() { isRunning = false; stopCount += 1 } + func synchronize(fileURL: URL, text: String, languageID: String) throws {} + func closeDocument(_ fileURL: URL) {} + func completions(fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void) throws { completion(.success([])) } + func hover(fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result) -> Void) throws { completion(.success(nil)) } + func navigate(method: String, fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void) throws { completion(.success([])) } + func rename(fileURL: URL, position: LanguageServerPosition, newName: String, completion: @escaping (Result) -> Void) throws { completion(.success(.init())) } + func format(fileURL: URL, completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void) throws { completion(.success([])) } + func codeActions(fileURL: URL, range: LanguageServerRange, diagnostics: [LanguageServerDiagnostic], completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void) throws { completion(.success([])) } + func resolveCompletion(_ item: LanguageServerCompletionItem, fileURL: URL, completion: @escaping (Result) -> Void) throws { completion(.success(item)) } + func resolveCodeAction(_ action: LanguageServerCodeAction, fileURL: URL, completion: @escaping (Result) -> Void) throws { completion(.success(action)) } + func execute(_ command: LanguageServerCommand, fileURL: URL, completion: @escaping (Result) -> Void) throws { completion(.success(())) } + func resolveVirtualDocument(uri: String, completion: @escaping (Result) -> Void) throws { completion(.success("")) } + func javaEntrypoints(completion: @escaping (Result) -> Void) throws { throw CancellationError() } +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index a79c4e7d9b..a314053656 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9729,7 +9729,21 @@ "native.gitNotIgnored": "Git: not ignored", "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", "history.clearFailed": "Could not clear this project's local history. Check storage permissions and try again.", - "native.addClickableActionsBesideDocumentText": "Add clickable actions beside document text" + "native.addClickableActionsBesideDocumentText": "Add clickable actions beside document text", + "native.lspStatusLabel": "LSP", + "native.restartServer": "Restart Server", + "native.stopServer": "Stop Server", + "native.restartAllServers": "Restart All Servers", + "native.stopAllServers": "Stop All Servers", + "native.languageServerSettingsAction": "Language Server Settings...", + "native.lspStopOnDemandHint": "Starts again when a matching document is opened or edited.", + "native.lspLogsTitle": "LSP Logs", + "native.lspServerLogs": "Server Logs", + "native.lspAvailableLogChannels": "Shows session events and server output. Server Trace and RPC message capture are not available.", + "native.clearLogView": "Clear View", + "native.lspClearViewHint": "Clear only the selected server's visible logs. Collected diagnostic logs are kept.", + "native.noMatchingLanguageServerLogs": "No matching language server logs", + "native.logInfoLevel": "Info" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 559628f6a6..18820c0e2c 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9729,7 +9729,21 @@ "native.gitNotIgnored": "Git:未忽略", "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/`、`/*` 或 `/**` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级。不带结尾斜杠的规则只隐藏文件:`.lithe` 隐藏名为 .lithe 的文件,而不是文件夹。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/**` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。", "history.clearFailed": "无法清空此项目的本地历史。请检查存储目录权限后重试。", - "native.addClickableActionsBesideDocumentText": "在文档文本旁添加可点击的操作" + "native.addClickableActionsBesideDocumentText": "在文档文本旁添加可点击的操作", + "native.lspStatusLabel": "LSP", + "native.restartServer": "重启服务器", + "native.stopServer": "停止服务器", + "native.restartAllServers": "重启所有服务器", + "native.stopAllServers": "停止所有服务器", + "native.languageServerSettingsAction": "语言服务器设置...", + "native.lspStopOnDemandHint": "打开、切换到或编辑匹配的文档时将再次启动。", + "native.lspLogsTitle": "LSP 日志", + "native.lspServerLogs": "服务器日志", + "native.lspAvailableLogChannels": "显示会话事件和服务器输出。暂不支持服务器跟踪和 RPC 消息采集。", + "native.clearLogView": "清空视图", + "native.lspClearViewHint": "仅清空所选服务器的可见日志,保留已采集的诊断日志。", + "native.noMatchingLanguageServerLogs": "没有匹配的语言服务器日志", + "native.logInfoLevel": "信息" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 4a859896ed..7f3bd5cee4 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93146,6 +93146,160 @@ "key": "Add clickable actions beside document text", "arguments": [] } + }, + { + "id": "native.lspStatusLabel", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "LSP", + "arguments": [] + } + }, + { + "id": "native.restartServer", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Server", + "arguments": [] + } + }, + { + "id": "native.stopServer", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Server", + "arguments": [] + } + }, + { + "id": "native.restartAllServers", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart All Servers", + "arguments": [] + } + }, + { + "id": "native.stopAllServers", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop All Servers", + "arguments": [] + } + }, + { + "id": "native.languageServerSettingsAction", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language Server Settings...", + "arguments": [] + } + }, + { + "id": "native.lspStopOnDemandHint", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starts again when a matching document is opened or edited.", + "arguments": [] + } + }, + { + "id": "native.lspLogsTitle", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "LSP Logs", + "arguments": [] + } + }, + { + "id": "native.lspServerLogs", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server Logs", + "arguments": [] + } + }, + { + "id": "native.lspAvailableLogChannels", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shows session events and server output. Server Trace and RPC message capture are not available.", + "arguments": [] + } + }, + { + "id": "native.clearLogView", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear View", + "arguments": [] + } + }, + { + "id": "native.lspClearViewHint", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear only the selected server's visible logs. Collected diagnostic logs are kept.", + "arguments": [] + } + }, + { + "id": "native.noMatchingLanguageServerLogs", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching language server logs", + "arguments": [] + } + }, + { + "id": "native.logInfoLevel", + "description": "Native LSP status and log viewer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Info", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json new file mode 100644 index 0000000000..534b78d46c --- /dev/null +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -0,0 +1,29 @@ +{ + "id": "lsp-status-logs", + "area": "编辑器", + "group": "语言服务", + "capability": "LSP 状态栏控制与按服务器查看日志", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift", + "macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift", + "macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift", + "macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift", + "macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", + "macos/Tests/LitheTests/LanguageServerStatusTests.swift", + ".agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/editor/lsp" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Language Tooling", + "verification": "在 macOS 打开至少两种已安装语言插件的源文件,检查右下角 LSP 状态及数量、绿色运行、蓝色启动、黄色停止和红色故障。未启动、已停止和工作区停用的服务器不显示,代码诊断错误不改变运行状态;菜单按名称及 ID 稳定排序。分别重启和停止单个及全部服务器,确认不写工作区启用偏好、停止阻止旧异步激活重新启动、下一次显式文档打开或编辑仍按需启动;无打开文档的预热服务器也能重启。查看日志打开普通编辑器中的 LSP 日志标签并选择对应服务器;重复查看复用同一标签,不创建底部工具窗口。关闭日志标签返回原文件且不丢失未保存内容,支持共享标签排序与分组。验证服务器切换、最低级别筛选、保留原文、复制、清空当前服务器视图、新日志继续出现和其他服务器不受影响。检查日志面板在状态栏消失后仍可见,在中英语言、明暗主题、窄窗口及标签拖动分组后正常显示。Trace 与 RPC 原始消息暂不采集,信息提示明确说明;不得把同一日志伪装成多个通道。真实产品流程结束后关闭本次实例,比较 app bundle 文件清单不变。Windows 暂无该状态项与专用日志视图,不宣称两端一致。" +} From 9d62d76f3312ddf5379eecdbfbb311bbae86dc2c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 1 Nov 2026 14:09:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(macos):=20LSP=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E5=B9=B6=E4=BB=85=E7=94=B1=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=B3=A8=E5=85=A5=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...guage-tooling-and-lsp-runtime-ownership.md | 18 +++-- macos/Resources/en.lproj/Localizable.strings | 4 ++ .../zh-Hans.lproj/Localizable.strings | 4 ++ .../Application/Composition/AppServices.swift | 3 + .../LSPControlCenterPresentation.swift | 45 +++++++++--- .../LanguageToolingFeatureModel.swift | 14 ++-- .../AppModel+LanguageServerRuntime.swift | 7 +- .../Platform/MacOS/MacServiceContainer.swift | 3 + .../MacOS/Plugins/MacPluginManager.swift | 48 +++++++++++++ .../Language/LanguageServerLogsView.swift | 2 +- .../Workbench/LanguageServerStatusView.swift | 68 +++++++++++-------- .../LanguageServerStatusTests.swift | 59 ++++++++++++---- .../Tests/LitheTests/PluginManagerTests.swift | 52 ++++++++++++++ shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 22 ++++++ .../features/lsp-status-logs.json | 6 +- 17 files changed, 293 insertions(+), 70 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index f5123c6fa6..b04c1736f9 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -38,10 +38,20 @@ ### 状态栏只投影会话,日志面板不接管协议 -macOS 状态栏从会话管理器的状态和服务器自报版本生成列表,不扫描工具、不启动 -服务器,也不把代码诊断错误当作服务器故障。没有启动、已经停止或工作区停用的 -服务器隐藏;启动失败仍保留入口,供用户查看日志和重试。颜色使用现有主题的 -成功、强调、警告和错误色,与 LSP 设置页一致。 +macOS 的 LSP 入口始终保留,即使没有源文件、可选语言模块尚未激活或没有运行会话。 +列表只能来自平台插件管理器中已接受、已注册且当前启用的插件的语言服务器声明; +内置但作为可管理插件注册的语言服务也必须通过同一个模块启用检查,不能默认列出。 +宿主内置语言目录、文件扩展名识别和工具候选不等于服务器注册,不能填充此列表。 +禁用、隔离、安全模式抑制、等待首次重启注册或卸载的插件不贡献行。 + +名称复用现有服务器可执行名字段。插件直接声明 packaged/systemCommand 时取其 +`executableNames`;runtime 启动不能把 `java` 或 `node` 当服务器名,改读该插件 +languageID 对应描述元数据已有的 `languageServerLaunch.executableNames`(例如 jdtls)。 +元数据只补名称,不能绕过已启用插件注册来新增行。没有这个字段时直接显示插件 ID, +不回退语言名、插件展示名或服务器自报名称;不新增清单字段、版本或工具名映射。 +状态与版本继续由现有会话投影,代码诊断不改变服务器状态,不按当前文档类型过滤。 +空列表仍提供日志和设置入口;批量操作只针对已有会话,不因打开菜单启动服务器。 +颜色使用现有主题的成功、强调、警告和错误色,与 LSP 设置页一致。 单个或全部服务器的停止与重启只影响当前会话,不改工作区启用偏好。停止同时 使该提供方已排队的激活请求失效;下一次打开、切换或编辑匹配文档仍可按需启动。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index ff4222c51c..f8d7a6c7a6 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2493,6 +2493,7 @@ "No pull requests match the selected state." = "No pull requests match the selected state."; "No recent progress has been received. The task is still active." = "No recent progress has been received. The task is still active."; "No recent projects" = "No recent projects"; +"No registered language servers" = "No registered language servers"; "No release is available yet" = "No release is available yet"; "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time." = "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time."; "No remote" = "No remote"; @@ -2552,6 +2553,7 @@ "Not running" = "Not running"; "Not selected" = "Not selected"; "Not set" = "Not set"; +"Not started" = "Not started"; "Not supported on %@ yet." = "Not supported on %@ yet."; "Not tested" = "Not tested"; "Not used" = "Not used"; @@ -6978,6 +6980,7 @@ "native.lspAvailableLogChannels" = "Shows session events and server output. Server Trace and RPC message capture are not available."; "native.lspClearViewHint" = "Clear only the selected server's visible logs. Collected diagnostic logs are kept."; "native.lspLogsTitle" = "LSP Logs"; +"native.lspNotStarted" = "Not started"; "native.lspServerLogs" = "Server Logs"; "native.lspStatusLabel" = "LSP"; "native.lspStopOnDemandHint" = "Starts again when a matching document is opened or edited."; @@ -7329,6 +7332,7 @@ "native.noPullRequestsMatchTheSelectedState" = "No pull requests match the selected state."; "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive" = "No recent progress has been received. The task is still active."; "native.noRecentProjects" = "No recent projects"; +"native.noRegisteredLanguageServers" = "No registered language servers"; "native.noReleaseIsAvailableYet" = "No release is available yet"; "native.noRememberedChoicesCheckRememberMyChoiceInTheQuitDialogToSkipTheQuestionForAKi279db9aaf7" = "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time."; "native.noRemote" = "No remote"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 80f752e7d1..9844794714 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2493,6 +2493,7 @@ "No pull requests match the selected state." = "当前状态下没有拉取请求。"; "No recent progress has been received. The task is still active." = "暂未收到新的进度,任务仍在进行。"; "No recent projects" = "没有最近项目"; +"No registered language servers" = "未注册语言服务器"; "No release is available yet" = "暂时没有可用版本"; "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time." = "暂无已记住的选择。在退出对话框中勾选“记住对这类服务的选择”后,下次遇到同类服务就不再询问。"; "No remote" = "无远程仓库"; @@ -2552,6 +2553,7 @@ "Not running" = "未运行"; "Not selected" = "未勾选"; "Not set" = "未设置"; +"Not started" = "未启动"; "Not supported on %@ yet." = "暂不支持 %@ 平台"; "Not tested" = "未测试"; "Not used" = "不使用"; @@ -6978,6 +6980,7 @@ "native.lspAvailableLogChannels" = "显示会话事件和服务器输出。暂不支持服务器跟踪和 RPC 消息采集。"; "native.lspClearViewHint" = "仅清空所选服务器的可见日志,保留已采集的诊断日志。"; "native.lspLogsTitle" = "LSP 日志"; +"native.lspNotStarted" = "未启动"; "native.lspServerLogs" = "服务器日志"; "native.lspStatusLabel" = "LSP"; "native.lspStopOnDemandHint" = "打开、切换到或编辑匹配的文档时将再次启动。"; @@ -7329,6 +7332,7 @@ "native.noPullRequestsMatchTheSelectedState" = "当前状态下没有拉取请求。"; "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive" = "暂未收到新的进度,任务仍在进行。"; "native.noRecentProjects" = "没有最近项目"; +"native.noRegisteredLanguageServers" = "未注册语言服务器"; "native.noReleaseIsAvailableYet" = "暂时没有可用版本"; "native.noRememberedChoicesCheckRememberMyChoiceInTheQuitDialogToSkipTheQuestionForAKi279db9aaf7" = "暂无已记住的选择。在退出对话框中勾选“记住对这类服务的选择”后,下次遇到同类服务就不再询问。"; "native.noRemote" = "无远程仓库"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 239ced2d76..a2835eef3e 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -28,6 +28,9 @@ final class AppServices { /// Installed plugins with their enabled state, for the New Project /// catalog and the plugins that provide a generator's requirements. var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? + /// Only enabled, registered plugins inject rows. Existing launch metadata + /// can supply names, but never creates rows or triggers executable discovery. + var languageServerRegistrations: (@MainActor (LanguageProviderCatalog) -> [LanguageServerRegistration])? /// Gives build providers that share a language server the language /// sessions; `nil` when the platform starts no build providers. var connectBuildProviderLanguageSessions: ((@escaping @MainActor () -> LanguageToolingSessionManager?) -> Void)? diff --git a/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift b/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift index e9302ad6d3..80d84f91e1 100644 --- a/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift +++ b/macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift @@ -16,6 +16,27 @@ struct LSPStatusBarServer: Identifiable, Equatable, Sendable { let displayName: String let status: LSPServerStatus let version: String? + + var canStop: Bool { status != .stopped && status != .disabled } +} + +/// An enabled plugin's server declaration. The language ID only routes existing +/// session controls; it never supplies the server's visible name. +struct LanguageServerRegistration: Equatable, Sendable { + let id: String + let pluginID: String + let executableNames: [String] + + init(id: String, pluginID: String, executableNames: [String] = []) { + self.id = id + self.pluginID = pluginID + self.executableNames = executableNames + } +} + +struct LanguageServerRuntimeSnapshot: Equatable { + var states: [String: LanguageServerSessionState] = [:] + var infos: [String: LanguageServerInfo] = [:] } enum LSPCapabilityPresentationState: Equatable, Sendable { @@ -76,20 +97,24 @@ enum LSPControlCenterPresenter { } static func statusBarServers( - descriptors: [LanguageProviderDescriptor], + registrations: [LanguageServerRegistration], states: [String: LanguageServerSessionState], infos: [String: LanguageServerInfo], disabledProviderIDs: Set ) -> [LSPStatusBarServer] { - descriptors.filter { $0.capabilities.contains(.languageServer) }.compactMap { descriptor in + registrations.map { registration in let status = serverStatus( - isDisabled: disabledProviderIDs.contains(descriptor.id), - sessionState: states[descriptor.id] + isDisabled: disabledProviderIDs.contains(registration.id), + sessionState: states[registration.id] ) - guard status != .stopped, status != .disabled else { return nil } + let executableName = registration.executableNames.lazy + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } return LSPStatusBarServer( - id: descriptor.id, displayName: descriptor.displayName, status: status, - version: status == .active ? reportedServerVersion(infos[descriptor.id]) : nil + id: registration.id, + displayName: executableName ?? registration.pluginID, + status: status, + version: status == .active ? reportedServerVersion(infos[registration.id]) : nil ) }.sorted { let order = $0.displayName.localizedStandardCompare($1.displayName) @@ -98,10 +123,10 @@ enum LSPControlCenterPresenter { } /// A failed server must remain discoverable even when another is healthy. - static func aggregateStatus(_ servers: [LSPStatusBarServer]) -> LSPServerStatus? { - [.error, .starting, .initializing, .stopping, .active].first { status in + static func aggregateStatus(_ servers: [LSPStatusBarServer]) -> LSPServerStatus { + [.error, .starting, .initializing, .stopping, .active, .stopped, .disabled].first { status in servers.contains { $0.status == status } - } + } ?? .stopped } static func integrationState( diff --git a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index 6910f6ab40..ead2e9734c 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift @@ -13,7 +13,7 @@ final class LanguageToolingFeatureModel: ObservableObject { @Published private(set) var catalogSnapshot: LanguageProviderCatalogSnapshot @Published private(set) var disabledProviderIDs: Set = [] @Published private(set) var projectPreparation: ProjectPreparationSnapshot? - @Published private(set) var statusBarServers: [LSPStatusBarServer] = [] + @Published private(set) var serverRuntimeSnapshot = LanguageServerRuntimeSnapshot() let projectUsage = LSPProjectUsageModel() let logs = LanguageServerLogsModel() private var preparationObservation: AnyCancellable? @@ -80,13 +80,9 @@ final class LanguageToolingFeatureModel: ObservableObject { func bindProjectPreparation(_ sessions: LanguageToolingSessionManager) { logs.bind(sessions) - statusObservation = Publishers.CombineLatest4( - sessions.$languageServerStates, sessions.$languageServerInfos, $catalog, $disabledProviderIDs - ).map { states, infos, catalog, disabled in - LSPControlCenterPresenter.statusBarServers( - descriptors: catalog.descriptors, states: states, infos: infos, disabledProviderIDs: disabled - ) - }.removeDuplicates().sink { [weak self] in self?.statusBarServers = $0 } + statusObservation = Publishers.CombineLatest(sessions.$languageServerStates, sessions.$languageServerInfos) + .map { LanguageServerRuntimeSnapshot(states: $0, infos: $1) } + .removeDuplicates().sink { [weak self] in self?.serverRuntimeSnapshot = $0 } preparationObservation = sessions.$projectPreparation.removeDuplicates().sink { [weak self] snapshot in self?.projectPreparation = snapshot } @@ -96,7 +92,7 @@ final class LanguageToolingFeatureModel: ObservableObject { activationEpoch = UUID() activationGenerations.removeAll() logs.reset() - statusBarServers = [] + serverRuntimeSnapshot = LanguageServerRuntimeSnapshot() projectPreparation = nil startupFailures.removeAll() } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index becf89eadf..d41b75f627 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -41,7 +41,12 @@ extension AppModel { } var statusBarLanguageServers: [LSPStatusBarServer] { - languageToolingFeature.statusBarServers + let snapshot = languageToolingFeature.serverRuntimeSnapshot + return LSPControlCenterPresenter.statusBarServers( + registrations: services.languageServerRegistrations?(languageProviderCatalog) ?? [], + states: snapshot.states, infos: snapshot.infos, + disabledProviderIDs: languageToolingFeature.disabledProviderIDs + ) } func showLanguageServerLogs(providerID: String) { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index a5baa3f16c..ddb1d500b2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1270,6 +1270,9 @@ final class MacServiceContainer { ) } } + services.languageServerRegistrations = { [pluginManager] catalog in + pluginManager.languageServerRegistrations(catalog: catalog) + } moduleLifecycleCoordinator.start() Task { do { diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index 5bd674d117..e26e4a18be 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -134,6 +134,54 @@ final class MacPluginManager: PluginManaging { return (builtIn + native).sorted { $0.manifest.displayName < $1.manifest.displayName } } + func languageServerRegistrations(catalog: LanguageProviderCatalog) -> [LanguageServerRegistration] { + let packaged = installedPlugins.values + .filter { registeredPluginIDs.contains($0.manifest.id) && $0.installation.status == .installed } + .sorted { $0.manifest.id < $1.manifest.id } + .flatMap { package -> [LanguageServerRegistration] in + package.descriptor.languageServers.compactMap { server in + guard isLanguageServerModuleEnabled(.languageServerExtension(server.languageID)) else { return nil } + return Self.languageServerRegistration(server, pluginID: package.manifest.id.rawValue, catalog: catalog) + } + } + let builtIn = managedBuiltInPlugins.flatMap { plugin in + (plugin.languageSupports ?? []).compactMap { support -> LanguageServerRegistration? in + guard let moduleID = support.languageServerModuleID, + isLanguageServerModuleEnabled(moduleID) else { return nil } + return LanguageServerRegistration( + id: support.id, pluginID: plugin.id.rawValue, + executableNames: catalog.descriptors.first { $0.id == support.id }? + .languageServerLaunch?.executableNames ?? [] + ) + } + } + return packaged + builtIn + } + + private func isLanguageServerModuleEnabled(_ id: ModuleID) -> Bool { + guard let snapshot = try? moduleRuntime.snapshot(for: id) else { return false } + return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode + } + + static func languageServerRegistration( + _ server: DeclarativeLanguageServer, + pluginID: String, + catalog: LanguageProviderCatalog + ) -> LanguageServerRegistration { + let executableNames: [String] + switch server.launch { + case .packaged, .systemCommand: + executableNames = server.executableNames + case .runtime: + // A runtime such as java/node is not the server's name. Reuse the + // descriptive launch metadata already associated with this language; + // it supplies a name only after the enabled plugin supplies the row. + executableNames = catalog.descriptors.first { $0.id == server.languageID }? + .languageServerLaunch?.executableNames ?? [] + } + return LanguageServerRegistration(id: server.languageID, pluginID: pluginID, executableNames: executableNames) + } + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws { guard let manifest = manifest(for: pluginID) else { throw PluginManagerError.unknownPlugin(pluginID) diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift index e703a46525..84b2206a6c 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift @@ -75,7 +75,7 @@ struct LanguageServerLogsView: View { } private func providerName(_ id: String) -> String { - model.languageProviderCatalog.descriptors.first { $0.id == id }?.displayName ?? id + model.statusBarLanguageServers.first { $0.id == id }?.displayName ?? id } private func levelTitle(_ level: LanguageServerLogsModel.MinimumLevel) -> String { diff --git a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift index d6d033e5fd..b6f734e7e9 100644 --- a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift +++ b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift @@ -6,29 +6,34 @@ struct LanguageServerStatusView: View { var body: some View { let servers = model.statusBarLanguageServers - if let status = LSPControlCenterPresenter.aggregateStatus(servers) { - LitheMenu(opensUpward: true) { - LanguageServerStatusMenu.items( - servers: servers, - viewLogs: model.showLanguageServerLogs, - restart: model.languageToolingFeature.restart, - stop: model.languageToolingFeature.stop, - settings: { model.showSettings(category: .lsp) } - ) - } label: { - HStack(spacing: 5) { - Circle().fill(status.indicatorColor).frame(width: 7, height: 7) - Text("LSP") - if servers.count > 1 { Text("\(servers.count)").monospacedDigit() } + let status = LSPControlCenterPresenter.aggregateStatus(servers) + let startedCount = servers.filter(\.canStop).count + LitheMenu(opensUpward: true) { + LanguageServerStatusMenu.items( + servers: servers, + viewLogs: model.showLanguageServerLogs, + restart: model.languageToolingFeature.restart, + stop: model.languageToolingFeature.stop, + settings: { model.showSettings(category: .lsp) } + ) + } label: { + HStack(spacing: 5) { + Circle().fill(status.indicatorColor).frame(width: 7, height: 7) + Text("LSP") + if status == .stopped || status == .disabled { + Text(status.localizedTitle) + } else if startedCount > 1 { + Text("\(startedCount)").monospacedDigit() } - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("Language Services")) - .accessibilityValue(Text(servers.map { "\($0.displayName): \($0.status.localizedTitle)" }.joined(separator: ", "))) } - .buttonStyle(.litheNoPress) - .fixedSize() - .help("Language Services") + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Language Services")) + .accessibilityValue(Text(servers.isEmpty ? status.localizedTitle + : servers.map { "\($0.displayName): \($0.status.localizedTitle)" }.joined(separator: ", "))) } + .buttonStyle(.litheNoPress) + .fixedSize() + .help("Language Services") } } @@ -49,12 +54,19 @@ enum LanguageServerStatusMenu { stop: @escaping (String) -> Void, settings: @escaping () -> Void ) -> [LitheContextMenuItem] { - [.heading("Language Services")] + let startedServers = servers.filter(\.canStop) + return [.heading("Language Services")] + + (servers.isEmpty ? [.heading("No registered language servers")] : []) + servers.map { serverItem($0, viewLogs: viewLogs, restart: restart, stop: stop) } + [ .separator, - .action("Restart All Servers", icon: restartIcon) { for server in servers { restart(server.id) } }, - .action("Stop All Servers", icon: stopIcon) { for server in servers { stop(server.id) } }, + .action(L10n.string(key: "native.inline.viewLogs"), localizesTitle: false) { viewLogs(servers.first?.id ?? "") }, + .action("Restart All Servers", icon: restartIcon, isEnabled: !startedServers.isEmpty) { + for server in startedServers { restart(server.id) } + }, + .action("Stop All Servers", icon: stopIcon, isEnabled: !startedServers.isEmpty) { + for server in startedServers { stop(server.id) } + }, .separator, .action("Language Server Settings...", icon: AnyView( LitheIDEAIcon(resourcePath: "expui/general/settings.svg", size: 16, preservesOriginalColors: true) @@ -71,9 +83,11 @@ enum LanguageServerStatusMenu { var item = LitheContextMenuItem.submenu(server.displayName, icon: AnyView( Circle().fill(server.status.indicatorColor).frame(width: 8, height: 8) )) { - LitheContextMenuItem.action("View logs") { viewLogs(server.id) } - LitheContextMenuItem.action("Restart Server", icon: restartIcon) { restart(server.id) } - LitheContextMenuItem.action("Stop Server", description: L10n.string("Starts again when a matching document is opened or edited."), icon: stopIcon) { + LitheContextMenuItem.action(L10n.string(key: "native.inline.viewLogs"), localizesTitle: false) { viewLogs(server.id) } + LitheContextMenuItem.action("Restart Server", icon: restartIcon, + isEnabled: server.canStop) { restart(server.id) } + LitheContextMenuItem.action("Stop Server", description: L10n.string("Starts again when a matching document is opened or edited."), + icon: stopIcon, isEnabled: server.canStop) { stop(server.id) } } @@ -90,7 +104,7 @@ extension LSPServerStatus { case .initializing: L10n.string("Initializing project index") case .active: L10n.string("Running") case .stopping: L10n.string("Stopping") - case .stopped: L10n.string("Starts on demand when a matching file is opened") + case .stopped: L10n.string("Not started") case .disabled: L10n.string("Off") case .error: L10n.string(key: "native.inline.failedToStartCheckTheLanguageServerConfiguration") } diff --git a/macos/Tests/LitheTests/LanguageServerStatusTests.swift b/macos/Tests/LitheTests/LanguageServerStatusTests.swift index 52f73fbc90..ec485e386f 100644 --- a/macos/Tests/LitheTests/LanguageServerStatusTests.swift +++ b/macos/Tests/LitheTests/LanguageServerStatusTests.swift @@ -11,20 +11,22 @@ import SwiftUI @Suite("Language server status and logs") @MainActor struct LanguageServerStatusTests { - @Test func statusListHidesDormantAndDisabledProvidersAndKeepsFailures() { - let descriptors = [descriptor("z", "Same"), descriptor("a", "Same"), descriptor("b", "Alpha"), - descriptor("d", "Disabled"), descriptor("s", "Stopped"), descriptor("n", "Never started")] + @Test func statusListIncludesDormantAndDisabledRegisteredProvidersAndKeepsFailures() { + let registrations = [registration("z", "Same"), registration("a", "Same"), registration("b", "Alpha"), + registration("d", "Disabled"), registration("s", "Stopped"), registration("n", "Never started")] let servers = LSPControlCenterPresenter.statusBarServers( - descriptors: descriptors, + registrations: registrations, states: ["z": .ready, "a": .initializing, "b": .failed(.init(exitCode: 1, message: "failure")), "d": .ready, "s": .stopped], - infos: ["z": .init(name: "server", version: " 1.2 "), "a": .init(name: "old", version: "stale")], + infos: ["z": .init(name: "Same", version: " 1.2 "), "a": .init(name: "Same", version: "stale")], disabledProviderIDs: ["d"] ) - #expect(servers.map(\.id) == ["b", "a", "z"]) - #expect(servers.map(\.version) == [nil, nil, "1.2"]) + #expect(servers.map(\.id) == ["b", "d", "n", "a", "z", "s"]) + #expect(servers.map(\.version) == [nil, nil, nil, nil, "1.2", nil]) + #expect(servers.first { $0.id == "d" }?.status == .disabled) + #expect(servers.first { $0.id == "n" }?.status == .stopped) #expect(LSPControlCenterPresenter.aggregateStatus(servers) == .error) - #expect(LSPControlCenterPresenter.aggregateStatus([]) == nil) + #expect(LSPControlCenterPresenter.aggregateStatus([]) == .stopped) #expect(LSPControlCenterPresenter.aggregateStatus(Array(servers.dropFirst())) == .initializing) } @@ -33,15 +35,45 @@ struct LanguageServerStatusTests { defer { harness.sessions.stopAllLanguageServers() } harness.feature.bindProjectPreparation(harness.sessions) var changes = 0 - let observation = harness.feature.$statusBarServers.dropFirst().sink { _ in changes += 1 } + let observation = harness.feature.$serverRuntimeSnapshot.dropFirst().sink { _ in changes += 1 } defer { observation.cancel() } try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) - #expect(harness.feature.statusBarServers.first?.status == .active) + #expect(harness.feature.serverRuntimeSnapshot.states["example"] == .ready) let beforeLog = changes harness.sessions.recordLanguageServerLog(providerID: "example", level: .info, message: "server output") #expect(changes == beforeLog) harness.feature.stop(providerID: "example") - #expect(harness.feature.statusBarServers.isEmpty) + #expect(harness.feature.serverRuntimeSnapshot.states["example"] == .stopped) + } + + @Test func serverNamesUseExistingExecutableMetadataAndOtherwiseThePluginID() { + let registrations = [registration("java", "jdtls"), registration("c", "clangd"), + LanguageServerRegistration(id: "legacy", pluginID: "org.example.legacy", executableNames: ["", " "])] + let rows = LSPControlCenterPresenter.statusBarServers(registrations: registrations, + states: ["java": .ready, "c": .ready, "unregistered": .ready], + infos: ["java": .init(name: "Eclipse JDT Language Server", version: "1"), + "c": .init(name: "Apple clangd", version: "21")], disabledProviderIDs: []) + #expect(Set(rows.map(\.displayName)) == ["jdtls", "clangd", "org.example.legacy"]) + #expect(rows.allSatisfy { $0.id != "unregistered" }) + #expect(LSPControlCenterPresenter.statusBarServers(registrations: [], + states: ["java": .ready], infos: [:], disabledProviderIDs: []).isEmpty) + } + + @Test func idleMenuKeepsLogsAndSettingsAvailableWithoutAnySessions() throws { + var viewed = false, configured = false + let menu = LanguageServerStatusMenu.items(servers: [], viewLogs: { _ in viewed = true }, + restart: { _ in Issue.record("An idle menu must not restart a server") }, + stop: { _ in Issue.record("An idle menu must not stop a server") }, + settings: { configured = true }) + let logs = try #require(menu.first { $0.title == L10n.string(key: "native.inline.viewLogs") }) + #expect(logs.isEnabled) + logs.action() + let settings = try #require(menu.first { $0.title == "Language Server Settings..." }) + #expect(settings.isEnabled) + settings.action() + #expect(viewed && configured) + #expect(menu.first { $0.title == "Restart All Servers" }?.isEnabled == false) + #expect(menu.first { $0.title == "Stop All Servers" }?.isEnabled == false) } @Test func restartStopsBeforeReopeningOnlyMatchingDocumentsAndPreservesPreferences() throws { @@ -243,9 +275,8 @@ struct LanguageServerStatusTests { } } - private func descriptor(_ id: String, _ name: String) -> LanguageProviderDescriptor { - LanguageProviderDescriptor(id: id, displayName: name, fileExtensions: [id], - capabilities: .languageServer, activationPolicy: .onDemand) + private func registration(_ id: String, _ name: String) -> LanguageServerRegistration { + LanguageServerRegistration(id: id, pluginID: "org.example.tools", executableNames: [name]) } } diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index 6ffec84919..3ddbc4dbde 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -7,6 +7,51 @@ import Testing @MainActor struct PluginManagerTests { + @Test func bundledServersAreInjectedOnlyAfterTheirPluginIsEnabled() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let configuration = MacModuleConfigurationStore(store: PluginManagerKeyValueStore()) + let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) + let plugin = try #require(BundledLanguagePluginCatalog.manifests.first { + $0.languageSupports?.first?.id == "clangd" + }) + let declaration = try #require(plugin.modules.first) + try runtime.register(ModuleFactory(manifest: declaration.manifest) { + PluginManagerTestModule(manifest: declaration.manifest, recorder: PluginManagerModuleRecorder()) + }) + let manager = MacPluginManager(packageStore: makeStore(root: root), moduleRuntime: runtime, + configurationStore: configuration, settings: InMemoryPluginSettings(), launchMode: .normal, + startup: MacPluginStartupResult(installedPlugins: [], registeredPlugins: [], issues: []), + managedBuiltInPlugins: [plugin]) + let catalog = RustLanguageProviderCatalogSource(core: RustCoreBridge()).load().catalog + #expect(manager.languageServerRegistrations(catalog: catalog).isEmpty) + try await manager.setEnabled(true, for: plugin.id) + #expect(manager.languageServerRegistrations(catalog: catalog).map(\.executableNames) == [["clangd"]]) + try await manager.setEnabled(false, for: plugin.id) + #expect(manager.languageServerRegistrations(catalog: catalog).isEmpty) + } + + @Test func serverRegistrationUsesDeclaredCommandsOrExistingServerMetadataWithoutInventingNames() throws { + let java = try DeclarativePluginTestSupport.officialDescriptor("JavaSupport") + let server = try #require(java.languageServers.first) + let catalog = RustLanguageProviderCatalogSource(core: RustCoreBridge()).load().catalog + let entry = MacPluginManager.languageServerRegistration(server, pluginID: java.id.rawValue, catalog: catalog) + #expect(entry.executableNames == ["jdtls"]) + #expect(entry.pluginID == java.id.rawValue) + let unnamed = MacPluginManager.languageServerRegistration(server, pluginID: java.id.rawValue, + catalog: LanguageProviderCatalog(descriptors: [])) + #expect(unnamed.executableNames.isEmpty) + let row = try #require(LSPControlCenterPresenter.statusBarServers(registrations: [unnamed], + states: [:], infos: [:], disabledProviderIDs: []).first) + #expect(row.displayName == java.id.rawValue) + + let installed = try installedPlugin() + let direct = try #require(installed.descriptor.languageServers.first) + let directEntry = MacPluginManager.languageServerRegistration(direct, + pluginID: installed.manifest.id.rawValue, catalog: catalog) + #expect(directEntry.executableNames == ["fixture-language-server"]) + } + @Test func bundledLanguageCatalogOnlyCreatesPluginsForImplementedProcessFeatures() throws { let expectedIDs: Set = [ @@ -106,6 +151,7 @@ struct PluginManagerTests { ) #expect(manager.snapshots.isEmpty) + #expect(manager.languageServerRegistrations(catalog: .compatibilityFallback).isEmpty) } @Test @@ -135,6 +181,7 @@ struct PluginManagerTests { let snapshot = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) #expect(snapshot.isEnabled) #expect(snapshot.requiresRestart) + #expect(manager.languageServerRegistrations(catalog: .compatibilityFallback).isEmpty) } /// A declarative plugin registered at launch has no code mapped in the @@ -174,6 +221,9 @@ struct PluginManagerTests { invalidateWebPages: { invalidated.append($0) }, enableWebPages: { webPagesEnabled.append($0) } ) + #expect(manager.languageServerRegistrations(catalog: .compatibilityFallback) == [LanguageServerRegistration( + id: "fixture", pluginID: installed.manifest.id.rawValue, executableNames: ["fixture-language-server"] + )]) try await manager.setEnabled(false, for: installed.manifest.id) @@ -183,6 +233,7 @@ struct PluginManagerTests { let disabled = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) #expect(!disabled.isEnabled) #expect(!disabled.requiresRestart) + #expect(manager.languageServerRegistrations(catalog: .compatibilityFallback).isEmpty) try await manager.setEnabled(true, for: installed.manifest.id) let enabled = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) @@ -190,6 +241,7 @@ struct PluginManagerTests { #expect(!enabled.requiresRestart) #expect(invalidated == [installed.manifest.id]) #expect(webPagesEnabled == [installed.manifest.id]) + #expect(manager.languageServerRegistrations(catalog: .compatibilityFallback).map(\.id) == ["fixture"]) } @Test diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index a314053656..4e469f1682 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9743,7 +9743,9 @@ "native.clearLogView": "Clear View", "native.lspClearViewHint": "Clear only the selected server's visible logs. Collected diagnostic logs are kept.", "native.noMatchingLanguageServerLogs": "No matching language server logs", - "native.logInfoLevel": "Info" + "native.logInfoLevel": "Info", + "native.lspNotStarted": "Not started", + "native.noRegisteredLanguageServers": "No registered language servers" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 18820c0e2c..eb8f67390f 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9743,7 +9743,9 @@ "native.clearLogView": "清空视图", "native.lspClearViewHint": "仅清空所选服务器的可见日志,保留已采集的诊断日志。", "native.noMatchingLanguageServerLogs": "没有匹配的语言服务器日志", - "native.logInfoLevel": "信息" + "native.logInfoLevel": "信息", + "native.lspNotStarted": "未启动", + "native.noRegisteredLanguageServers": "未注册语言服务器" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 7f3bd5cee4..a27c5f1849 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93300,6 +93300,28 @@ "key": "Info", "arguments": [] } + }, + { + "id": "native.lspNotStarted", + "description": "LSP entry remains available before any server has started", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not started", + "arguments": [] + } + }, + { + "id": "native.noRegisteredLanguageServers", + "description": "LSP entry remains available before any server has started", + "platforms": [ + "macos" + ], + "macos": { + "key": "No registered language servers", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index 534b78d46c..ed07d950cf 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -12,7 +12,9 @@ "macos/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift", "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", "macos/Tests/LitheTests/LanguageServerStatusTests.swift", - ".agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md" + ".agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", + "macos/Tests/LitheTests/PluginManagerTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -25,5 +27,5 @@ "verificationStatus": "not-applicable" }, "owner": "Language Tooling", - "verification": "在 macOS 打开至少两种已安装语言插件的源文件,检查右下角 LSP 状态及数量、绿色运行、蓝色启动、黄色停止和红色故障。未启动、已停止和工作区停用的服务器不显示,代码诊断错误不改变运行状态;菜单按名称及 ID 稳定排序。分别重启和停止单个及全部服务器,确认不写工作区启用偏好、停止阻止旧异步激活重新启动、下一次显式文档打开或编辑仍按需启动;无打开文档的预热服务器也能重启。查看日志打开普通编辑器中的 LSP 日志标签并选择对应服务器;重复查看复用同一标签,不创建底部工具窗口。关闭日志标签返回原文件且不丢失未保存内容,支持共享标签排序与分组。验证服务器切换、最低级别筛选、保留原文、复制、清空当前服务器视图、新日志继续出现和其他服务器不受影响。检查日志面板在状态栏消失后仍可见,在中英语言、明暗主题、窄窗口及标签拖动分组后正常显示。Trace 与 RPC 原始消息暂不采集,信息提示明确说明;不得把同一日志伪装成多个通道。真实产品流程结束后关闭本次实例,比较 app bundle 文件清单不变。Windows 暂无该状态项与专用日志视图,不宣称两端一致。" + "verification": "在 macOS 打开至少两种已安装语言插件的源文件,检查右下角 LSP 状态及数量、绿色运行、蓝色启动、黄色停止和红色故障。入口在没有打开源文件、没有活跃会话和语言模块未激活时仍显示。菜单仅由当前启用且已注册的插件的 LSP 声明注入(内置可管理插件也需显式启用);没有插件时列表为空,宿主内置语言目录不得出现。启用 Java 等插件后才显示对应服务器;名称取插件已声明的服务器 executableNames;runtime 启动取关联描述元数据现有的 languageServerLaunch.executableNames,不显示 java/node 运行时名。无该字段则直接显示插件 ID,不回退语言名、插件展示名或服务器自报名称;元数据不得单独贡献服务器行。不新增插件字段和版本。禁用、隔离、安全模式抑制或未注册插件不贡献行。显示真实运行、未启动、停止和工作区停用状态,不以当前文件类型过滤运行服务;空列表仍能查看日志和打开设置,重启/停止全部只处理已有会话。代码诊断错误不改变运行状态;菜单按名称及 ID 稳定排序。分别重启和停止单个及全部服务器,确认不写工作区启用偏好、停止阻止旧异步激活重新启动、下一次显式文档打开或编辑仍按需启动;无打开文档的预热服务器也能重启。查看日志打开普通编辑器中的 LSP 日志标签并选择对应服务器;重复查看复用同一标签,不创建底部工具窗口。关闭日志标签返回原文件且不丢失未保存内容,支持共享标签排序与分组。验证服务器切换、最低级别筛选、保留原文、复制、清空当前服务器视图、新日志继续出现和其他服务器不受影响。检查服务器停止后日志标签仍可见,在中英语言、明暗主题、窄窗口及标签拖动分组后正常显示。Trace 与 RPC 原始消息暂不采集,信息提示明确说明;不得把同一日志伪装成多个通道。真实产品流程结束后关闭本次实例,比较 app bundle 文件清单不变。Windows 暂无该状态项与专用日志视图,不宣称两端一致。" } From bb51d5ae871fc474b0e60b2048070ac8fa8f36fe Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sun, 1 Nov 2026 15:42:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(lsp):=20=E7=AD=89=E5=BE=85=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=85=B3=E9=97=AD=E5=AE=8C=E6=88=90=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=B1=80=E9=83=A8=E7=B4=A2=E5=BC=95=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...guage-tooling-and-lsp-runtime-ownership.md | 12 +- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../LanguageToolingFeatureModel.swift | 43 ++++++-- .../Models/AppModel/AppModel+JavaIndex.swift | 2 +- .../Language/LanguageToolingContracts.swift | 15 +++ .../Runtime/LanguageServerSession.swift | 59 +++++++++- .../LanguageToolingSessionManager.swift | 61 +++++++++- .../LanguageServerStatusTests.swift | 104 +++++++++++++++++- .../MacBuildProviderSessionsTests.swift | 31 +++++- shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 ++ .../features/lsp-status-logs.json | 7 +- 14 files changed, 323 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index b04c1736f9..90755ec979 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -56,8 +56,16 @@ languageID 对应描述元数据已有的 `languageServerLaunch.executableNames` 单个或全部服务器的停止与重启只影响当前会话,不改工作区启用偏好。停止同时 使该提供方已排队的激活请求失效;下一次打开、切换或编辑匹配文档仍可按需启动。 专属准备任务通过在依赖组装处登记的取消回调结束,通用状态菜单不按语言名称分支。 -重启先停止,再重新同步匹配的打开文档;没有打开文档时也重启同一个预热提供方, -不把“重启”静默变成“停止”。 +重启先发起停止,等待旧进程的终止事件及异步资源释放,再重新同步匹配的打开文档; +没有打开文档时也重启同一个预热提供方,不把“重启”静默变成“停止”。等待期间保留 +`.stopping` 状态和提供方的占位,文档激活不能另起替代进程。停止完成等待有本地期限; +超时返回失败并保留占位,不冒险与旧 JDT 工作区目录锁重叠。等待期间的再次停止、 +工作区替换或停用会撤销重启意图,旧关闭完成不能复活服务器。异步/远程启动在取消后 +才返回时,同样先停止并销毁该迟到会话,再释放包快照。 + +重建 Java 索引只重置 Java 的应用状态,不调用工作区整体重置。其他语言服务的 +运行投影、日志选择及订阅必须保留;不能清空被 `removeDuplicates` 去重的共享投影后, +指望日志或无变化的状态通知重新填回。 “查看日志”打开或聚焦普通编辑器标签页中的 LSP 日志,不新增底部工具窗口。 日志标签复用混合标签顺序、拖动分组、选择和关闭机制;切换服务器仍使用同一标签。它直接观察现有的最多 100 条会话事件 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f8d7a6c7a6..9061a4783d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -4033,6 +4033,7 @@ "The interface language changes immediately. English is the default." = "The interface language changes immediately. English is the default."; "The item can be recovered from the macOS Trash." = "The item can be recovered from the macOS Trash."; "The language server could not apply this setting. Restart it to use the new value." = "The language server could not apply this setting. Restart it to use the new value."; +"The language server is still stopping. Try again after it has stopped." = "The language server is still stopping. Try again after it has stopped."; "The language server this build system uses is not available. Open a file of the language and try again." = "The language server this build system uses is not available. Open a file of the language and try again."; "The language service that hosts the debug adapter is unavailable." = "The language service that hosts the debug adapter is unavailable."; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release."; @@ -6748,6 +6749,7 @@ "native.languageServerReturnedOverlappingTextEdits" = "Language server returned overlapping text edits."; "native.languageServerSessionStoppedBeforeTheRequestCompleted" = "Language server session stopped before the request completed."; "native.languageServerSettingsAction" = "Language Server Settings..."; +"native.languageServerStillStopping" = "The language server is still stopping. Try again after it has stopped."; "native.languageServerToolConfigurationChanged" = "Language server tool configuration changed"; "native.languageServers" = "Language servers"; "native.languageService" = "Language Service"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9844794714..7abad4e18b 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -4033,6 +4033,7 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "The item can be recovered from the macOS Trash." = "可以从 macOS 废纸篓中恢复此项目。"; "The language server could not apply this setting. Restart it to use the new value." = "语言服务器无法应用此设置。请重启它以使用新值。"; +"The language server is still stopping. Try again after it has stopped." = "语言服务器仍在停止。请在停止完成后重试。"; "The language server this build system uses is not available. Open a file of the language and try again." = "此构建系统使用的语言服务器不可用。请打开该语言的文件后重试。"; "The language service that hosts the debug adapter is unavailable." = "承载调试适配器的语言服务不可用。"; "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release." = "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。"; @@ -6748,6 +6749,7 @@ "native.languageServerReturnedOverlappingTextEdits" = "语言服务器返回了重叠的文本编辑。"; "native.languageServerSessionStoppedBeforeTheRequestCompleted" = "语言服务器会话在请求完成前已停止。"; "native.languageServerSettingsAction" = "语言服务器设置..."; +"native.languageServerStillStopping" = "语言服务器仍在停止。请在停止完成后重试。"; "native.languageServerToolConfigurationChanged" = "语言服务器工具配置已更改"; "native.languageServers" = "语言服务器"; "native.languageService" = "语言服务"; diff --git a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index ead2e9734c..6be07e5df2 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift @@ -30,6 +30,9 @@ final class LanguageToolingFeatureModel: ObservableObject { private var preparationCancellations: [String: @MainActor () -> Void] = [:] private var activationEpoch = UUID() private var activationGenerations: [String: UUID] = [:] + private var restartTasks: [String: Task] = [:] + + deinit { restartTasks.values.forEach { $0.cancel() } } struct ActivationToken: Equatable { let epoch: UUID @@ -45,6 +48,7 @@ final class LanguageToolingFeatureModel: ObservableObject { } private func prepareToStop(providerID: String) { + restartTasks.removeValue(forKey: providerID)?.cancel() activationGenerations[providerID] = UUID() preparationCancellations[providerID]?() } @@ -89,6 +93,8 @@ final class LanguageToolingFeatureModel: ObservableObject { } func resetWorkspaceState() { + restartTasks.values.forEach { $0.cancel() } + restartTasks.removeAll() activationEpoch = UUID() activationGenerations.removeAll() logs.reset() @@ -97,6 +103,13 @@ final class LanguageToolingFeatureModel: ObservableObject { startupFailures.removeAll() } + /// Index rebuild is local to one provider. Keep other sessions' projection, + /// log selection and observations intact instead of clearing deduplicated data. + func resetProviderState(providerID: String) { + prepareToStop(providerID: providerID) + startupFailures[providerID] = nil + } + func projectUsageRequest(workspaceURL: URL?, snapshotID: UUID?) -> LSPProjectUsageRequest { let providers = catalog.descriptors .filter { $0.capabilities.contains(.languageServer) && $0.languageServerLaunch != nil } @@ -193,16 +206,28 @@ final class LanguageToolingFeatureModel: ObservableObject { let descriptor = catalog.descriptors.first(where: { $0.id == providerID && $0.capabilities.contains(.languageServer) }), let sessions = sessionsProvider() else { return } - stop(providerID: providerID) + prepareToStop(providerID: providerID) startupFailures[providerID] = nil - let documents = (documentsProvider?() ?? []).filter { catalog.provider(for: $0.url)?.id == providerID } - if documents.isEmpty { - // A prewarmed server can outlive its last editor tab. Restart that - // same provider instead of silently turning Restart into Stop. - do { try sessions.startLanguageServer(providerID: providerID, rootURL: workspaceURL) } - catch { markActivationFailed(providerID: providerID, descriptor: descriptor, error: error) } - } else { - for document in documents { _ = activateDocument?(document) } + let token = activationToken(providerID: providerID) + restartTasks[providerID] = Task { @MainActor [weak self] in + guard !Task.isCancelled, self?.activationToken(providerID: providerID) == token else { return } + let ended = await sessions.stopLanguageServerAndWait(providerID: providerID) + guard let self, !Task.isCancelled, + self.activationToken(providerID: providerID) == token, + self.workspaceURL == workspaceURL, !self.isDisabled(providerID), + self.sessionsProvider() === sessions else { return } + defer { self.restartTasks[providerID] = nil } + guard ended else { + self.notify?(L10n.string("The language server is still stopping. Try again after it has stopped.")) + return + } + let documents = (self.documentsProvider?() ?? []).filter { self.catalog.provider(for: $0.url)?.id == providerID } + if documents.isEmpty { + do { try sessions.startLanguageServer(providerID: providerID, rootURL: workspaceURL) } + catch { self.markActivationFailed(providerID: providerID, descriptor: descriptor, error: error) } + } else { + for document in documents { _ = self.activateDocument?(document) } + } } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift index d88cd247f4..cb65f10081 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift @@ -18,7 +18,7 @@ extension AppModel { let sessions = try await languageSessionsForWorkspaceMaintenance(for: identity) try sessions.rebuildWorkspaceState(providerID: "java", rootURL: identity.url) cancelJavaLanguageServerPreparation() - languageIntelligenceModuleCoordinator.resetWorkspaceState(languageToolingFeature) + languageToolingFeature.resetProviderState(providerID: "java") showNotification( settings.language.localizedString("native.inline.javaIndexClearedReopenAJavaFileToRebuild") ) diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 2f2ed7a7cb..fd8adc159f 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -1022,6 +1022,21 @@ package protocol LanguageServerSession: AnyObject { func updateWorkspaceConfigurationAsync(_ configuration: ToolingJSONValue) async -> WorkspaceConfigurationUpdateResult func stop() + /// Stops the session and confirms process/resource release within a local + /// deadline. A false result must not be followed by a replacement launch. + func stopAndWait() async -> Bool +} + +package extension LanguageServerSession { + func stopAndWait() async -> Bool { + stop() + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while isRunning, ContinuousClock.now < deadline { + do { try await Task.sleep(for: .milliseconds(10)) } + catch { return false } + } + return !isRunning + } } /// Completion metadata is list-scoped, including when the list is empty. diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index ed38c27299..2d8db78c25 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -74,6 +74,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private var functionProgress: [String: @MainActor @Sendable (PluginFunctionProgress) -> Void] = [:] private var documentVersions: [URL: Int] = [:] private var pollTask: Task? + private var releaseTask: Task? + private var startsInFlight = 0 private var state: LanguageServerSessionState = .stopped private var processID: Int32? /// Requests being written off the main actor. Core writes a request to @@ -210,7 +212,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if let prepareProcess, !startingPreparedProcess { let generation = UUID() startupGeneration = generation + startsInFlight += 1 preparationTask = Task { @MainActor [weak self] in + defer { self?.startsInFlight -= 1 } do { let prepared = try await prepareProcess(normalizedRoot) guard let self, self.startupGeneration == generation, !Task.isCancelled else { @@ -338,14 +342,32 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { startupGeneration = generation let resources = releaseResources releaseResources = nil + startsInFlight += 1 Task.detached(priority: .userInitiated) { [weak self] in let started = launch() // The completion claims the result only while its generation // is active; a closed/replaced owner destroys the late session. if await self?.completeBackgroundStart(started, generation: generation, resources: resources) != true { - if case .success(let payload) = started { core.destroyLanguageServer(sessionID: payload.sessionID) } + if case .success(let payload) = started { + // A cancelled remote launch can finish after stop(). + // Keep its ownership until Core confirms termination. + core.stopLanguageServer(sessionID: payload.sessionID) + let deadline = ContinuousClock.now.advanced(by: .seconds(max(5, shutdownTimeout * 2 + 2))) + var ended = false + while !ended, ContinuousClock.now < deadline { + ended = core.pollLanguageServerEvents(sessionID: payload.sessionID).contains { + $0.type == "stateChanged" && ($0.state == "stopped" || $0.state == "failed") + } + if !ended { try? await Task.sleep(for: .milliseconds(10)) } + } + // Fail closed: retaining the in-flight marker prevents + // reuse of a workspace whose old process is still alive. + guard ended else { return } + core.destroyLanguageServer(sessionID: payload.sessionID) + } await resources?() } + await self?.finishBackgroundStart() } return } @@ -357,6 +379,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + private func finishBackgroundStart() { startsInFlight -= 1 } + private func completeBackgroundStart( _ result: Result, generation: UUID, @@ -1176,6 +1200,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func stop() { + beginStop(inBackground: false) + } + + private func beginStop(inBackground: Bool) { let wasStarting = startupGeneration != nil preparationTask?.cancel() preparationTask = nil @@ -1205,7 +1233,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { watchdog.arm(after: stuckSendGrace) { [weak self] in self?.endStuckSend() } stopWatchdog = watchdog } - } else if usesBackgroundStart { + } else if usesBackgroundStart || inBackground { let core = core Task.detached(priority: .userInitiated) { core.stopLanguageServer(sessionID: sessionID) } } else { @@ -1214,6 +1242,19 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if isRunning { transition(to: .stopping) } } + package func stopAndWait() async -> Bool { + // Core may wait for an outbound lock/write; the caller's deadline must + // remain responsive on the main actor even when that stop send blocks. + beginStop(inBackground: true) + let deadline = ContinuousClock.now.advanced(by: .seconds(max(5, shutdownTimeout * 2 + 2))) + while sessionID != nil || startsInFlight > 0 || releaseTask != nil { + guard ContinuousClock.now < deadline else { return false } + do { try await Task.sleep(for: .milliseconds(10)) } + catch { return false } + } + return true + } + package func retryMavenProfiles() { guard let sessionID, isRunning else { return } if case .failure(let failure) = core.retryMavenProfiles(sessionID: sessionID) { @@ -1504,15 +1545,21 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if let sessionID { if usesBackgroundStart || releaseResources != nil { let core = core - Task.detached(priority: .userInitiated) { - core.destroyLanguageServer(sessionID: sessionID) - await releaseResources?() + releaseTask = Task { @MainActor [self] in + await Task.detached(priority: .userInitiated) { + core.destroyLanguageServer(sessionID: sessionID) + await releaseResources?() + }.value + releaseTask = nil } } else { core.destroyLanguageServer(sessionID: sessionID) } } else if let releaseResources { - Task.detached { await releaseResources() } + releaseTask = Task { @MainActor [self] in + await releaseResources() + releaseTask = nil + } } sessionID = nil documentVersions = [:] diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index d0c11102fe..9318896420 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -70,6 +70,9 @@ package final class LanguageToolingSessionManager: ObservableObject, private var extensionLifecycles: [String: WeakLanguageServerExtensionLifecycle] = [:] private let runtimeFactory: (any LanguageProviderRuntimeFactory)? private var languageServers: [String: any LanguageServerSession] = [:] + /// Explicit restarts retain the old process's slot through shutdown and + /// resource release. A timed-out close must not admit an overlapping start. + private var stoppingServers: [String: (id: UUID, session: any LanguageServerSession, task: Task)] = [:] private var languageServerRoots: [String: URL] = [:] private static let maximumStartupAttempts = 3 private var languageServerStartupAttempts: [String: (root: URL, count: Int)] = [:] @@ -1088,6 +1091,11 @@ package final class LanguageToolingSessionManager: ObservableObject, } package func stopLanguageServer(providerID: String) { + guard stoppingServers[providerID] == nil else { return } + removeLanguageServer(providerID: providerID, stopSession: true) + } + + private func removeLanguageServer(providerID: String, stopSession: Bool) { languageServerStartupAttempts[providerID] = nil if providerID == "java" { projectPreparation = nil } if providerID == "java" { @@ -1116,22 +1124,24 @@ package final class LanguageToolingSessionManager: ObservableObject, clearDiagnostics(providerID: providerID) if providerID == "java" { mavenProfileProjectResults.removeAll() } languageServerSessionIdentities[providerID] = nil - languageServers.removeValue(forKey: providerID)?.stop() + let removed = languageServers.removeValue(forKey: providerID) + if stopSession { removed?.stop() } languageServerRoots[providerID] = nil languageServerMavenContexts[providerID] = nil languageServerWorkspaceFingerprints[providerID] = nil languageServerFeatures[providerID] = nil languageServerInfos[providerID] = nil languageServerFeatureProviders[providerID] = nil - languageServerStates[providerID] = .stopped + let state: LanguageServerSessionState = stopSession ? .stopped : .stopping + languageServerStates[providerID] = state resumeLanguageServerReadyWaiters( providerID: providerID, - state: .stopped, + state: state, rootURL: nil ) onLanguageServerStateChange?( providerID, - .stopped, + state, operationID ) languageServerOperationIDs[providerID] = nil @@ -1155,6 +1165,44 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerOperationIDs = [:] } + /// The ordinary stop API initiates shutdown. Restart callers additionally + /// wait for the old process and its package/workspace resources to release. + package func stopLanguageServerAndWait(providerID: String) async -> Bool { + let retiring: any LanguageServerSession + if let stopping = stoppingServers[providerID] { + if await stopping.task.value { return true } + guard !Task.isCancelled else { return false } + // A previous wait can time out before the process finally exits. + // An explicit retry waits on that same process, never starts over. + retiring = stopping.session + } else { + guard let session = languageServers[providerID] else { + stopLanguageServer(providerID: providerID) + return true + } + retiring = session + } + let id = UUID() + let task = Task { @MainActor [weak self] in + let ended = await retiring.stopAndWait() + guard let self, self.stoppingServers[providerID]?.id == id else { return ended } + if ended { self.stoppingServers[providerID] = nil } + // Full workspace teardown may already have cleared the projection. + if self.languageServerStates[providerID] == .stopping { + let state: LanguageServerSessionState = ended ? .stopped : .failed(.init( + code: "shutdownTimeout", stage: "shutdown", + message: L10n.string("The language server is still stopping. Try again after it has stopped.") + )) + self.languageServerStates[providerID] = state + self.onLanguageServerStateChange?(providerID, state, nil) + } + return ended + } + stoppingServers[providerID] = (id, retiring, task) + removeLanguageServer(providerID: providerID, stopSession: false) + return await task.value + } + /// Stops one provider and removes only its current workspace-state directory. package func rebuildWorkspaceState(providerID: String, rootURL: URL) throws { guard let descriptor = catalog.descriptors.first(where: { $0.id == providerID }) else { @@ -1580,6 +1628,11 @@ package final class LanguageToolingSessionManager: ObservableObject, rootURL: URL, operationID requestedOperationID: UUID? ) throws -> any LanguageServerSession { + guard stoppingServers[descriptor.id] == nil else { + throw LanguageToolingSessionError.toolingUnavailable( + L10n.string("The language server is still stopping. Try again after it has stopped.") + ) + } // All entry points, including Maven reload, testing, and debugging, must // recheck the current workspace preference before creating or reusing a session. guard isLanguageServerEnabled(descriptor.id, rootURL) else { diff --git a/macos/Tests/LitheTests/LanguageServerStatusTests.swift b/macos/Tests/LitheTests/LanguageServerStatusTests.swift index ec485e386f..6f05112150 100644 --- a/macos/Tests/LitheTests/LanguageServerStatusTests.swift +++ b/macos/Tests/LitheTests/LanguageServerStatusTests.swift @@ -76,32 +76,38 @@ struct LanguageServerStatusTests { #expect(menu.first { $0.title == "Stop All Servers" }?.isEnabled == false) } - @Test func restartStopsBeforeReopeningOnlyMatchingDocumentsAndPreservesPreferences() throws { + @Test func restartStopsBeforeReopeningOnlyMatchingDocumentsAndPreservesPreferences() async throws { let harness = Harness() defer { harness.sessions.stopAllLanguageServers() } let matching = EditorDocument(url: harness.workspace.appendingPathComponent("a.example"), text: "", modificationDate: nil) let other = EditorDocument(url: harness.workspace.appendingPathComponent("README.txt"), text: "", modificationDate: nil) var activated: [URL] = [] + let reopened = TestGate() harness.feature.configure(documentsProvider: { [other, matching] }, activateDocument: { document in #expect(harness.session.stopCount == 1) activated.append(document.url) + reopened.open() return true }, notify: { _ in }) try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) let preferences = harness.store.values let token = harness.feature.activationToken(providerID: "example") harness.feature.restart(providerID: "example") + try #require(await reopened.waitUntilOpen()) #expect(activated == [matching.url]) #expect(harness.feature.activationToken(providerID: "example") != token) #expect(harness.store.values.keys.sorted() == preferences.keys.sorted()) #expect(!harness.feature.isDisabled("example")) } - @Test func restartWithoutDocumentsRestartsTheSameProviderAndDisabledRestartDoesNothing() throws { + @Test func restartWithoutDocumentsRestartsTheSameProviderAndDisabledRestartDoesNothing() async throws { let harness = Harness() defer { harness.sessions.stopAllLanguageServers() } try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + let restarted = TestGate() + harness.session.onStart = { restarted.open() } harness.feature.restart(providerID: "example") + try #require(await restarted.waitUntilOpen()) #expect(harness.session.startCount == 2) #expect(harness.session.stopCount == 1) harness.feature.setEnabled(false, providerID: "example") @@ -110,6 +116,76 @@ struct LanguageServerStatusTests { #expect(harness.feature.isDisabled("example")) } + @Test func restartWaitsForDelayedShutdownAndKeepsStoppingUntilTheSlotIsReleased() async throws { + let harness = Harness() + let released = TestGate(), restarted = TestGate() + harness.session.releaseStop = released + defer { released.open(); harness.feature.stop(providerID: "example") } + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + harness.feature.bindProjectPreparation(harness.sessions) + harness.session.onStart = { restarted.open() } + harness.feature.restart(providerID: "example") + try #require(await harness.session.stopRequested.waitUntilOpen()) + #expect(harness.session.startCount == 1) + #expect(harness.feature.serverRuntimeSnapshot.states["example"] == .stopping) + #expect(throws: (any Error).self) { + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + } + released.open() + try #require(await restarted.waitUntilOpen()) + #expect(harness.session.startCount == 2) + } + + @Test func stoppingWhileRestartWaitsCannotResurrectTheServer() async throws { + let harness = Harness() + let released = TestGate() + harness.session.releaseStop = released + defer { released.open(); harness.sessions.stopAllLanguageServers() } + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + harness.feature.restart(providerID: "example") + try #require(await harness.session.stopRequested.waitUntilOpen()) + harness.feature.stop(providerID: "example") + released.open() + #expect(await harness.sessions.stopLanguageServerAndWait(providerID: "example")) + #expect(harness.session.startCount == 1) + } + + @Test func aFailedShutdownKeepsItsSlotUntilAnExplicitRetryConfirmsRelease() async throws { + let harness = Harness() + defer { harness.sessions.stopAllLanguageServers() } + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + harness.session.stopCompletes = false + #expect(await harness.sessions.stopLanguageServerAndWait(providerID: "example") == false) + #expect(throws: (any Error).self) { + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + } + harness.session.stopCompletes = true + #expect(await harness.sessions.stopLanguageServerAndWait(providerID: "example")) + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + #expect(harness.session.startCount == 2) + } + + @Test func providerResetPreservesOtherLiveServersAndLogSelection() throws { + let harness = Harness(includesJava: true) + defer { harness.sessions.stopAllLanguageServers() } + harness.feature.bindProjectPreparation(harness.sessions) + try harness.sessions.startLanguageServer(providerID: "example", rootURL: harness.workspace) + try harness.sessions.startLanguageServer(providerID: "java", rootURL: harness.workspace) + harness.feature.logs.providerID = "example" + harness.sessions.recordLanguageServerLog(providerID: "example", level: .info, message: "retained output") + let before = harness.feature.serverRuntimeSnapshot + let logs = harness.feature.logs.entries + try harness.sessions.rebuildWorkspaceState(providerID: "java", rootURL: harness.workspace) + harness.feature.resetProviderState(providerID: "java") + #expect(harness.feature.serverRuntimeSnapshot.states["example"] == .ready) + #expect(harness.feature.serverRuntimeSnapshot.states["java"] == .stopped) + #expect(harness.feature.logs.providerID == "example") + #expect(harness.feature.logs.entries.filter { $0.providerID == "example" } + == logs.filter { $0.providerID == "example" }) + harness.sessions.recordLanguageServerLog(providerID: "example", level: .info, message: "later output") + #expect(harness.feature.serverRuntimeSnapshot.states["example"] == before.states["example"]) + } + @Test func stopInvalidatesPendingActivationAndCallsRegisteredPreparationOwner() { let harness = Harness() var cancelled = 0 @@ -288,12 +364,16 @@ private final class Harness { let sessions: LanguageToolingSessionManager let feature: LanguageToolingFeatureModel - init() { + init(includesJava: Bool = false) { let descriptor = LanguageProviderDescriptor(id: "example", displayName: "Example", fileExtensions: ["example"], capabilities: .languageServer, activationPolicy: .onDemand) - let source = StatusTestCatalog(catalog: LanguageProviderCatalog(descriptors: [descriptor])) + let java = LanguageProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"], + capabilities: .languageServer, activationPolicy: .onDemand) + let source = StatusTestCatalog(catalog: LanguageProviderCatalog(descriptors: includesJava ? [descriptor, java] : [descriptor])) + var runtimes: [any LanguageProviderRuntime] = [StatusTestRuntime(descriptor: descriptor, session: session)] + if includesJava { runtimes.append(StatusTestRuntime(descriptor: java, session: StatusTestSession())) } let sessions = LanguageToolingSessionManager(catalog: source.catalog, - runtimes: [StatusTestRuntime(descriptor: descriptor, session: session)]) + runtimes: runtimes, workspaceStateResetter: { _, _, _ in }) self.sessions = sessions feature = LanguageToolingFeatureModel(catalogSource: source, catalogSnapshot: source.load(workspaceURL: nil), preferences: MacWorkspaceLanguageServerPreferencesStore(store: store), sessionsProvider: { sessions }) @@ -337,12 +417,26 @@ private final class StatusTestSession: LanguageServerSession { var onStateChange: ((LanguageServerSessionState) -> Void)? var startCount = 0 var stopCount = 0 + var releaseStop: TestGate? + var stopCompletes = true + let stopRequested = TestGate() + var onStart: (() -> Void)? func start(rootURL: URL, workspaceFingerprint: String?) throws { isRunning = true startCount += 1 onStateChange?(.ready) + onStart?() } func stop() { isRunning = false; stopCount += 1 } + func stopAndWait() async -> Bool { + stopRequested.open() + guard stopCompletes else { return false } + if let releaseStop { + guard await releaseStop.waitUntilOpen() else { return false } + } + stop() + return true + } func synchronize(fileURL: URL, text: String, languageID: String) throws {} func closeDocument(_ fileURL: URL) {} func completions(fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void) throws { completion(.success([])) } diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 40e21a6d97..d4b4329572 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -9,6 +9,33 @@ import Testing /// process a request reuses, and when the host stops or restarts it. @MainActor struct MacBuildProviderSessionsTests { + @Test func stopAndWaitIncludesTerminalEventAndAsynchronousResourceRelease() async throws { + let core = ScriptedProviderCore(readyAfterStop: true) + let session = LanguageServerRuntimeSession(providerID: "fixture", + executableURL: URL(fileURLWithPath: "/tools/server"), arguments: [], environment: [:], core: core) + let releasing = TestGate(), release = TestGate(), finished = TestGate() + session.releaseResources = { + releasing.open() + #expect(await release.waitUntilOpen(), "Resource release was not permitted") + } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + let closing = Task { @MainActor in + let result = await session.stopAndWait() + finished.open() + return result + } + defer { core.finishStopping(); release.open(); closing.cancel() } + try #require(await core.stopRequested.waitUntilOpen()) + #expect(!finished.isOpen) + core.finishStopping() + try #require(await releasing.waitUntilOpen()) + #expect(core.destroyed.isOpen) + #expect(!finished.isOpen, "Termination alone does not release package/workspace resources") + release.open() + try #require(await finished.waitUntilOpen()) + #expect(await closing.value) + } + @Test(arguments: [false, true]) func documentSavesReachCoreAndPropagateTransportFailure(fails: Bool) async throws { let core = ScriptedProviderCore() @@ -241,7 +268,7 @@ struct MacBuildProviderSessionsTests { #expect(await core.destroyed.waitUntilOpen()) #expect(await resourcesReleased.waitUntilOpen()) #expect(states == [.startingProcess, .stopped]) - #expect(core.stopCalls.isEmpty) + #expect(core.stopCalls == ["provider-1"]) } @Test @@ -839,6 +866,7 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked let functionCancelled = TestGate() let commandStarted = TestGate() let destroyed = TestGate() + let stopRequested = TestGate() let releaseStartup = TestGate() let synchronized = TestGate() /// Opened when a `workspace.stuck` send is blocked; it stays blocked, as @@ -1068,6 +1096,7 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked let state = readyAfterStop ? "ready" : "stopped" events[sessionID, default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: state)) } + stopRequested.open() } func finishStopping() { diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 4e469f1682..c3a186ef29 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9745,7 +9745,8 @@ "native.noMatchingLanguageServerLogs": "No matching language server logs", "native.logInfoLevel": "Info", "native.lspNotStarted": "Not started", - "native.noRegisteredLanguageServers": "No registered language servers" + "native.noRegisteredLanguageServers": "No registered language servers", + "native.languageServerStillStopping": "The language server is still stopping. Try again after it has stopped." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index eb8f67390f..bfe8296f56 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9745,7 +9745,8 @@ "native.noMatchingLanguageServerLogs": "没有匹配的语言服务器日志", "native.logInfoLevel": "信息", "native.lspNotStarted": "未启动", - "native.noRegisteredLanguageServers": "未注册语言服务器" + "native.noRegisteredLanguageServers": "未注册语言服务器", + "native.languageServerStillStopping": "语言服务器仍在停止。请在停止完成后重试。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index a27c5f1849..2606c1e276 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93322,6 +93322,17 @@ "key": "No registered language servers", "arguments": [] } + }, + { + "id": "native.languageServerStillStopping", + "description": "Restart refuses to overlap an old language-server process that has not released its resources", + "platforms": [ + "macos" + ], + "macos": { + "key": "The language server is still stopping. Try again after it has stopped.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index ed07d950cf..acffac829b 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -14,7 +14,10 @@ "macos/Tests/LitheTests/LanguageServerStatusTests.swift", ".agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", - "macos/Tests/LitheTests/PluginManagerTests.swift" + "macos/Tests/LitheTests/PluginManagerTests.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", + "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -27,5 +30,5 @@ "verificationStatus": "not-applicable" }, "owner": "Language Tooling", - "verification": "在 macOS 打开至少两种已安装语言插件的源文件,检查右下角 LSP 状态及数量、绿色运行、蓝色启动、黄色停止和红色故障。入口在没有打开源文件、没有活跃会话和语言模块未激活时仍显示。菜单仅由当前启用且已注册的插件的 LSP 声明注入(内置可管理插件也需显式启用);没有插件时列表为空,宿主内置语言目录不得出现。启用 Java 等插件后才显示对应服务器;名称取插件已声明的服务器 executableNames;runtime 启动取关联描述元数据现有的 languageServerLaunch.executableNames,不显示 java/node 运行时名。无该字段则直接显示插件 ID,不回退语言名、插件展示名或服务器自报名称;元数据不得单独贡献服务器行。不新增插件字段和版本。禁用、隔离、安全模式抑制或未注册插件不贡献行。显示真实运行、未启动、停止和工作区停用状态,不以当前文件类型过滤运行服务;空列表仍能查看日志和打开设置,重启/停止全部只处理已有会话。代码诊断错误不改变运行状态;菜单按名称及 ID 稳定排序。分别重启和停止单个及全部服务器,确认不写工作区启用偏好、停止阻止旧异步激活重新启动、下一次显式文档打开或编辑仍按需启动;无打开文档的预热服务器也能重启。查看日志打开普通编辑器中的 LSP 日志标签并选择对应服务器;重复查看复用同一标签,不创建底部工具窗口。关闭日志标签返回原文件且不丢失未保存内容,支持共享标签排序与分组。验证服务器切换、最低级别筛选、保留原文、复制、清空当前服务器视图、新日志继续出现和其他服务器不受影响。检查服务器停止后日志标签仍可见,在中英语言、明暗主题、窄窗口及标签拖动分组后正常显示。Trace 与 RPC 原始消息暂不采集,信息提示明确说明;不得把同一日志伪装成多个通道。真实产品流程结束后关闭本次实例,比较 app bundle 文件清单不变。Windows 暂无该状态项与专用日志视图,不宣称两端一致。" + "verification": "在 macOS 打开至少两种已安装语言插件的源文件,检查右下角 LSP 状态及数量、绿色运行、蓝色启动、黄色停止和红色故障。入口在没有打开源文件、没有活跃会话和语言模块未激活时仍显示。菜单仅由当前启用且已注册的插件的 LSP 声明注入(内置可管理插件也需显式启用);没有插件时列表为空,宿主内置语言目录不得出现。启用 Java 等插件后才显示对应服务器;名称取插件已声明的服务器 executableNames;runtime 启动取关联描述元数据现有的 languageServerLaunch.executableNames,不显示 java/node 运行时名。无该字段则直接显示插件 ID,不回退语言名、插件展示名或服务器自报名称;元数据不得单独贡献服务器行。不新增插件字段和版本。禁用、隔离、安全模式抑制或未注册插件不贡献行。显示真实运行、未启动、停止和工作区停用状态,不以当前文件类型过滤运行服务;空列表仍能查看日志和打开设置,重启/停止全部只处理已有会话。代码诊断错误不改变运行状态;菜单按名称及 ID 稳定排序。分别重启和停止单个及全部服务器,确认不写工作区启用偏好、停止阻止旧异步激活重新启动、下一次显式文档打开或编辑仍按需启动;无打开文档的预热服务器也能重启。查看日志打开普通编辑器中的 LSP 日志标签并选择对应服务器;重复查看复用同一标签,不创建底部工具窗口。关闭日志标签返回原文件且不丢失未保存内容,支持共享标签排序与分组。验证服务器切换、最低级别筛选、保留原文、复制、清空当前服务器视图、新日志继续出现和其他服务器不受影响。检查服务器停止后日志标签仍可见,在中英语言、明暗主题、窄窗口及标签拖动分组后正常显示。Trace 与 RPC 原始消息暂不采集,信息提示明确说明;不得把同一日志伪装成多个通道。真实产品流程结束后关闭本次实例,比较 app bundle 文件清单不变。Windows 暂无该状态项与专用日志视图,不宣称两端一致。 PR #1631 review:延迟旧服务 shutdown 与异步资源释放,确认重启期间保持 stopping、旧资源释放前不启动替代会话,等待超时不重启;等待时再次停止或换工作区不能复活服务。Java 与另一服务同时运行时重建 Java 索引,确认另一服务状态和所选日志保持,后续同状态日志不需要触发投影重建。" }