From 1d3672986d92e8334cb50246dc2f2e6efe9aef21 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 10:24:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(macos):=20=E6=97=A5=E5=BF=97=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E8=87=AA=E9=80=82=E5=BA=94=E5=B8=83=E5=B1=80=E3=80=81?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E7=AA=97=E5=8F=A3=E4=B8=8E=E9=BD=BF=E8=BD=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日志表格按视口分配多余宽度,只在表头拖动结束后保存手动列宽 - 实时批次只增量替换末尾行,跟随滚动平滑衔接并遵守减少动态效果 - ⌘F 由所属窗口处理,工作台快捷键分发前先交给日志窗口 - 通道列表改用原生表格,支持 ⌘ 点击增减和 Shift 连选 - 新增时间范围弹层(系统日历与时分秒),事件分布显示秒级刻度 - 筛选、通道、跟随、调用链、时间范围补充 ⓘ 说明 - 语言服务器“查看日志”打开独立窗口,不再改写应用日志窗口的筛选 - 齿轮菜单的插件与日志项增加图标 --- ...guage-tooling-and-lsp-runtime-ownership.md | 10 +- .../2026-10-12-editor-tab-split-docking.md | 5 +- ...026-11-03-structured-logging-log-window.md | 43 +++- macos/Resources/en.lproj/Localizable.strings | 28 +++ .../zh-Hans.lproj/Localizable.strings | 28 +++ .../Application/Composition/AppServices.swift | 1 + .../Features/LanguageServerLogsModel.swift | 73 ++++++ macos/Sources/Lithe/LitheApp.swift | 15 ++ .../AppModel+LanguageServerRuntime.swift | 10 +- .../MacOS/UI/MacShortcutDetector.swift | 2 + .../MacOS/UI/MacWindowFindTarget.swift | 47 ++++ macos/Sources/Lithe/Views/App/RootView.swift | 2 + .../Views/Diagnostics/LogChannelList.swift | 124 ++++++++++ .../Diagnostics/LogTimeRangePicker.swift | 107 ++++++++ .../Diagnostics/LogWindowPresentation.swift | 33 --- .../Views/Diagnostics/LogWindowTable.swift | 90 ++++++- .../Views/Diagnostics/LogWindowView.swift | 234 +++++++++--------- .../Language/LanguageServerLogsView.swift | 63 +++++ .../Workbench/LanguageServerStatusView.swift | 2 +- .../Workbench/WorkbenchSettingsMenu.swift | 22 ++ .../Lithe/Views/Workbench/WorkbenchView.swift | 12 +- .../LitheTests/DiagnosticsWorkflowTests.swift | 84 ++++++- .../LitheTests/LogWindowReviewTests.swift | 131 ++++++++++ macos/Tests/LitheTests/LogWindowTests.swift | 81 +++++- shared/i18n/locales/en-US.json | 16 +- shared/i18n/locales/zh-CN.json | 16 +- shared/i18n/messages.json | 154 ++++++++++++ .../features/diagnostics-export.json | 9 +- .../features/lsp-status-logs.json | 8 +- 29 files changed, 1234 insertions(+), 216 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/UI/MacWindowFindTarget.swift create mode 100644 macos/Sources/Lithe/Views/Diagnostics/LogChannelList.swift create mode 100644 macos/Sources/Lithe/Views/Diagnostics/LogTimeRangePicker.swift create mode 100644 macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift create mode 100644 macos/Sources/Lithe/Views/Workbench/WorkbenchSettingsMenu.swift create mode 100644 macos/Tests/LitheTests/LogWindowReviewTests.swift 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 90755ec97..1f3c35058 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 @@ -67,11 +67,11 @@ languageID 对应描述元数据已有的 `languageServerLaunch.executableNames` 运行投影、日志选择及订阅必须保留;不能清空被 `removeDuplicates` 去重的共享投影后, 指望日志或无变化的状态通知重新填回。 -“查看日志”打开或聚焦普通编辑器标签页中的 LSP 日志,不新增底部工具窗口。 -日志标签复用混合标签顺序、拖动分组、选择和关闭机制;切换服务器仍使用同一标签。它直接观察现有的最多 100 条会话事件 -和服务器输出,按提供方与最低级别筛选;日志更新不经整个 AppModel 广播。 -“清空视图”只记住所选提供方当前可见缓冲条目的身份,不删除持久诊断日志, -也不影响其他提供方。标记随缓冲淘汰,不建立另一个无界日志存储。 +“查看日志”打开或聚焦独立的“语言服务器日志”窗口,不新增底部工具窗口,也不占用编辑器标签。 +窗口读取已脱敏的 v2 磁盘日志与实时广播,只看 `lsp` 通道;切换服务器仍在同一窗口, +服务器通道由 Core 解析,Swift 不用 providerID 拼通道名。“清空视图”只隐藏当前已显示的记录, +不删除持久诊断日志,也不建立另一份日志存储。窗口与应用日志窗口的关系见 +[应用级日志窗口与诊断导出](../feature/2026-11-03-structured-logging-log-window.md)。 正确做法是按当前会话事件显示真实内容。不要在 Swift 再实现 JSON-RPC(语言服务器 使用的请求响应协议)抓包器,或把现有文本日志复制成 Server Trace、RPC Messages 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 93af4d3ef..17c0276ba 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,10 +24,7 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 关闭或移走最后一个标签后合并空组;新标签进入当前聚焦的组。文件和会话生命周期不交给布局树。 分隔器继续使用 `LitheSplitPaneView`,尺寸变化留在局部视图,只在释放后提交比例。 分组是当前项目会话状态,本次不增加重启恢复编辑分屏或跨窗口传输。 -LSP 日志同样是普通编辑器标签,不登记底部工具窗口。单个工作区只打开一个日志标签, -再次查看另一服务器时复用标签并更新筛选;标签可参与重排和分组。日志不拥有文件缓冲区, -选择它应清除活动文件/媒体/终端投影,关闭后通过混合标签选择恢复原内容,不能关闭未保存文件。 -文件通过功能模型直接打开时,也必须让日志标签让出活动选择;工作区重置移除日志标签。 +LSP 日志已不在编辑器标签里显示,改为独立的“语言服务器日志”窗口,不参与编辑分组。 与数据库 PR #17 集成后,数据库标签继续使用其共享工作区和查询状态,统一保留在同一个编辑分组,只允许组内重排;不能直接开放跨组移动,否则两个分组会显示同一数据库上下文。数据库工具窗口及 Services 工具窗口仍支持停靠分组。 ### 终端标签使用独立的 AppKit 拖放 diff --git a/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md b/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md index 07530b54f..3e06da8bb 100644 --- a/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md +++ b/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md @@ -4,7 +4,7 @@ ## 先说结论 -macOS 只保留一个看日志的地方:用 ⌘⇧L 打开的独立日志窗口,所有项目共用。窗口读取磁盘上已经由 Core 脱敏的 v2 日志,以及写入器的实时广播。过滤语法、重复折叠、事件分布、调用链和复制格式都由 Rust Core 计算,SwiftUI 只负责读文件、订阅和画表格。导出对话框和窗口使用同一套 Core 查询,导出的永远是脱敏版。语言服务的日志编辑器标签页、100 条内存日志和设置页里的日志时间线已经删除。Windows 仍然使用 v1 日志,没有迁移。 +macOS 的应用日志只在一个地方看:用 ⌘⇧L 打开的独立日志窗口,所有项目共用。语言服务状态里的“查看日志”打开另一个“语言服务器日志”窗口,读的是同一份脱敏记录,只显示 `lsp` 通道,不会改动应用日志窗口的筛选。窗口读取磁盘上已经由 Core 脱敏的 v2 日志,以及写入器的实时广播。过滤语法、重复折叠、事件分布、调用链和复制格式都由 Rust Core 计算,SwiftUI 只负责读文件、订阅和画表格。导出对话框和窗口使用同一套 Core 查询,导出的永远是脱敏版。语言服务的日志编辑器标签页、100 条内存日志和设置页里的日志时间线已经删除。Windows 仍然使用 v1 日志,没有迁移。 ## 问题 @@ -23,12 +23,24 @@ macOS 只保留一个看日志的地方:用 ⌘⇧L 打开的独立日志窗 - 工具栏齿轮菜单的“日志与诊断…”,紧跟在“插件…”下面,和“主题…”之间隔一条分隔线; - 菜单栏的“日志”菜单项; - 可改键的命令 `show-logs`,默认 ⌘⇧L; -- 语言服务状态里的“查看日志”; - 应用内提示上的“查看日志”; - 应用菜单的“导出诊断包…”:打开窗口后直接弹出导出对话框。 窗口已经打开时,请求只套用过滤条件并把窗口提到前面,不会再开一个窗口。 +### 语言服务器日志单独成窗 + +语言服务状态菜单里的“查看日志”不发 `LogWindowOpenRequest`,而是打开 id 为 `language-server-logs` 的“语言服务器日志”窗口。 +原因是两类用户目的不同:查语言服务器的人要连续阅读某个服务器的会话事件、stderr(标准错误输出)和服务消息; +查应用问题的人在应用日志窗口里按通道、级别和时间筛选。合并在一个窗口时,点一次“查看日志”就会覆盖用户在应用日志窗口里刚设好的通道筛选。 + +正确做法:`AppModel.showLanguageServerLogs(providerID:)` 只把提供方和状态栏服务器列表交给 `LanguageServerLogsCoordinator`; +`LanguageServerLogsModel` 内部持有一个只查 `lsp` 前缀、不折叠重复记录的 `LogWindowModel`,选中服务器后用 Core 的 +`windowEngineChannel` 换成该服务器的通道。数据来源仍是 `MacLogStore` 的磁盘 v2 记录和实时广播,所以这里看到的就是应用日志窗口和导出里的同一份脱敏证据。 +窗口顶部可以切换服务器,“全部语言服务器”查看整个 `lsp` 前缀;状态菜单底部的“查看日志”默认打开全部服务器。 + +不要这样做:为语言服务器另建内存缓冲或第二份日志文件;在 Swift 里用 providerID 拼通道名;为了打开这个窗口改写应用日志窗口的查询。 + ### 设置里没有日志页 日志与诊断是独立界面,不和设置共用。原来的“设置 → 工具 → 日志与诊断”页已经删除,`SettingsCategory` 里没有日志分类。那一页独有的三项搬进了窗口: @@ -127,6 +139,28 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 - 显示缓存和逐像素滚动锚点由 `NSTableView` 的 coordinator 持有;详情面板高度用共享分隔条;关闭窗口时取消查询、调用链和订阅。 - Core 的脱敏占位符是 ``,窗口灰色显示、不可点击。 +### 窗口尺寸、筛选与实时呈现 + +表格把窗口多出来的宽度分给事件、消息和字段列;窄窗口保留原来的可读列宽及横向滚动。 +自动分配只属于当前视口,不写用户偏好。只有表头拖动结束后才保存手动列宽,避免窗口放大、 +隐藏列或延迟的原生列宽通知污染已保存配置。详情与调用链以视口宽度作为最小内容宽度, +从左上角展开,长内容继续横向滚动;右侧动作说明在自己的滚动区域内,不能覆盖底部状态栏。 + +筛选框复用共享搜索组件,ⓘ 说明表达式的 AND、排除、字段、前缀和正则语法,以及它与通道、 +级别和时间条件的交集。通道列表通过共享列表的原生渲染入口承载 NSTableView(系统表格控件)的选择流程,⌘ 点击增减、Shift 点击 +连续选择,“全部通道”清除限制;不要在 SwiftUI Button 的动作闭包里读取可能已变化的 currentEvent。 +⌘F 由所属窗口的原生目标处理,工作台快捷键分发器先交给它,避免编辑器抢走日志窗口的 Find。 +窗口关闭时目标取消事件监听;有 sheet 或输入法正在组字时保留输入所有权。 + +事件分布显示带秒数的时间刻度。时间范围弹层使用共享下拉承载系统日历与时分秒编辑器, +编辑草稿后点击应用才交给现有 Core 时间查询;界面使用本地时区,结束时间包含该秒的全部记录。 +范围与实时批次、其他过滤条件取交集,清除后恢复不限时间。调用链的 ⓘ 明确条形和 +N ms +表示相对首条记录的偏移,不能把它解释成事件执行耗时。 + +实时批次只替换表格受影响的末尾行,选择、暂停或状态变化不整表重建;跟随时用短时原生滚动 +衔接批次,并遵守系统减少动态效果设置。历史查询一次显示实际结果,真实突发仍保留原始时间 +和顺序,不增加空闲定时器或延迟队列来制造稳定的事件发生速度。 + ## 考虑过的备选方案 - 保留编辑器标签页和设置页时间线两套浏览:来源和生命周期继续不同,无法回答“导出的是否就是看到的”。 @@ -136,10 +170,11 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 - 导出提供“包含原文”选项:磁盘上根本没有原文。要支持就得另存一份未脱敏日志,扩大隐私面。 - 每个事件单独调用 Core、每次打开文件:实现简单,但跨语言调用和磁盘开销随日志量线性增长;选择按批查询和读取尾部。 - 两端一次迁移:扩大验证范围并阻塞 macOS;Windows 保留 v1 兼容,以后单独迁移。 +- 语言服务器的“查看日志”继续打开应用日志窗口并切换通道(PR4 的做法):入口少一个,但每次打开都会覆盖应用日志窗口的通道筛选,表格视图也不适合连续阅读服务器输出。改为独立窗口,数据来源不变。 ## 后果 -收益:用户只有一个查看入口,看到的、复制的和导出的是同一份脱敏证据;错误提示可以一键跳到对应记录;过滤和折叠在 Core 里,有 fixture 覆盖,Windows 可以复用。 +收益:应用日志只有一个查看入口,语言服务器日志窗口也读同一份数据,看到的、复制的和导出的是同一份脱敏证据;错误提示可以一键跳到对应记录;过滤和折叠在 Core 里,有 fixture 覆盖,Windows 可以复用。 代价:Core 的诊断 API 变大,每个新字段都要同步契约、fixture 和 Swift 桥接。实时跟随每个批次都要调用一次 Core 查询,事件分布也由 Core 对有界窗口重新计算;高吞吐下的成本还需要整机测量,如果不可接受,应扩展 Core 的增量摘要接口,而不是在 Swift 里复制统计规则。门禁零额度意味着每个新的 `try?`、空 `catch` 或丢弃结果都必须当场记录或写明例外类别。 @@ -151,7 +186,7 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 - `node scripts/logging/verify-log-usage.mjs` - `node --test --test-timeout=60000 scripts/logging/test-log-events.mjs` - `cargo test --manifest-path rust/lithe-core/Cargo.toml diagnostics` -- `./scripts/macos/test/test-macos.sh --filter 'LitheTests.(DiagnosticsWorkflowTests|LogWindowTests)'` +- `./scripts/macos/test/test-macos.sh --filter 'LitheTests.(DiagnosticsWorkflowTests|LogWindowTests|LogWindowReviewTests)'` - `./scripts/macos/probe/probe-macos-logging-ui.sh --skip-build` - `./scripts/architecture/verify-service-boundaries.sh` - `./scripts/architecture/verify-shared-contracts.sh` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 113edb82a..9b6e80038 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -323,6 +323,7 @@ "All configurations" = "All configurations"; "All conflicts resolved. Continue to finish, or abort to undo." = "All conflicts resolved. Continue to finish, or abort to undo."; "All conversations" = "All conversations"; +"All language servers" = "All language servers"; "All messages" = "All messages"; "All projects" = "All projects"; "All projects · project can override" = "All projects · project can override"; @@ -689,6 +690,7 @@ "Choose one of the offered values." = "Choose one of the offered values."; "Choose path" = "Choose path"; "Choose services to run" = "Choose services to run"; +"Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it." = "Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it."; "Choose two branches" = "Choose two branches"; "Choose what Pull does when your branch and the remote branch have diverged." = "Choose what Pull does when your branch and the remote branch have diverged."; "Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped." = "Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped."; @@ -748,6 +750,7 @@ "Clear user filter" = "Clear user filter"; "Clear view" = "clear view"; "Clearing…" = "Clearing…"; +"Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction." = "Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction."; "Click the editor gutter to add a breakpoint" = "Click the editor gutter to add a breakpoint"; "Click the folded options to expand; right-click for command details." = "Click the folded options to expand; right-click for command details."; "Click to jump · drag for time range" = "click to jump · drag for time range"; @@ -1446,6 +1449,7 @@ "End This Terminal" = "End This Terminal"; "End all" = "End all"; "End on next launch" = "End on next launch"; +"End time" = "End time"; "End without asking" = "End without asking"; "English" = "English"; "Enlarge image" = "Enlarge image"; @@ -2073,6 +2077,7 @@ "Labels and assignees" = "Labels and assignees"; "Language" = "Language"; "Language Server" = "Language Server"; +"Language Server Logs" = "Language Server Logs"; "Language Server Settings..." = "Language Server Settings..."; "Language Service" = "Language Service"; "Language Services" = "Language Services"; @@ -2183,6 +2188,7 @@ "Lithe: prune stale branches" = "Lithe: prune stale branches"; "Little Endian" = "Little Endian"; "Live follow" = "tail -f"; +"Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs." = "Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs."; "Load" = "Load"; "Load %lld more" = "Load %lld more"; "Load Disk Version" = "Load Disk Version"; @@ -2595,6 +2601,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 messages" = "No matching messages"; "No matching plugins" = "No matching plugins"; "No matching skills." = "No matching skills."; @@ -3667,6 +3674,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"; @@ -3791,6 +3799,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 the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics." = "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics."; "Side-by-side" = "Side-by-side"; "Side-by-side view" = "Side-by-side view"; "Sign in again" = "Sign in again"; @@ -3870,6 +3879,7 @@ "Source Repository" = "Source Repository"; "Source Roots" = "Source Roots"; "Sources Root" = "Sources Root"; +"Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results." = "Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results."; "Speed" = "Speed"; "Spring Boot" = "Spring Boot"; "Spring Endpoints" = "Spring Endpoints"; @@ -3918,6 +3928,7 @@ "Start the discussion or submit the first review." = "Start the discussion or submit the first review."; "Start the session by sending a message" = "Start the session by sending a message"; "Start this configuration to see its output here." = "Start this configuration to see its output here."; +"Start time" = "Start time"; "Start with an action verb, without a type prefix" = "Start with an action verb, without a type prefix"; "Started in Claude Desktop" = "Started in Claude Desktop"; "Starting" = "Starting"; @@ -4167,6 +4178,7 @@ "The bundled JDT LS manifest is invalid at %@." = "The bundled JDT LS manifest is invalid at %@."; "The bundled JDT LS version could not be determined." = "The bundled JDT LS version could not be determined."; "The bundled palette is an approximation; custom theme colors are not imported." = "The bundled palette is an approximation; custom theme colors are not imported."; +"The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table." = "The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table."; "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record."; "The checkout path does not exist" = "The checkout path does not exist"; "The chosen file is missing or not executable." = "The chosen file is missing or not executable."; @@ -4213,6 +4225,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page."; "The downloaded update failed its checksum verification." = "The downloaded update failed its checksum verification."; "The edit stop changed. Refresh the session before amending." = "The edit stop changed. Refresh the session before amending."; +"The end time must be after the start time." = "The end time must be after the start time."; "The environment snapshot could not be read. Refresh to try again." = "The environment snapshot could not be read. Refresh to try again."; "The expression does not hold an address" = "The expression does not hold an address"; "The featured MCP catalog could not be loaded." = "The featured MCP catalog could not be loaded."; @@ -4545,6 +4558,7 @@ "Threads" = "Threads"; "Threads & Variables" = "Threads & Variables"; "Time" = "Time"; +"Time range" = "Time range"; "Time to exit" = "Time to exit"; "Timed Out" = "Timed Out"; "Timeout (seconds)" = "Timeout (seconds)"; @@ -5155,6 +5169,11 @@ "logs.filter.regexTooLong" = "Regular expression is too long"; "logs.filter.unterminatedQuote" = "Close the quoted value"; "logs.filter.unterminatedRegex" = "Close the regular expression with /"; +"logs.language.allServers" = "All language servers"; +"logs.language.empty" = "No matching language server logs"; +"logs.language.outputHelp" = "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics."; +"logs.language.outputTitle" = "Server Logs"; +"logs.language.windowTitle" = "Language Server Logs"; "logs.window.absoluteTime" = "absolute time"; "logs.window.allChannels" = "all channels"; "logs.window.autoOffIn" = "Auto-off in %1$@"; @@ -5163,6 +5182,7 @@ "logs.window.category" = "Category: %1$@"; "logs.window.changeDirectory" = "Change Log Directory…"; "logs.window.channel" = "Channel"; +"logs.window.channelHelp" = "Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction."; "logs.window.clearOnlyTheDisplayLogFilesAreKept" = "Clear only the display; log files are kept"; "logs.window.clearStorage" = "Clear Logs (used %1$@)…"; "logs.window.clearTimeRange" = "Clear time range"; @@ -5181,6 +5201,7 @@ "logs.window.disableDiagnostics" = "stop"; "logs.window.droppedLld" = "dropped %1$lld"; "logs.window.enableDiagnostics" = "Enable"; +"logs.window.endTime" = "End time"; "logs.window.event" = "Event"; "logs.window.eventsOverTime" = "events over time"; "logs.window.export" = "Export"; @@ -5188,7 +5209,9 @@ "logs.window.fields" = "Fields"; "logs.window.filterChannel" = "filter channel"; "logs.window.filterErrorAtLldLld" = "Filter error at %1$lld–%2$lld: %3$@"; +"logs.window.filterHelp" = "Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results."; "logs.window.filterServerJdtlsLevelWarnEventLspTraceReceivedRegexOpC0ffee" = "Filter: server:jdtls level>=warn -event:lsp.trace.received /regex/ op:c0ffee"; +"logs.window.followHelp" = "Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs."; "logs.window.following" = "Following"; "logs.window.hideThisEvent" = "Hide this event"; "logs.window.histogramTooltip" = "%1$@ · ERROR %2$lld · WARN %3$lld · other %4$lld"; @@ -5209,6 +5232,7 @@ "logs.window.repeatedLldTimesLast" = "Repeated %1$lld times · last %2$@"; "logs.window.restoreDirectory" = "Restore Default Directory"; "logs.window.revealDirectory" = "Reveal Log Directory in Finder"; +"logs.window.reversedTime" = "The end time must be after the start time."; "logs.window.saveCurrentFilter" = "Save current filter…"; "logs.window.savedFilters" = "saved filters"; "logs.window.selected" = "δ selected"; @@ -5218,9 +5242,13 @@ "logs.window.showOnlyThisChannel" = "Show only this channel"; "logs.window.showOnlyThisEvent" = "Show only this event"; "logs.window.sourceLine" = "line %1$lld"; +"logs.window.startTime" = "Start time"; "logs.window.tailing" = "tailing"; "logs.window.thisRecordHasNoOpId" = "This record has no op id"; "logs.window.time" = "Time"; +"logs.window.timeHelp" = "Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it."; +"logs.window.timeRange" = "Time range"; +"logs.window.traceHelp" = "The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table."; "logs.window.traceOperation" = "trace operation"; "logs.window.traceThisOperation" = "Trace this operation"; "logs.window.unreadErrors" = "Unread errors"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 586668f35..be898cab5 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -323,6 +323,7 @@ "All configurations" = "全部配置"; "All conflicts resolved. Continue to finish, or abort to undo." = "冲突已全部解决。继续以完成操作,或中止以撤销。"; "All conversations" = "全部会话"; +"All language servers" = "全部语言服务器"; "All messages" = "全部消息"; "All projects" = "所有项目"; "All projects · project can override" = "所有项目 · 项目可覆盖"; @@ -689,6 +690,7 @@ "Choose one of the offered values." = "请从提供的选项中选择。"; "Choose path" = "选择路径"; "Choose services to run" = "选择要运行的服务"; +"Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it." = "选择开始与结束日期,再编辑时、分、秒。界面使用本地时区;点击“应用”后包含结束秒内的全部记录。范围同样限制实时日志,并与其他条件取交集。也可拖动事件分布选择范围;“清除时间范围”移除限制。"; "Choose two branches" = "请选择两个分支"; "Choose what Pull does when your branch and the remote branch have diverged." = "选择本地分支和远程分支分叉时 Pull 的处理方式。"; "Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped." = "选择退出 Lithe 或关闭终端时,如何处理在 Lithe 中启动的程序。在 Lithe 之外启动的程序不会被列出,也不会被结束。"; @@ -748,6 +750,7 @@ "Clear user filter" = "清除用户筛选"; "Clear view" = "清空显示"; "Clearing…" = "正在清空…"; +"Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction." = "点击选择一个通道;按住 ⌘ 点击可添加或移除通道,按住 Shift 点击可连续选择。选中的通道之间是“或”,再与级别、时间和文本条件取交集。“全部通道”清除通道限制。"; "Click the editor gutter to add a breakpoint" = "点击编辑器左侧行号区域添加断点"; "Click the folded options to expand; right-click for command details." = "点击折叠参数可展开;右键查看命令详情。"; "Click to jump · drag for time range" = "点击跳转 · 拖动框选时间"; @@ -1446,6 +1449,7 @@ "End This Terminal" = "结束此终端"; "End all" = "全部结束"; "End on next launch" = "下次启动时结束"; +"End time" = "结束时间"; "End without asking" = "直接结束"; "English" = "英文"; "Enlarge image" = "放大查看图片"; @@ -2073,6 +2077,7 @@ "Labels and assignees" = "标签和负责人"; "Language" = "语言"; "Language Server" = "语言服务器"; +"Language Server Logs" = "语言服务器日志"; "Language Server Settings..." = "语言服务器设置..."; "Language Service" = "语言服务"; "Language Services" = "语言服务"; @@ -2183,6 +2188,7 @@ "Lithe: prune stale branches" = "Lithe:清理失效的远程分支"; "Little Endian" = "小端序"; "Live follow" = "实时跟随"; +"Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs." = "实时跟随按记录顺序追加日志并滚动到最新记录。写入器按短批次交付,突发日志反映实际活动。向上滚动会暂停自动跟随,日志收集继续;恢复跟随可回到最新记录。"; "Load" = "加载"; "Load %lld more" = "再加载 %lld 项"; "Load Disk Version" = "加载磁盘版本"; @@ -2595,6 +2601,7 @@ "No matching commits in the loaded history." = "已加载的历史中没有匹配的提交。"; "No matching conversations" = "没有匹配的会话"; "No matching items" = "没有匹配项"; +"No matching language server logs" = "没有匹配的语言服务器日志"; "No matching messages" = "没有匹配的消息"; "No matching plugins" = "没有匹配的插件"; "No matching skills." = "没有匹配的技能。"; @@ -3667,6 +3674,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" = "服务器默认"; @@ -3791,6 +3799,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 the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics." = "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。"; "Side-by-side" = "并排"; "Side-by-side view" = "并排视图"; "Sign in again" = "重新登录"; @@ -3870,6 +3879,7 @@ "Source Repository" = "源码仓库"; "Source Roots" = "源码目录"; "Sources Root" = "源代码根目录"; +"Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results." = "空格分隔的条件同时满足(AND)。level>=warn 包含 WARN 和 ERROR;level:info 精确匹配级别。event:、channel: 和 op: 按前缀匹配;server:jdtls 和 key:value 匹配字段中的文本。/正则/ 搜索消息,忽略大小写;前置 - 排除该条件。含空格的值用双引号包住。左侧通道、级别按钮和时间范围与表达式取交集;语法错误时保留上一次有效结果。"; "Speed" = "速度"; "Spring Boot" = "Spring Boot"; "Spring Endpoints" = "Spring 接口"; @@ -3918,6 +3928,7 @@ "Start the discussion or submit the first review." = "发起讨论或提交第一次审查。"; "Start the session by sending a message" = "发送消息以开始会话"; "Start this configuration to see its output here." = "启动此配置后,输出将显示在这里。"; +"Start time" = "开始时间"; "Start with an action verb, without a type prefix" = "以动作动词开头,不添加类型前缀"; "Started in Claude Desktop" = "已在 Claude Desktop 中开始"; "Starting" = "正在启动"; @@ -4167,6 +4178,7 @@ "The bundled JDT LS manifest is invalid at %@." = "%@ 处内置的 JDT LS 清单无效。"; "The bundled JDT LS version could not be determined." = "无法确定内置 JDT LS 的版本。"; "The bundled palette is an approximation; custom theme colors are not imported." = "将近似映射为 Lithe 内置配色,不导入自定义主题颜色。"; +"The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table." = "调用链按时间顺序列出同一操作 ID 的日志。+N ms 是距离首条日志的时间差,不是该事件的执行耗时;右侧条形表示它在整个操作时间跨度中的位置。点击记录可定位到上方表格。"; "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record." = "检出目录已不存在。你可以修复移动后的工作树元数据,或清理失效的 Git 记录。"; "The checkout path does not exist" = "检出路径不存在"; "The chosen file is missing or not executable." = "所选文件不存在或不可执行。"; @@ -4213,6 +4225,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。"; "The downloaded update failed its checksum verification." = "下载的更新未通过校验。"; "The edit stop changed. Refresh the session before amending." = "编辑暂停位置已更改。请刷新会话后再修改提交。"; +"The end time must be after the start time." = "结束时间不能早于开始时间。"; "The environment snapshot could not be read. Refresh to try again." = "无法读取环境快照,请刷新重试。"; "The expression does not hold an address" = "该表达式的值不是地址"; "The featured MCP catalog could not be loaded." = "无法加载内置 MCP 精选目录。"; @@ -4545,6 +4558,7 @@ "Threads" = "线程"; "Threads & Variables" = "线程和变量"; "Time" = "时间"; +"Time range" = "时间范围"; "Time to exit" = "等待退出时间"; "Timed Out" = "已超时"; "Timeout (seconds)" = "超时(秒)"; @@ -5155,6 +5169,11 @@ "logs.filter.regexTooLong" = "正则表达式过长"; "logs.filter.unterminatedQuote" = "请补齐引号"; "logs.filter.unterminatedRegex" = "请用 / 结束正则表达式"; +"logs.language.allServers" = "全部语言服务器"; +"logs.language.empty" = "没有匹配的语言服务器日志"; +"logs.language.outputHelp" = "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。"; +"logs.language.outputTitle" = "服务器日志"; +"logs.language.windowTitle" = "语言服务器日志"; "logs.window.absoluteTime" = "绝对时间"; "logs.window.allChannels" = "全部通道"; "logs.window.autoOffIn" = "自动关闭倒计时 %1$@"; @@ -5163,6 +5182,7 @@ "logs.window.category" = "类别=%1$@"; "logs.window.changeDirectory" = "更改日志目录…"; "logs.window.channel" = "通道"; +"logs.window.channelHelp" = "点击选择一个通道;按住 ⌘ 点击可添加或移除通道,按住 Shift 点击可连续选择。选中的通道之间是“或”,再与级别、时间和文本条件取交集。“全部通道”清除通道限制。"; "logs.window.clearOnlyTheDisplayLogFilesAreKept" = "只清空显示,保留磁盘上的日志文件"; "logs.window.clearStorage" = "清理日志(已用 %1$@)…"; "logs.window.clearTimeRange" = "清除时间范围"; @@ -5181,6 +5201,7 @@ "logs.window.disableDiagnostics" = "关闭"; "logs.window.droppedLld" = "已丢弃 %1$lld"; "logs.window.enableDiagnostics" = "开启"; +"logs.window.endTime" = "结束时间"; "logs.window.event" = "事件"; "logs.window.eventsOverTime" = "事件分布"; "logs.window.export" = "导出"; @@ -5188,7 +5209,9 @@ "logs.window.fields" = "字段"; "logs.window.filterChannel" = "只看此通道"; "logs.window.filterErrorAtLldLld" = "过滤错误,位置 %1$lld–%2$lld:%3$@"; +"logs.window.filterHelp" = "空格分隔的条件同时满足(AND)。level>=warn 包含 WARN 和 ERROR;level:info 精确匹配级别。event:、channel: 和 op: 按前缀匹配;server:jdtls 和 key:value 匹配字段中的文本。/正则/ 搜索消息,忽略大小写;前置 - 排除该条件。含空格的值用双引号包住。左侧通道、级别按钮和时间范围与表达式取交集;语法错误时保留上一次有效结果。"; "logs.window.filterServerJdtlsLevelWarnEventLspTraceReceivedRegexOpC0ffee" = "过滤:server:jdtls level>=warn -event:lsp.trace.received /正则/ op:c0ffee"; +"logs.window.followHelp" = "实时跟随按记录顺序追加日志并滚动到最新记录。写入器按短批次交付,突发日志反映实际活动。向上滚动会暂停自动跟随,日志收集继续;恢复跟随可回到最新记录。"; "logs.window.following" = "跟随中"; "logs.window.hideThisEvent" = "隐藏这个事件"; "logs.window.histogramTooltip" = "%1$@ · ERROR %2$lld · WARN %3$lld · 其它 %4$lld"; @@ -5209,6 +5232,7 @@ "logs.window.repeatedLldTimesLast" = "重复 %1$lld 次 · 末次 %2$@"; "logs.window.restoreDirectory" = "恢复默认目录"; "logs.window.revealDirectory" = "在访达中显示日志目录"; +"logs.window.reversedTime" = "结束时间不能早于开始时间。"; "logs.window.saveCurrentFilter" = "保存当前过滤…"; "logs.window.savedFilters" = "书签"; "logs.window.selected" = "相对选中行"; @@ -5218,9 +5242,13 @@ "logs.window.showOnlyThisChannel" = "只看这个通道"; "logs.window.showOnlyThisEvent" = "只看这个事件"; "logs.window.sourceLine" = "第 %1$lld 行"; +"logs.window.startTime" = "开始时间"; "logs.window.tailing" = "跟随中"; "logs.window.thisRecordHasNoOpId" = "这条记录没有 op"; "logs.window.time" = "时间"; +"logs.window.timeHelp" = "选择开始与结束日期,再编辑时、分、秒。界面使用本地时区;点击“应用”后包含结束秒内的全部记录。范围同样限制实时日志,并与其他条件取交集。也可拖动事件分布选择范围;“清除时间范围”移除限制。"; +"logs.window.timeRange" = "时间范围"; +"logs.window.traceHelp" = "调用链按时间顺序列出同一操作 ID 的日志。+N ms 是距离首条日志的时间差,不是该事件的执行耗时;右侧条形表示它在整个操作时间跨度中的位置。点击记录可定位到上方表格。"; "logs.window.traceOperation" = "追踪此操作"; "logs.window.traceThisOperation" = "追踪这个操作"; "logs.window.unreadErrors" = "未查看的错误"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 5cfe567ca..ae4cdfe86 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -11,6 +11,7 @@ import LitheLanguageIntelligenceModule @MainActor final class AppServices { var logWindowCoordinator: LogWindowCoordinator? + var languageServerLogsCoordinator: LanguageServerLogsCoordinator? var logWindowLogging: (any LogWindowLogging)? /// Process log sink supplied by the composition root; modules receive the same instance. var logSink: (any LogSink)? diff --git a/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift new file mode 100644 index 000000000..4c7de6aa1 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation +import LitheModuleAPI + +struct LanguageServerLogsRequest: Equatable { + let providerID: String + let servers: [LSPStatusBarServer] +} + +/// Separate presentation from application diagnostics, backed by the same redacted evidence. +@MainActor +final class LanguageServerLogsCoordinator: ObservableObject { + @Published private(set) var request: LanguageServerLogsRequest? + var present: (() -> Void)? + func open(providerID: String, servers: [LSPStatusBarServer]) { + request = .init(providerID: providerID, servers: servers) + present?() + } +} + +@MainActor +final class LanguageServerLogsModel: ObservableObject { + let logs: LogWindowModel + @Published private(set) var servers: [LSPStatusBarServer] = [] + @Published private(set) var providerID = "" + @Published private(set) var error: String? + private let logging: any LogWindowLogging + private let sink: (any LogSink)? + private var selectionTask: Task? + private var revision = 0 + private var running = false + + init(logging: any LogWindowLogging, sink: (any LogSink)? = nil) { + self.logging = logging; self.sink = sink + var preferences = LogWindowPreferences() + preferences.collapse = false + preferences.query.channelPrefixes = ["lsp"] + logs = LogWindowModel(logging: logging, preferences: preferences, sink: sink) + } + deinit { selectionTask?.cancel() } + func apply(_ request: LanguageServerLogsRequest) { + servers = request.servers + select(request.providerID) + } + func start() { + guard !running else { return } + running = true + logs.start() + } + func select(_ id: String) { + providerID = id + revision += 1 + let revision = revision + selectionTask?.cancel() + selectionTask = Task { [weak self, logging] in + do { + let channel = id.isEmpty ? "lsp" : try await logging.windowEngineChannel(providerID: id) + guard let self, !Task.isCancelled, self.revision == revision else { return } + self.error = nil + self.logs.changeQuery { $0.channelPrefixes = [channel] } + } catch { + guard let self, !Task.isCancelled, self.revision == revision else { return } + self.error = error.localizedDescription + self.sink?.emit(AppLoggingDiagnosticsFailed(error: error)) + } + } + } + func stop() { + running = false; revision += 1 + selectionTask?.cancel(); selectionTask = nil + logs.stop() + } +} diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index b918b8660..b6f89de55 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -492,6 +492,8 @@ struct LitheApp: App { @StateObject private var logWindowCoordinator: LogWindowCoordinator @StateObject private var logWindowModel: LogWindowModel @StateObject private var logExportFeature: DiagnosticsFeatureModel + @StateObject private var languageServerLogsCoordinator: LanguageServerLogsCoordinator + @StateObject private var languageServerLogsModel: LanguageServerLogsModel private let applicationLogWriter: MacLogStore init() { @@ -521,6 +523,7 @@ struct LitheApp: App { } ) let logWindowCoordinator = LogWindowCoordinator() + let languageServerLogsCoordinator = LanguageServerLogsCoordinator() let applicationLogWriter = MacServiceContainer.makeApplicationLogWriter { [weak settings] in Task { @MainActor [weak settings] in settings?.setCustomLogDirectory(nil) } } @@ -537,6 +540,8 @@ struct LitheApp: App { } self.applicationLogWriter = applicationLogWriter _logWindowCoordinator = StateObject(wrappedValue: logWindowCoordinator) + _languageServerLogsCoordinator = StateObject(wrappedValue: languageServerLogsCoordinator) + _languageServerLogsModel = StateObject(wrappedValue: LanguageServerLogsModel(logging: applicationLogWriter, sink: applicationLogWriter)) settings.logWindowSink = applicationLogWriter let logWindowModel = LogWindowModel(logging: applicationLogWriter, preferences: settings.logWindowPreferences, sink: applicationLogWriter, save: { [weak settings] in settings?.logWindowPreferences = $0 }) @@ -585,6 +590,7 @@ struct LitheApp: App { networkSessionProvider: networkSessionProvider ).services services.logWindowCoordinator = logWindowCoordinator + services.languageServerLogsCoordinator = languageServerLogsCoordinator logWindowModel.environment = .init(appVersion: logWindowModel.environment.appVersion, osVersion: logWindowModel.environment.osVersion, architecture: logWindowModel.environment.architecture, plugins: services.pluginManager.snapshots.filter { $0.isEnabled }.map(\.id.rawValue)) @@ -915,6 +921,15 @@ struct LitheApp: App { .defaultSize(width: 1340, height: 786) .windowResizability(.contentMinSize) + Window(L10n.string("Language Server Logs"), id: LitheWindowID.languageServerLogs) { + LanguageServerLogsView(feature: languageServerLogsModel, coordinator: languageServerLogsCoordinator) + .environment(\.locale, settings.language.locale) + .preferredColorScheme(settings.themePreference.preferredColorScheme) + .id(settings.language) + } + .defaultSize(width: 1100, height: 720) + .windowResizability(.contentMinSize) + Window(settingsWindowTitle(for: settings.language), id: LitheWindowID.settings) { SettingsWindowHost(projectSessions: projectSessions, settings: settings) .tint(LitheTheme.accent) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index 274146aa8..bcb2f39a3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -50,15 +50,7 @@ extension AppModel { } func showLanguageServerLogs(providerID: String) { - guard let logging = services.logWindowLogging else { return } - Task { - do { - let channel = try await logging.windowEngineChannel(providerID: providerID) - openLogs(.init(channelPrefixes: [channel])) - } catch { - services.logSink?.emit(AppLoggingDiagnosticsFailed(error: error)) - } - } + services.languageServerLogsCoordinator?.open(providerID: providerID, servers: statusBarLanguageServers) } @discardableResult diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift index df63e73a3..ff9e1fa94 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift @@ -221,6 +221,8 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { if MacShortcutInputPolicy.shouldDeferToMarkedText(event.window?.firstResponder) { return event } + // Windows such as Logs own Find before the workspace keymap. + if MacWindowFindTarget.handle(event) { return nil } // A focused HTML preview page owns its find, zoom and print // chords; the keymap would otherwise take ⌘F before the page. // Note: 预览页面快捷键的归属见 .agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacWindowFindTarget.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacWindowFindTarget.swift new file mode 100644 index 000000000..7699bc979 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacWindowFindTarget.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +/// A window owns Find before the workspace keymap. The native target also works +/// when no project window (and therefore no workspace shortcut detector) exists. +struct MacWindowFindCommand: NSViewRepresentable { + let focus: () -> Void + func makeNSView(context: Context) -> MacWindowFindTarget { MacWindowFindTarget() } + func updateNSView(_ view: MacWindowFindTarget, context: Context) { view.focus = focus } + static func dismantleNSView(_ view: MacWindowFindTarget, coordinator: ()) { view.stop() } +} + +final class MacWindowFindTarget: NSView { + var focus: (() -> Void)? + private var monitor: Any? + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + stop() + guard window != nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, event.window === self.window else { return event } + return Self.handle(event) ? nil : event + } + } + static func handle(_ event: NSEvent) -> Bool { + guard event.type == .keyDown, + event.modifierFlags.intersection([.command, .shift, .option, .control]) == .command, + event.charactersIgnoringModifiers?.lowercased() == "f", + let window = event.window, window.attachedSheet == nil, + !MacShortcutInputPolicy.shouldDeferToMarkedText(window.firstResponder), + let root = window.contentView else { return false } + func target(in view: NSView) -> MacWindowFindTarget? { + if let target = view as? MacWindowFindTarget { return target } + for child in view.subviews { if let found = target(in: child) { return found } } + return nil + } + guard let target = target(in: root), let focus = target.focus else { return false } + focus() + return true + } + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + } + deinit { if let monitor { NSEvent.removeMonitor(monitor) } } +} diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 84e1b6f95..7ca128cd0 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -6,6 +6,7 @@ import SwiftUI enum LitheWindowID { static let logs = "logs" + static let languageServerLogs = "language-server-logs" static let welcome = "welcome" static let settings = "settings" static let project = "project" @@ -296,6 +297,7 @@ private struct ActiveSessionChrome: View { ) .onAppear { session.services.logWindowCoordinator?.present = { openWindow(id: LitheWindowID.logs) } + session.services.languageServerLogsCoordinator?.present = { openWindow(id: LitheWindowID.languageServerLogs) } } .onReceive(session.workbenchFeature.$settingsCategoryRequest.dropFirst()) { _ in SettingsWindowChrome.ownerWindow = NSApp.orderedWindows.first { diff --git a/macos/Sources/Lithe/Views/Diagnostics/LogChannelList.swift b/macos/Sources/Lithe/Views/Diagnostics/LogChannelList.swift new file mode 100644 index 000000000..8c6d11891 --- /dev/null +++ b/macos/Sources/Lithe/Views/Diagnostics/LogChannelList.swift @@ -0,0 +1,124 @@ +import AppKit +import SwiftUI +import LitheModuleAPI + +/// Native selection retains the mouse event's modifiers, including Command-click. +/// Shared list chrome still owns selection geometry, colors and scrolling. +struct LogChannelList: NSViewRepresentable, LitheTreeListNativeRenderer { + let rows: [LogWindowView.ChannelRow] + let unread: Set + @Binding var selection: Set + + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + let table = LogChannelTableView() + table.headerView = nil + table.rowHeight = LitheTheme.Tree.rowHeight + table.intercellSpacing = .zero + table.backgroundColor = .clear + table.selectionHighlightStyle = .none + table.allowsMultipleSelection = true + table.allowsEmptySelection = true + table.focusRingType = .none + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + let column = NSTableColumn(identifier: .init("channel")) + column.width = 212 + table.addTableColumn(column) + table.delegate = context.coordinator + table.dataSource = context.coordinator + table.setAccessibilityLabel(L10n.string("All channels")) + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.documentView = table + return scroll + } + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let table = scroll.documentView as? NSTableView else { return } + context.coordinator.parent = self + context.coordinator.updating = true + table.reloadData() + table.selectRowIndexes(IndexSet(rows.indices.filter { rows[$0].root == nil && selection.contains(rows[$0].id) }), byExtendingSelection: false) + context.coordinator.updating = false + } + static func dismantleNSView(_ scroll: NSScrollView, coordinator: Coordinator) { + (scroll.documentView as? NSTableView)?.delegate = nil + (scroll.documentView as? NSTableView)?.dataSource = nil + } + @MainActor final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate { + var parent: LogChannelList + var updating = false + init(_ parent: LogChannelList) { self.parent = parent } + func numberOfRows(in tableView: NSTableView) -> Int { parent.rows.count } + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { parent.rows[row].root == nil } + func tableViewSelectionDidChange(_ notification: Notification) { + guard !updating, let table = notification.object as? NSTableView else { return } + parent.selection = Set(table.selectedRowIndexes.map { parent.rows[$0].id }) + } + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { ChannelRowView() } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let item = parent.rows[row] + let cell = tableView.makeView(withIdentifier: .init("channel"), owner: self) as? ChannelCell ?? ChannelCell() + cell.identifier = .init("channel") + if let root = item.root { + cell.name.stringValue = root + cell.name.textColor = LogWindowPalette.tx3 + cell.count.stringValue = "" + } else { + let parts = item.id.split(separator: "/") + let title = item.id.isEmpty ? L10n.string("All channels") : parts.count == 1 ? item.id : parts.dropFirst().filter { $0 != "host" }.joined(separator: "/") + cell.name.stringValue = (item.id.isEmpty ? "□ " : "■ ") + title + (parent.unread.contains(item.id) ? " ●" : "") + cell.name.textColor = item.id.isEmpty ? LogWindowPalette.tx : LogWindowPalette.channel(item.id) + cell.count.stringValue = item.count.formatted(.number.locale(Locale(identifier: "en_US"))) + } + cell.setAccessibilityIdentifier("logs.channel." + item.id) + cell.setAccessibilityLabel(cell.name.stringValue) + return cell + } + } +} + +final class LogChannelTableView: NSTableView { + private var anchor: String? + override func mouseDown(with event: NSEvent) { + let index = row(at: convert(event.locationInWindow, from: nil)) + guard index >= 0, delegate?.tableView?(self, shouldSelectRow: index) != false else { return } + window?.makeFirstResponder(self) + let visible = (0.. Date? { + guard let value else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + } + private func label(_ date: Date) -> String { + date.formatted(.dateTime.month(.twoDigits).day(.twoDigits).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) + } +} + +private struct LogTimeRangeEditor: View { + @State var start: Date + @State var end: Date + let apply: (Date, Date) -> Void + let clear: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Time range") + LitheInfoTip("logs.window.timeHelp") + } + HStack(alignment: .top, spacing: 16) { + endpoint("Start time", date: $start) + endpoint("End time", date: $end) + } + Text(verbatim: TimeZone.current.localizedName(for: .standard, locale: InterfaceLanguage.locale) ?? TimeZone.current.identifier) + .font(LitheTheme.uiFont(size: 11)).foregroundStyle(LitheTheme.secondaryText) + if start > end { Text("The end time must be after the start time.").foregroundStyle(LitheTheme.error) } + HStack { + Button("Clear time range", action: clear) + .accessibilityIdentifier("logs.clear-time-range") + Spacer() + Button("Apply") { apply(start, end) }.disabled(start > end) + .accessibilityIdentifier("logs.apply-time-range") + } + }.padding(12).frame(width: 536).font(LitheTheme.uiFont(size: 12)) + } + private func endpoint(_ title: LocalizedStringKey, date: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + LogNativeDatePicker(date: date, calendar: true).frame(width: 248, height: 150) + LogNativeDatePicker(date: date, calendar: false).frame(width: 248, height: 26) + } + } +} + +private struct LogNativeDatePicker: NSViewRepresentable { + @Binding var date: Date + let calendar: Bool + func makeCoordinator() -> Coordinator { Coordinator(self) } + func makeNSView(context: Context) -> NSDatePicker { + let picker = NSDatePicker() + picker.datePickerStyle = calendar ? .clockAndCalendar : .textFieldAndStepper + picker.datePickerElements = calendar ? .yearMonthDay : .hourMinuteSecond + picker.timeZone = .current + picker.target = context.coordinator + picker.action = #selector(Coordinator.changed(_:)) + return picker + } + func updateNSView(_ picker: NSDatePicker, context: Context) { + context.coordinator.parent = self + picker.locale = InterfaceLanguage.locale + if picker.dateValue != date { picker.dateValue = date } + } + @MainActor final class Coordinator: NSObject { + var parent: LogNativeDatePicker + init(_ parent: LogNativeDatePicker) { self.parent = parent } + @objc func changed(_ picker: NSDatePicker) { parent.date = picker.dateValue } + } +} diff --git a/macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift b/macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift index 482fe7a73..4deb305c6 100644 --- a/macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift +++ b/macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift @@ -1,39 +1,6 @@ import AppKit import SwiftUI -struct LogChannelRow: View { - let row: LogWindowView.ChannelRow - let selected: Bool - let unread: Bool - let action: () -> Void - @State private var hovered = false - private var shortName: String { - var parts = row.id.split(separator: "/").map(String.init) - if parts.count > 1 { parts.removeFirst() } - if parts.count > 1 && parts.last == "host" { parts.removeLast() } - return parts.joined(separator: "/") - } - var body: some View { - Button(action: action) { - HStack(spacing: 7) { - RoundedRectangle(cornerRadius: 2) - .fill(row.id.isEmpty ? Color.clear : Color(LogWindowPalette.channel(row.id))) - .overlay { if row.id.isEmpty { RoundedRectangle(cornerRadius: 2).stroke(Color(LogWindowPalette.tx2)) } } - .frame(width: 8, height: 8) - if row.id.isEmpty { Text("All channels") } else { Text(verbatim: shortName) } - if unread { Circle().fill(Color(LogWindowPalette.red)).frame(width: 7, height: 7).accessibilityLabel(Text("Unread errors")) } - Spacer(minLength: 0) - Text(verbatim: row.count.formatted(.number.locale(Locale(identifier: "en_US")))) - .font(LitheTheme.uiFont(size: 11, design: .monospaced)).foregroundStyle(Color(LogWindowPalette.tx3)) - }.padding(.leading, 12).padding(.trailing, 12).frame(height: 25) - .foregroundStyle(Color(selected ? LogWindowPalette.tx : LogWindowPalette.tx2)) - .background(Color(selected ? LogWindowPalette.hl : hovered ? LogWindowPalette.bg3 : LogWindowPalette.bg2)) - .overlay(alignment: .leading) { if selected { Color(LogWindowPalette.blu).frame(width: 2) } } - .contentShape(Rectangle()) - }.buttonStyle(.plain).onHover { hovered = $0 } - } -} - struct LogFollowingPulse: View { @State private var dimmed = false var body: some View { diff --git a/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift b/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift index 74c3aa5e1..910e1f093 100644 --- a/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift +++ b/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift @@ -1,4 +1,5 @@ import AppKit +import QuartzCore import SwiftUI import LitheModuleAPI @@ -30,6 +31,7 @@ struct LogWindowTable: NSViewRepresentable { value.isHidden = model.preferences.hidden.contains(column) table.addTableColumn(value) } + table.preferredWidths = Dictionary(uniqueKeysWithValues: table.tableColumns.map { ($0.identifier, $0.width) }) table.delegate = context.coordinator; table.dataSource = context.coordinator table.owner = context.coordinator let header = LogTableHeaderView() @@ -73,6 +75,9 @@ struct LogWindowTable: NSViewRepresentable { private var scrollRevision = -1 private var updating = false private var selectedRecord: RustCoreBridge.LogQueryRecord? + private var renderedTimeMode = -1 + private var renderedReference: Int64? + private var renderedSignature = "" private let timeFormatter: DateFormatter = { let formatter = DateFormatter(); formatter.dateFormat = "HH:mm:ss.SSS"; return formatter }() @@ -81,7 +86,8 @@ struct LogWindowTable: NSViewRepresentable { func update() { guard let table, let scroll else { return } selectedRecord = model.selected - if appearanceName != table.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) { + let appearanceChanged = appearanceName != table.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) + if appearanceChanged { appearanceName = table.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]); prepared.removeAll(keepingCapacity: true) } table.backgroundColor = LogWindowPalette.bg @@ -96,12 +102,43 @@ struct LogWindowTable: NSViewRepresentable { let id = LogWindowModel.recordID(record) if prepared[id] == nil { prepared[id] = prepare(record) } } - rows = model.displayRows + let nextRows = model.displayRows + let oldCount = rows.count + let reference = model.preferences.timeMode == 2 ? selectedRecord?.epochMs : nil + let presentationChanged = appearanceChanged || renderedTimeMode != model.preferences.timeMode || renderedReference != reference || renderedSignature != signature + let common = zip(rows, nextRows).prefix { $0 == $1 }.count updating = true for column in table.tableColumns { column.isHidden = model.preferences.hidden.contains(column.identifier.rawValue) } - table.reloadData() + table.fitColumnsToViewport() + rows = nextRows + // Live append and fold changes replace only the affected suffix. Native + // cells above it survive; selection and status updates do not repaint all rows. + if !presentationChanged, table.dataSource === self, table.numberOfRows == oldCount, + common >= max(0, oldCount - 1) { + table.beginUpdates() + if common < oldCount { table.removeRows(at: IndexSet(integersIn: common.. 0, rows.count > oldCount, !presentationChanged { + let point = NSPoint(x: scroll.contentView.bounds.minX, + y: max(0, table.rect(ofRow: rows.count - 1).maxY - scroll.contentView.bounds.height)) + NSAnimationContext.runAnimationGroup { context in + context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.1 + context.timingFunction = CAMediaTimingFunction(name: .linear) + scroll.contentView.animator().setBoundsOrigin(point) + } + } else { table.scrollRowToVisible(rows.count - 1) } + } else if scrollRevision != model.state.scrollRevision, let target = model.state.scrollTarget, let row = rows.firstIndex(where: { $0.id == target }) { table.scrollRowToVisible(row) } else if let anchor, let row = rows.firstIndex(where: { $0.id == anchor }) { @@ -206,9 +243,13 @@ struct LogWindowTable: NSViewRepresentable { guard !updating, let table else { return } model.select(Set(table.selectedRowIndexes.compactMap { rows.indices.contains($0) ? rows[$0].id : nil })) } - func tableViewColumnDidResize(_ notification: Notification) { - guard let column = notification.userInfo?["NSTableColumn"] as? NSTableColumn else { return } - model.setWidth(column.identifier.rawValue, width: column.width) + func commitColumnWidths(previous: [NSUserInterfaceItemIdentifier: CGFloat]) { + guard let table else { return } + for column in table.tableColumns where previous[column.identifier] != column.width { + table.preferredWidths[column.identifier] = column.width + model.setWidth(column.identifier.rawValue, width: column.width) + } + table.fitColumnsToViewport() } func copy(format: String, only: RustCoreBridge.LogQueryRecord? = nil) { let records = only.map { [$0] } ?? model.selectedRecords @@ -331,6 +372,10 @@ private final class LogScrollView: NSScrollView { var onUp: (() -> Void)? private var lastY: CGFloat = 0 private var boundsObserver: NSObjectProtocol? + override func tile() { + super.tile() + (documentView as? LogTableView)?.fitColumnsToViewport() + } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let boundsObserver { NotificationCenter.default.removeObserver(boundsObserver); self.boundsObserver = nil } @@ -352,6 +397,30 @@ private final class LogScrollView: NSScrollView { } final class LogTableView: NSTableView { weak var owner: LogWindowTable.Coordinator? + var preferredWidths: [NSUserInterfaceItemIdentifier: CGFloat] = [:] + var isResizingColumns = false + private(set) var isFittingColumns = false + + /// Widths are presentation state: resizing never reloads records or persists + /// the space added by the viewport. Narrow windows keep horizontal scrolling. + func fitColumnsToViewport() { + guard !isFittingColumns, !isResizingColumns, !preferredWidths.isEmpty, let scroll = enclosingScrollView else { return } + let visible = tableColumns.filter { !$0.isHidden } + guard !visible.isEmpty else { return } + let flexible = visible.filter { ["event", "message", "fields"].contains($0.identifier.rawValue) } + let expanding = flexible.isEmpty ? Array(visible.suffix(1)) : flexible + let baseline = visible.reduce(CGFloat.zero) { $0 + (preferredWidths[$1.identifier] ?? $1.width) + intercellSpacing.width } + let spare = max(0, scroll.contentSize.width - baseline) + let weight = expanding.reduce(CGFloat.zero) { $0 + (preferredWidths[$1.identifier] ?? $1.width) } + isFittingColumns = true + defer { isFittingColumns = false } + for column in visible { + let preferred = preferredWidths[column.identifier] ?? column.width + let extra = expanding.contains(column) ? spare * preferred / max(1, weight) : 0 + let width = preferred + extra + if abs(column.width - width) > 0.1 { column.width = width } + } + } override func keyDown(with event: NSEvent) { if event.modifierFlags.contains(.command), event.charactersIgnoringModifiers == "c" { owner?.copy(format: "json"); return } if event.keyCode == 53 { owner?.model.select([]); return } @@ -364,5 +433,12 @@ final class LogTableView: NSTableView { private final class LogTableHeaderView: NSTableHeaderView { weak var owner: LogWindowTable.Coordinator? + override func mouseDown(with event: NSEvent) { + let widths = Dictionary(uniqueKeysWithValues: (tableView?.tableColumns ?? []).map { ($0.identifier, $0.width) }) + (tableView as? LogTableView)?.isResizingColumns = true + super.mouseDown(with: event) + (tableView as? LogTableView)?.isResizingColumns = false + owner?.commitColumnWidths(previous: widths) + } override func rightMouseDown(with event: NSEvent) { owner?.headerContext(event: event) } } diff --git a/macos/Sources/Lithe/Views/Diagnostics/LogWindowView.swift b/macos/Sources/Lithe/Views/Diagnostics/LogWindowView.swift index 8b0bd78f1..5f3610cac 100644 --- a/macos/Sources/Lithe/Views/Diagnostics/LogWindowView.swift +++ b/macos/Sources/Lithe/Views/Diagnostics/LogWindowView.swift @@ -14,9 +14,6 @@ struct LogWindowView: View { @State private var namingBookmark = false @State private var appliedRevision = -1 @State private var confirmsClear = false - @State private var channelCursor: String? - @State private var channelAnchor: String? - @FocusState private var channelsFocused: Bool var body: some View { VStack(spacing: 0) { @@ -38,7 +35,8 @@ struct LogWindowView: View { HStack(spacing: 0) { sidebar.frame(width: 212) Rectangle().fill(Color(LogWindowPalette.line)).frame(width: 1) - LitheSplitPaneView(axis: .vertical, placement: .trailing, defaultSize: 196, + GeometryReader { viewport in + LitheSplitPaneView(axis: .vertical, placement: .trailing, defaultSize: max(196, viewport.size.height * 0.28), minimum: 130, maximum: 420, flexibleMinimum: 100, dividerColor: Color(LogWindowPalette.line), sized: { details }, flexible: { ZStack(alignment: .bottom) { @@ -54,6 +52,7 @@ struct LogWindowView: View { } } }) + } } status } @@ -90,7 +89,7 @@ struct LogWindowView: View { // A reference can select before the table is created; load its chain through the same path. if model.state.trace == nil, model.selected?.op != nil { model.select(model.state.selection) } } - .background(Button("") { filterFocused = true }.keyboardShortcut("f", modifiers: .command).hidden()) + .background(MacWindowFindCommand { filterFocused = true }) .confirmationDialog("Clear collected logs?", isPresented: $confirmsClear, titleVisibility: .visible) { Button("Clear Logs", role: .destructive) { Task { await exportFeature.clearLogs(); model.refresh() } @@ -122,24 +121,14 @@ struct LogWindowView: View { } private var toolbar: some View { LogToolbarLayout { - HStack(spacing: 6) { - Text(verbatim: "❯").foregroundStyle(Color(LogWindowPalette.pur)) - if let since = model.query.since, let until = model.query.until { - Button { model.changeQuery { $0.since = nil; $0.until = nil } } label: { - Text(verbatim: String((since.split(separator: "T").last ?? "").prefix(8)) + "–" + String((until.split(separator: "T").last ?? "").prefix(8)) + " ✕") - }.buttonStyle(.plain).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) - .foregroundStyle(Color(LogWindowPalette.blu)).padding(.horizontal, 6) - .background(Color(LogWindowPalette.hl), in: RoundedRectangle(cornerRadius: 4)).help("Clear time range") - } - TextField("Filter: server:jdtls level>=warn -event:lsp.trace.received /regex/ op:c0ffee", - text: Binding(get: { model.query.expression }, set: model.setExpression)) - .textFieldStyle(.plain).focused($filterFocused) + LitheSearchField("Filter: server:jdtls level>=warn -event:lsp.trace.received /regex/ op:c0ffee", + text: Binding(get: { model.query.expression }, set: model.setExpression), focus: $filterFocused) { + LitheInfoTip("logs.window.filterHelp") Text(verbatim: "⌘F").font(LitheTheme.uiFont(size: 11, design: .monospaced)) .foregroundStyle(Color(LogWindowPalette.tx2)).padding(.horizontal, 5) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color(LogWindowPalette.bd))) - }.padding(.horizontal, 8).frame(minWidth: 260, idealWidth: 260, maxWidth: .infinity).frame(height: 28) - .background(Color(LogWindowPalette.bg), in: RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(filterFocused ? LogWindowPalette.blu : LogWindowPalette.bd))) + }.frame(minWidth: 320, idealWidth: 320, maxWidth: .infinity) + .accessibilityIdentifier("logs.filter") ForEach(["ERROR", "WARN", "INFO", "DEBUG"], id: \.self) { level in let enabled = model.query.levels.contains(level) Button { @@ -159,6 +148,7 @@ struct LogWindowView: View { LitheContextMenuItem.action(sessionTitle(value), checked: model.query.session == value) { model.changeQuery { $0.session = value } } } } label: { HStack(spacing: 6) { Text(LocalizedStringKey(sessionTitle(model.query.session))); Text(verbatim: "⌄") } } + LogTimeRangePicker(model: model) LitheMenu { if model.preferences.bookmarks.isEmpty { LitheContextMenuItem.heading("No saved filters") } for bookmark in model.preferences.bookmarks { @@ -176,6 +166,7 @@ struct LogWindowView: View { Button { model.cycleTime() } label: { toolbarLabel("⏱", ["Absolute time", "Δ previous", "Δ selected"][model.preferences.timeMode]) } Button { model.toggleFollow() } label: { toolbarLabel(model.state.follows ? "●" : "❚❚", model.state.follows ? "Live follow" : "Paused") } .buttonStyle(LogButtonStyle(recording: model.state.follows)) + LitheInfoTip("logs.window.followHelp") Button { model.clearDisplay() } label: { toolbarLabel("⌫", "Clear view") }.help("Clear only the display; log files are kept") maintenanceMenu }.buttonStyle(LogButtonStyle()).padding(.horizontal, 10).padding(.vertical, 7) @@ -237,30 +228,17 @@ struct LogWindowView: View { } private var sidebar: some View { VStack(alignment: .leading, spacing: 0) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(channelRows) { row in - if let root = row.root { - Text(verbatim: root).font(LitheTheme.uiFont(size: 10.5, design: .monospaced)).tracking(0.63) - .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.leading, 12).padding(.top, 10).padding(.bottom, 3) - } - LogChannelRow(row: row, selected: row.id.isEmpty ? model.query.channelPrefixes.isEmpty : model.query.channelPrefixes.contains(row.id), - unread: model.state.unreadErrors.contains(row.id)) { - channelsFocused = true - selectChannel(row.id, flags: NSApp.currentEvent?.modifierFlags ?? []) - } - } - Text("⌘-click to select several").font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) + LitheTreeList(native: LogChannelList(rows: channelRows, unread: model.state.unreadErrors, selection: Binding( + get: { model.query.channelPrefixes.isEmpty ? [""] : Set(model.query.channelPrefixes) }, + set: { selected in + let clears = selected.contains("") && !model.query.channelPrefixes.isEmpty + model.changeQuery { $0.channelPrefixes = clears ? [] : selected.subtracting([""]).sorted() } + }))).litheScrollViewChrome() + HStack(spacing: 4) { + Text("⌘-click to select several").font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.horizontal, 12).padding(.vertical, 6) - } - }.litheScrollViewChrome().focusable().focused($channelsFocused).litheFocusEffectHidden() - .onMoveCommand { direction in - guard direction == .up || direction == .down else { return } - let rows = channelRows.map(\.id) - let index = rows.firstIndex(of: channelCursor ?? model.query.channelPrefixes.first ?? "") ?? 0 - let next = min(rows.count - 1, max(0, index + (direction == .down ? 1 : -1))) - selectChannel(rows[next], flags: NSApp.currentEvent?.modifierFlags ?? []) - } + LitheInfoTip("logs.window.channelHelp") + } if model.state.diagnosticRemaining > 0 { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(verbatim: "●"); Text("Verbose diagnostics on") }.foregroundStyle(Color(LogWindowPalette.grn)) @@ -297,14 +275,6 @@ struct LogWindowView: View { } }.padding(.top, 8).background(Color(LogWindowPalette.bg2)) } - private func selectChannel(_ id: String, flags: NSEvent.ModifierFlags) { - let selected = LitheTreeListSelection.select(id, in: channelRows.map(\.id), - selected: Set(model.query.channelPrefixes), anchor: channelAnchor ?? channelCursor, - extending: flags.contains(.shift), toggling: flags.contains(.command)) - if !flags.contains(.shift) || channelAnchor == nil { channelAnchor = id } - channelCursor = id - model.changeQuery { $0.channelPrefixes = selected.contains("") ? [] : selected.sorted() } - } @State private var cardStarted = Date() struct ChannelRow: Identifiable { let id: String; let count: Int; var root: String? = nil } private var channelRows: [ChannelRow] { @@ -316,11 +286,12 @@ struct LogWindowView: View { let ai = order.firstIndex(of: a) ?? order.count, bi = order.firstIndex(of: b) ?? order.count return ai == bi ? $0 < $1 : ai < bi } - return [.init(id: "", count: model.state.channels.values.reduce(0) { $0 + $1.total })] + channels.map { channel in + return [.init(id: "", count: model.state.channels.values.reduce(0) { $0 + $1.total })] + channels.flatMap { channel -> [ChannelRow] in let root = channel.split(separator: "/").first.map(String.init) ?? channel let heading = root == previous ? nil : root + "/" previous = root - return .init(id: channel, count: model.state.channels[channel]!.total, root: heading) + let row = ChannelRow(id: channel, count: model.state.channels[channel]!.total) + return heading.map { [.init(id: "heading:" + root, count: 0, root: $0), row] } ?? [row] } } private var details: some View { @@ -329,10 +300,11 @@ struct LogWindowView: View { HStack(spacing: 2) { detailTab("Detail", index: 0) detailTab("Call chain", index: 1, count: model.state.trace?.opSummary?.count) + LitheInfoTip("logs.window.traceHelp") Spacer(minLength: 0) }.padding(.horizontal, 8).padding(.top, 4).overlay(alignment: .bottom) { rule } if tab == 1 { trace } else { fieldDetails } - } + }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) Rectangle().fill(Color(LogWindowPalette.line)).frame(width: 1) ScrollView { VStack(alignment: .leading, spacing: 5) { @@ -346,7 +318,7 @@ struct LogWindowView: View { .font(LitheTheme.uiFont(size: 10, design: .monospaced)).foregroundStyle(Color(LogWindowPalette.tx3)).fixedSize(horizontal: false, vertical: true) }.buttonStyle(LogButtonStyle(height: 25, fullWidth: true)).padding(8).disabled(model.selected == nil) }.frame(width: 209).litheScrollViewChrome() - }.background(Color(LogWindowPalette.bg2)).overlay(alignment: .top) { rule } + }.clipped().background(Color(LogWindowPalette.bg2)).overlay(alignment: .top) { rule } } private func detailTab(_ title: String, index: Int, count: Int? = nil) -> some View { Button { tab = index } label: { @@ -357,49 +329,53 @@ struct LogWindowView: View { }.buttonStyle(.plain).accessibilityIdentifier(index == 0 ? "logs.detail-tab" : "logs.trace-tab") } private var fieldDetails: some View { - ScrollView([.vertical, .horizontal]) { - VStack(alignment: .leading, spacing: 0) { - if let record = model.selected { - Text(verbatim: "// " + record.relativePath.replacingOccurrences(of: model.state.currentSessionPath + "/", with: "") + " · " + String(format: L10n.string("Line %lld"), Int64(record.lineNumber))) - .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.bottom, 2) - Text(json).textSelection(.enabled) - .environment(\.openURL, OpenURLAction { url in - guard url.scheme == "log-field", let key = url.host, let value = record.fields[key] else { return .discarded } - Task { await model.compose(kind: "field", key: key, value: value) }; return .handled - }) - } - }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 14).padding(.vertical, 8) - }.font(LitheTheme.uiFont(size: 12.5, design: .monospaced)).litheScrollViewChrome() + GeometryReader { viewport in + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 0) { + if let record = model.selected { + Text(verbatim: "// " + record.relativePath.replacingOccurrences(of: model.state.currentSessionPath + "/", with: "") + " · " + String(format: L10n.string("Line %lld"), Int64(record.lineNumber))) + .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.bottom, 2) + Text(json).textSelection(.enabled) + .environment(\.openURL, OpenURLAction { url in + guard url.scheme == "log-field", let key = url.host, let value = record.fields[key] else { return .discarded } + Task { await model.compose(kind: "field", key: key, value: value) }; return .handled + }) + } + }.frame(minWidth: max(0, viewport.size.width - 28), alignment: .leading).padding(.horizontal, 14).padding(.vertical, 8) + }.font(LitheTheme.uiFont(size: 12.5, design: .monospaced)).litheScrollViewChrome() + } } private var trace: some View { - ScrollView([.vertical, .horizontal]) { - LazyVStack(alignment: .leading, spacing: 0) { - if model.selected?.op == nil { Text("This record has no op id").foregroundStyle(Color(LogWindowPalette.tx3)) } - if let result = model.state.trace { - if let summary = result.opSummary { - Text(String(format: L10n.string("Op %@ · %lld records · %@"), summary.op, Int64(summary.count), "\(summary.durationMs) ms")) - .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.bottom, 4) - } - ForEach(result.records, id: \.line) { record in - HStack(spacing: 10) { - Text(verbatim: "+\(record.opOffsetMs ?? 0) ms").foregroundStyle(Color(LogWindowPalette.tx3)).frame(width: 70, alignment: .leading) - Text(verbatim: LogWindowPalette.symbol(record.level) + " " + record.level) - .foregroundStyle(Color(LogWindowPalette.level(record.level))).frame(width: 64, alignment: .leading) - Text(verbatim: record.channel).padding(.horizontal, 5).foregroundStyle(Color(LogWindowPalette.channel(record.channel))) - .background(Color(LogWindowPalette.channel(record.channel)).opacity(0.15), in: RoundedRectangle(cornerRadius: 3)).frame(width: 150, alignment: .leading) - Text(verbatim: record.event).foregroundStyle(Color(LogWindowPalette.pur)).frame(width: 210, alignment: .leading) - Text(verbatim: record.msg).frame(width: 180, alignment: .leading) - GeometryReader { geometry in - RoundedRectangle(cornerRadius: 3).fill(Color(record.level == "ERROR" ? LogWindowPalette.red : LogWindowPalette.pur).opacity(0.7)) - .frame(width: max(3, geometry.size.width * CGFloat(record.opOffsetMs ?? 0) / CGFloat(max(1, result.opSummary?.durationMs ?? 1))), height: 6).frame(maxHeight: .infinity) - }.frame(width: 160) - }.font(LitheTheme.uiFont(size: 11.5, design: .monospaced)).lineLimit(1).frame(height: 22).padding(.horizontal, 4) - .background(model.state.selection.contains(LogWindowModel.recordID(record)) ? Color(LogWindowPalette.hl) : .clear) - .contentShape(Rectangle()).onTapGesture { model.jump(epochMs: record.epochMs) } + GeometryReader { viewport in + ScrollView([.vertical, .horizontal]) { + LazyVStack(alignment: .leading, spacing: 0) { + if model.selected?.op == nil { Text("This record has no op id").foregroundStyle(Color(LogWindowPalette.tx3)) } + if let result = model.state.trace { + if let summary = result.opSummary { + Text(String(format: L10n.string("Op %@ · %lld records · %@"), summary.op, Int64(summary.count), "\(summary.durationMs) ms")) + .foregroundStyle(Color(LogWindowPalette.tx3)).padding(.bottom, 4) + } + ForEach(result.records, id: \.line) { record in + HStack(spacing: 10) { + Text(verbatim: "+\(record.opOffsetMs ?? 0) ms").foregroundStyle(Color(LogWindowPalette.tx3)).frame(width: 70, alignment: .leading) + Text(verbatim: LogWindowPalette.symbol(record.level) + " " + record.level) + .foregroundStyle(Color(LogWindowPalette.level(record.level))).frame(width: 64, alignment: .leading) + Text(verbatim: record.channel).padding(.horizontal, 5).foregroundStyle(Color(LogWindowPalette.channel(record.channel))) + .background(Color(LogWindowPalette.channel(record.channel)).opacity(0.15), in: RoundedRectangle(cornerRadius: 3)).frame(width: 150, alignment: .leading) + Text(verbatim: record.event).foregroundStyle(Color(LogWindowPalette.pur)).frame(width: 210, alignment: .leading) + Text(verbatim: record.msg).frame(minWidth: 180, maxWidth: .infinity, alignment: .leading) + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 3).fill(Color(record.level == "ERROR" ? LogWindowPalette.red : LogWindowPalette.pur).opacity(0.7)) + .frame(width: max(3, geometry.size.width * CGFloat(record.opOffsetMs ?? 0) / CGFloat(max(1, result.opSummary?.durationMs ?? 1))), height: 6).frame(maxHeight: .infinity) + }.frame(width: 160) + }.font(LitheTheme.uiFont(size: 11.5, design: .monospaced)).lineLimit(1).frame(height: 22).padding(.horizontal, 4) + .background(model.state.selection.contains(LogWindowModel.recordID(record)) ? Color(LogWindowPalette.hl) : .clear) + .contentShape(Rectangle()).onTapGesture { model.jump(epochMs: record.epochMs) } + } } - } - }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 10).padding(.vertical, 6) - }.litheScrollViewChrome() + }.frame(minWidth: max(0, viewport.size.width - 20), alignment: .leading).padding(.horizontal, 10).padding(.vertical, 6) + }.litheScrollViewChrome() + } } private var status: some View { HStack(spacing: 16) { @@ -451,36 +427,50 @@ private struct LogHistogramView: View { GeometryReader { geometry in if let histogram = model.state.histogram { let maximum = max(1, histogram.buckets.map { $0.debug + $0.info + $0.warn + $0.error }.max() ?? 1) - HStack(alignment: .bottom, spacing: 1) { - ForEach(histogram.buckets.indices, id: \.self) { index in - let bucket = histogram.buckets[index] - VStack(spacing: 0) { - Spacer(minLength: 0) - Rectangle().fill(Color(LogWindowPalette.red)).frame(height: CGFloat(bucket.error) / CGFloat(maximum) * 44) - Rectangle().fill(Color(LogWindowPalette.yel)).frame(height: CGFloat(bucket.warn) / CGFloat(maximum) * 44) - Rectangle().fill(Color(LogWindowPalette.blu).opacity(0.4)).frame(height: CGFloat(bucket.info + bucket.debug) / CGFloat(maximum) * 44) - }.background { - if let start = dragStart, let end = dragEnd, (min(start, end)...max(start, end)).contains(index) { - Color(LogWindowPalette.blu).opacity(0.16) - } - }.help(String(format: L10n.string("%@ · ERROR %lld · WARN %lld · other %lld"), - Date(timeIntervalSince1970: Double(histogram.startEpochMs + Int64(index) * histogram.bucketMs) / 1000).formatted(), - Int64(bucket.error), Int64(bucket.warn), Int64(bucket.debug + bucket.info))) - } - }.contentShape(Rectangle()).gesture(DragGesture(minimumDistance: 0).onChanged { value in - if dragStart == nil { dragStart = bucket(value.startLocation.x, width: geometry.size.width, count: histogram.buckets.count) } - dragEnd = bucket(value.location.x, width: geometry.size.width, count: histogram.buckets.count) - }.onEnded { value in - let end = bucket(value.location.x, width: geometry.size.width, count: histogram.buckets.count) - let start = dragStart ?? end; dragStart = nil; dragEnd = nil - if start == end { model.jump(epochMs: histogram.startEpochMs + Int64(end) * histogram.bucketMs) } - else { model.setRange(since: histogram.startEpochMs + Int64(min(start, end)) * histogram.bucketMs, - until: histogram.startEpochMs + Int64(max(start, end) + 1) * histogram.bucketMs - 1) } - }) + VStack(spacing: 2) { + HStack(alignment: .bottom, spacing: 1) { + ForEach(histogram.buckets.indices, id: \.self) { index in + let bucket = histogram.buckets[index] + VStack(spacing: 0) { + Spacer(minLength: 0) + Rectangle().fill(Color(LogWindowPalette.red)).frame(height: CGFloat(bucket.error) / CGFloat(maximum) * 34) + Rectangle().fill(Color(LogWindowPalette.yel)).frame(height: CGFloat(bucket.warn) / CGFloat(maximum) * 34) + Rectangle().fill(Color(LogWindowPalette.blu).opacity(0.4)).frame(height: CGFloat(bucket.info + bucket.debug) / CGFloat(maximum) * 34) + }.background { + if let start = dragStart, let end = dragEnd, (min(start, end)...max(start, end)).contains(index) { + Color(LogWindowPalette.blu).opacity(0.16) + } + }.help(String(format: L10n.string("%@ · ERROR %lld · WARN %lld · other %lld"), + timestamp(histogram.startEpochMs + Int64(index) * histogram.bucketMs), + Int64(bucket.error), Int64(bucket.warn), Int64(bucket.debug + bucket.info))) + } + }.contentShape(Rectangle()).gesture(DragGesture(minimumDistance: 0).onChanged { value in + if dragStart == nil { dragStart = bucket(value.startLocation.x, width: geometry.size.width, count: histogram.buckets.count) } + dragEnd = bucket(value.location.x, width: geometry.size.width, count: histogram.buckets.count) + }.onEnded { value in + let end = bucket(value.location.x, width: geometry.size.width, count: histogram.buckets.count) + let start = dragStart ?? end; dragStart = nil; dragEnd = nil + if start == end { model.jump(epochMs: histogram.startEpochMs + Int64(end) * histogram.bucketMs) } + else { model.setRange(since: histogram.startEpochMs + Int64(min(start, end)) * histogram.bucketMs, + until: histogram.startEpochMs + Int64(max(start, end) + 1) * histogram.bucketMs - 1) } + }) + HStack { + Text(verbatim: timestamp(histogram.startEpochMs)) + Spacer(minLength: 0) + Text(verbatim: timestamp(histogram.startEpochMs + (histogram.endEpochMs - histogram.startEpochMs) / 2)) + Spacer(minLength: 0) + Text(verbatim: timestamp(histogram.endEpochMs)) + }.font(LitheTheme.uiFont(size: 10, design: .monospaced)) + .foregroundStyle(Color(LogWindowPalette.tx3)).lineLimit(1) + } } }.accessibilityLabel(Text("Events over time")) - }.padding(.horizontal, 10).padding(.top, 6).padding(.bottom, 4).frame(height: 58).background(Color(LogWindowPalette.bg2)) + }.padding(.horizontal, 10).padding(.top, 6).padding(.bottom, 4).frame(height: 72).background(Color(LogWindowPalette.bg2)) .overlay(alignment: .bottom) { Color(LogWindowPalette.line).frame(height: 1) } } + private func timestamp(_ milliseconds: Int64) -> String { + Date(timeIntervalSince1970: Double(milliseconds) / 1000) + .formatted(.dateTime.month(.twoDigits).day(.twoDigits).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) + } private func bucket(_ x: CGFloat, width: CGFloat, count: Int) -> Int { min(max(0, Int(x / max(1, width) * CGFloat(count))), max(0, count - 1)) } } diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift new file mode 100644 index 000000000..96882c8f5 --- /dev/null +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import LitheModuleAPI + +/// Server output has its own window; application diagnostics keep their separate entry. +struct LanguageServerLogsView: View { + @ObservedObject var feature: LanguageServerLogsModel + @ObservedObject var coordinator: LanguageServerLogsCoordinator + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + LitheSettingsSelect(selection: Binding(get: { feature.providerID }, set: feature.select), + options: [""] + feature.servers.map(\.id), width: 200, + accessibilityLabel: "Language Server", title: serverTitle, localizesTitles: false) + Text("Server Logs").foregroundStyle(LitheTheme.accent) + LitheInfoTip("Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.") + Spacer(minLength: 0) + }.padding(12).font(LitheTheme.uiFont(size: 12)) + Divider() + if let error = feature.error { Text(error).foregroundStyle(LitheTheme.error).padding(8) } + LanguageServerLogOutput(model: feature.logs) + } + .background(LitheTheme.editor) + .frame(minWidth: 720, minHeight: 400) + .onAppear { + if let request = coordinator.request { feature.apply(request) } + feature.start() + } + .onReceive(coordinator.$request) { request in + if let request { feature.apply(request) } + } + .onDisappear { feature.stop() } + } + private func serverTitle(_ id: String) -> String { + id.isEmpty ? L10n.string("All language servers") : feature.servers.first { $0.id == id }?.displayName ?? id + } +} + +private struct LanguageServerLogOutput: View { + @ObservedObject var model: LogWindowModel + @State private var wrapsLines = false + private var output: String { + model.state.records.map { record in + let diagnostic = record.fields["diagnostic"] ?? record.fields["error"] + let detail = diagnostic.map { $0.isEmpty ? "" : "\n" + $0 } ?? "" + return "[\(record.level)] \(record.ts) \(record.channel) \(record.event)\n\(record.msg)\(detail)\n" + }.joined() + } + var body: some View { + VStack(spacing: 0) { + if let error = model.error { + HStack { + Text(error).foregroundStyle(LitheTheme.error) + Button("Retry") { model.retry() } + }.padding(8) + } + OutputTextView(output: output, searchRoots: [], fileExists: { _ in false }, + emptyMessage: L10n.string("No matching language server logs"), wrapsLines: wrapsLines, + showsConsoleToolbar: true, onClearOutput: model.clearDisplay, + onToggleWrapsLines: { wrapsLines.toggle() }, onOpenLocation: { _, _, _ in }) + } + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift index b6f734e7e..396389148 100644 --- a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift +++ b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift @@ -60,7 +60,7 @@ enum LanguageServerStatusMenu { + servers.map { serverItem($0, viewLogs: viewLogs, restart: restart, stop: stop) } + [ .separator, - .action(L10n.string(key: "native.inline.viewLogs"), localizesTitle: false) { viewLogs(servers.first?.id ?? "") }, + .action(L10n.string(key: "native.inline.viewLogs"), localizesTitle: false) { viewLogs("") }, .action("Restart All Servers", icon: restartIcon, isEnabled: !startedServers.isEmpty) { for server in startedServers { restart(server.id) } }, diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchSettingsMenu.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchSettingsMenu.swift new file mode 100644 index 000000000..ce122e029 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchSettingsMenu.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@MainActor +enum WorkbenchSettingsMenu { + static func items(model: AppModel) -> [LitheContextMenuItem] { + [ + .action("Settings…", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/general/settings.svg", size: 16)), + initiallySelected: true, shortcut: model.keyboardShortcutFeature.displayText(for: "settings")) { + model.showSettings() + }, + .action("Plugins…", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/nodes/plugin.svg", size: 16, preservesOriginalColors: true))) { + model.showSettings(category: .plugins) + }, + .action("Logs and Diagnostics…", icon: AnyView(LitheIDEAIcon(resourcePath: "fileTypes/text.svg", size: 16, preservesOriginalColors: true))) { + model.openLogs() + }, + .separator, + .action("Theme…") { model.showSettings(category: .general) }, + .action("Keymap…") { model.showSettings(category: .keymap) } + ] + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b64c07d63..3a37e9512 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1262,17 +1262,7 @@ struct WorkbenchView: View { .accessibilityLabel(Text(LocalizedStringKey("Settings"))) .accessibilityIdentifier("workbench-settings-menu") .overlay { - LitheDropdownPopover(isPresented: $isSettingsMenuPresented, items: [ - .action("Settings…", icon: AnyView(LitheIDEAIcon(resourcePath: "expui/general/settings.svg", size: 16)), - initiallySelected: true, shortcut: model.keyboardShortcutFeature.displayText(for: "settings")) { - model.showSettings() - }, - .action("Plugins…") { model.showSettings(category: .plugins) }, - .action("Logs and Diagnostics…") { model.openLogs() }, - .separator, - .action("Theme…") { model.showSettings(category: .general) }, - .action("Keymap…") { model.showSettings(category: .keymap) } - ]) { EmptyView() } + LitheDropdownPopover(isPresented: $isSettingsMenuPresented, items: WorkbenchSettingsMenu.items(model: model)) { EmptyView() } } } diff --git a/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift b/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift index 42daaef31..f7b382889 100644 --- a/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift +++ b/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift @@ -228,6 +228,7 @@ struct DiagnosticsWorkflowTests { defer { confirmation.cancel(); feature.dismiss() } feature.presentExport(filter: model.query) try #require(await ready.waitUntilOpen(timeout: .seconds(5)), "Export manifest did not load") + feature.dismiss() let suite = "log-fidelity-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } @@ -242,12 +243,12 @@ struct DiagnosticsWorkflowTests { let mounted = TestGate() let host = NSHostingView(rootView: LogWindowView(model: model, coordinator: coordinator, exportFeature: feature) .environmentObject(settings).environment(\.colorScheme, scheme) - .environment(\.locale, Locale(identifier: language)).frame(width: 1340, height: 786) + .environment(\.locale, Locale(identifier: language)) .task { mounted.open() }) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1340, height: 786), styleMask: [.borderless], backing: .buffered, defer: false) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1340, height: 786), styleMask: [.titled, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) - window.contentView = host; window.orderFront(nil) + window.contentView = host; window.makeKeyAndOrderFront(nil) defer { window.contentView = nil; window.close() } try #require(await mounted.waitUntilOpen(), "Log view did not mount") host.layoutSubtreeIfNeeded() @@ -256,6 +257,7 @@ struct DiagnosticsWorkflowTests { #expect(table.headerView?.frame.height == 26) #expect(table.numberOfRows > 100) #expect(host.bounds.size == NSSize(width: 1340, height: 786)) + try await verifyLogWindowInteractions(host: host, window: window, table: table, model: model, record: record) if let directory = ProcessInfo.processInfo.environment["LITHE_LOGGING_SCREENSHOTS"] { try #require(Bundle.main.localizations.contains(language), "Run the packaged probe for localized screenshots") // Native accessibility is the observable boundary for the asynchronous JSON view. @@ -275,6 +277,8 @@ struct DiagnosticsWorkflowTests { } let exportHost = NSHostingView(rootView: DiagnosticsExportSheet(feature: feature) .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: language))) + feature.presentExport(filter: model.query) + try #require(await ready.waitUntilOpen(timeout: .seconds(5)), "Export did not reopen") window.contentView = exportHost exportHost.frame = NSRect(origin: .zero, size: exportHost.fittingSize) exportHost.layoutSubtreeIfNeeded() @@ -289,6 +293,80 @@ struct DiagnosticsWorkflowTests { await writer.finishSession() } + private func verifyLogWindowInteractions(host: NSView, window: NSWindow, table: LogTableView, + model: LogWindowModel, record: RustCoreBridge.LogQueryRecord) async throws { + let initialCell = table.view(atColumn: 0, row: 0, makeIfNecessary: true) + model.userScrolledUp() + host.layoutSubtreeIfNeeded() + #expect(table.view(atColumn: 0, row: 0, makeIfNecessary: true) === initialCell, + "Pausing follow must preserve existing native cells") + window.makeFirstResponder(table) + let find = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, + modifierFlags: .command, timestamp: 0, windowNumber: window.windowNumber, + context: nil, characters: "f", charactersIgnoringModifiers: "f", isARepeat: false, keyCode: 3)) + NSApp.sendEvent(find) + let focusDeadline = ContinuousClock.now.advanced(by: .seconds(2)) + while !(window.firstResponder is NSTextView), ContinuousClock.now < focusDeadline { + await Task.yield(); host.layoutSubtreeIfNeeded() + } + #expect(window.firstResponder is NSTextView, "Command-F must focus the log filter from the table") + func clickChannel(_ channel: String, flags: NSEvent.ModifierFlags = []) async throws { + let channels = try #require(renderDescendants(host).compactMap { $0 as? NSTableView }.first { !($0 is LogTableView) }) + let row = try #require((0.. [NSView] { [view] + view.subviews.flatMap(renderDescendants) } diff --git a/macos/Tests/LitheTests/LogWindowReviewTests.swift b/macos/Tests/LitheTests/LogWindowReviewTests.swift new file mode 100644 index 000000000..c248f7970 --- /dev/null +++ b/macos/Tests/LitheTests/LogWindowReviewTests.swift @@ -0,0 +1,131 @@ +import AppKit +import Combine +import SwiftUI +import Testing +import LitheModuleAPI +@testable import Lithe + +@Suite("Log window review", .serialized) +@MainActor +struct LogWindowReviewTests { + @Test func serverSelectionUsesCoreChannelsAndKeepsOtherEvidenceOut() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let writer = MacLogStore(defaultDirectory: root) + defer { try? FileManager.default.removeItem(at: root) } + writer.emit(LspMessageLogged(diagnostic: "Java initialization", server: LogSegment("java")!, severity: "info")) + writer.emit(LspMessageLogged(diagnostic: "Go initialization", server: LogSegment("go")!, severity: "info")) + writer.emit(AppSessionStarted()) + let feature = LanguageServerLogsModel(logging: writer) + let loaded = TestGate() + let observation = feature.logs.$state.sink { if !$0.records.isEmpty { loaded.open() } } + defer { observation.cancel(); feature.stop() } + feature.apply(.init(providerID: "java", servers: [])) + feature.start() + try #require(await loaded.waitUntilOpen()) + #expect(feature.logs.state.records.allSatisfy { $0.channel == "lsp/java" }) + let allLoaded = TestGate() + let all = feature.logs.$state.sink { state in + if Set(state.records.map(\.channel)) == ["lsp/java", "lsp/go"] { allLoaded.open() } + } + defer { all.cancel() } + feature.select("") + try #require(await allLoaded.waitUntilOpen()) + await writer.finishSession() + } + + /// Optional local review: read a task-owned copy of real user evidence, never fabricate records. + @Test func renderFinalReviewFromExistingSession() async throws { + guard let source = ProcessInfo.processInfo.environment["LITHE_REVIEW_LOG_SESSION"], + let output = ProcessInfo.processInfo.environment["LITHE_LOGGING_SCREENSHOTS"] else { return } + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let sourceURL = URL(fileURLWithPath: source) + let sessions = root.appendingPathComponent("logs/sessions") + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: sourceURL, to: sessions.appendingPathComponent(sourceURL.lastPathComponent)) + let writer = MacLogStore(defaultDirectory: root.appendingPathComponent("logs")) + do { + try await renderReview(writer: writer, output: output) + } catch { + await writer.finishSession() + throw error + } + await writer.finishSession() + } + + private func renderReview(writer: MacLogStore, output: String) async throws { + MacBundledFontRegistry.registerFonts(bundle: .main) + let suite = "log-review-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + let previousLanguage = InterfaceLanguage.locale.identifier + settings.language = .simplifiedChinese + defer { InterfaceLanguage.select(previousLanguage) } + let container = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode, applicationLogging: writer) + let app = AppModel(settings: settings, services: container.services) + defer { container.moduleLifecycleCoordinator.stop() } + let menu = WorkbenchSettingsMenu.items(model: app) + #expect(menu.filter { $0.title == "Plugins…" || $0.title == "Logs and Diagnostics…" }.allSatisfy { $0.icon != nil }) + let export = DiagnosticsFeatureModel(service: app.services.diagnosticsExportService) + var preferences = LogWindowPreferences(); preferences.query.session = "all" + let logs = LogWindowModel(logging: writer, preferences: preferences) + let coordinator = LogWindowCoordinator() + let loaded = TestGate() + let observation = logs.$state.sink { if !$0.records.isEmpty { loaded.open() } } + defer { observation.cancel(); logs.stop(); export.dismiss() } + logs.refresh() + try #require(await loaded.waitUntilOpen()) + if let selected = logs.state.records.last(where: { $0.op != nil }) { + logs.select([LogWindowModel.recordID(selected)]) + } + for dark in [false, true] { + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 1600, height: 1000), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let host = NSHostingView(rootView: LogWindowView(model: logs, coordinator: coordinator, exportFeature: export) + .environmentObject(settings).environment(\.locale, settings.language.locale) + .environment(\.colorScheme, dark ? .dark : .light)) + window.contentView = host; window.makeKeyAndOrderFront(nil) + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + await Task.yield() + try screenshot(host, output: output, name: dark ? "final-logs-dark.png" : "final-logs-light.png") + let presenter = LitheContextMenuPresenter() + presenter.show(items: menu, at: NSPoint(x: window.frame.maxX - 280, y: window.frame.maxY - 80), + appearance: window.appearance, locale: settings.language.locale, parentWindow: window) + defer { presenter.dismiss() } + let panel = try #require(window.childWindows?.first { $0.isVisible }) + try screenshot(try #require(panel.contentView), output: output, name: dark ? "final-settings-menu-dark.png" : "final-settings-menu-light.png") + presenter.dismiss() + } + let serverCoordinator = LanguageServerLogsCoordinator() + serverCoordinator.open(providerID: "java", servers: [.init(id: "java", displayName: "jdtls", status: .stopped, version: nil)]) + let serverFeature = LanguageServerLogsModel(logging: writer) + serverFeature.logs.changeQuery { $0.session = "all" } + let serverLoaded = TestGate() + let serverObservation = serverFeature.logs.$state.sink { if !$0.records.isEmpty { serverLoaded.open() } } + defer { serverObservation.cancel(); serverFeature.stop() } + let host = NSHostingView(rootView: LanguageServerLogsView(feature: serverFeature, coordinator: serverCoordinator) + .environment(\.locale, settings.language.locale).environment(\.colorScheme, .light)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 1400, height: 900), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.makeKeyAndOrderFront(nil) + defer { window.contentView = nil; window.close() } + try #require(await serverLoaded.waitUntilOpen()) + host.layoutSubtreeIfNeeded() + await Task.yield() + try screenshot(host, output: output, name: "final-jdtls-logs.png") + await app.shutdownProjectSession() + } + + private func screenshot(_ view: NSView, output: String, name: String) throws { + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: output).appendingPathComponent(name)) + } +} diff --git a/macos/Tests/LitheTests/LogWindowTests.swift b/macos/Tests/LitheTests/LogWindowTests.swift index 94eab4eaf..49a5a4cee 100644 --- a/macos/Tests/LitheTests/LogWindowTests.swift +++ b/macos/Tests/LitheTests/LogWindowTests.swift @@ -1,6 +1,7 @@ import AppKit import Combine import Foundation +import SwiftUI import Testing import LitheModuleAPI @testable import Lithe @@ -53,6 +54,68 @@ private final class WindowQueryGate: LogWindowLogging, @unchecked Sendable { @Suite("Application log window", .serialized) @MainActor struct LogWindowTests { + @Test func findBelongsOnlyToTheWindowThatInstalledItsTarget() throws { + let target = MacWindowFindTarget() + var focuses = 0 + target.focus = { focuses += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = target + defer { window.contentView = nil; window.close() } + func event(_ flags: NSEvent.ModifierFlags) throws -> NSEvent { + try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "f", + charactersIgnoringModifiers: "f", isARepeat: false, keyCode: 3)) + } + #expect(MacWindowFindTarget.handle(try event(.command))) + #expect(focuses == 1) + #expect(!MacWindowFindTarget.handle(try event([.command, .shift]))) + window.contentView = NSView() + #expect(!MacWindowFindTarget.handle(try event(.command))) + #expect(focuses == 1) + } + + @Test func tableColumnsUseTheViewportWithoutSavingAutomaticWidths() async throws { + let directory = root() + let writer = MacLogStore(defaultDirectory: directory) + await writer.finishSession() + defer { try? FileManager.default.removeItem(at: directory) } + var preferences = LogWindowPreferences() + preferences.widths["channel"] = 190 + let model = LogWindowModel(logging: writer, preferences: preferences) + let host = NSHostingView(rootView: LogWindowTable(model: model)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1200, height: 400), + styleMask: [.borderless, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { model.stop(); window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + let table = try #require(descendants(host).compactMap { $0 as? LogTableView }.first) + let scroll = try #require(table.enclosingScrollView) + let widths = table.tableColumns.map(\.width) + window.setContentSize(NSSize(width: 1900, height: 600)) + host.layoutSubtreeIfNeeded() + let columns = table.tableColumns.filter { !$0.isHidden } + let usedWidth = columns.reduce(0) { $0 + $1.width + table.intercellSpacing.width } + #expect(abs(usedWidth - scroll.contentSize.width) <= 2) + for identifier in ["event", "message", "fields"] { + let index = table.column(withIdentifier: .init(identifier)) + #expect(table.tableColumns[index].width > widths[index]) + } + #expect(table.tableColumn(withIdentifier: .init("channel"))?.width == 190) + #expect(model.preferences.widths == preferences.widths) + window.setContentSize(NSSize(width: 680, height: 400)) + host.layoutSubtreeIfNeeded() + #expect(table.tableColumns.allSatisfy { $0.width >= $0.minWidth }) + #expect(scroll.hasHorizontalScroller) + window.setContentSize(NSSize(width: 1900, height: 600)) + host.layoutSubtreeIfNeeded() + #expect(model.preferences.widths == preferences.widths) + } + private func root() -> URL { FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) } private func emit(_ writer: MacLogStore) { writer.emit(AppSessionStarted()) } private func ready(_ model: LogWindowModel) -> (TestGate, AnyCancellable) { @@ -324,7 +387,7 @@ struct LogWindowTests { #expect(roundTrip.records == result.records) #expect(roundTrip.opSummary?.durationMs == 0) } - @Test func languageServerEntryEmitsCoreResolvedOpenRequestWithoutEditorTab() async throws { + @Test func languageServerEntryOpensItsOwnWindowWithoutChangingApplicationLogs() async throws { let directory = root() let writer = MacLogStore(defaultDirectory: directory) let store = WindowPreferenceStore() @@ -332,16 +395,18 @@ struct LogWindowTests { let container = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode, applicationLogging: writer) defer { container.moduleLifecycleCoordinator.stop(); try? FileManager.default.removeItem(at: directory) } let model = AppModel(settings: settings, services: container.services) - let coordinator = LogWindowCoordinator() - container.services.logWindowCoordinator = coordinator + let coordinator = LanguageServerLogsCoordinator() + container.services.languageServerLogsCoordinator = coordinator + let applicationLogs = LogWindowCoordinator() + container.services.logWindowCoordinator = applicationLogs let opened = TestGate() coordinator.present = { opened.open() } try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in let tabs = model.editorTabItems model.showLanguageServerLogs(providerID: "c++") #expect(await opened.waitUntilOpen()) - let expected = try await writer.windowEngineChannel(providerID: "c++") - #expect(coordinator.request?.channelPrefixes == [expected]) + #expect(coordinator.request?.providerID == "c++") + #expect(applicationLogs.request == nil) #expect(model.editorTabItems == tabs) } await writer.finishSession() @@ -402,12 +467,6 @@ struct LogWindowTests { let binding = KeyboardShortcutBinding.keyPress(key: "l", modifiers: [.shift, .command]) let owners = LitheCommandCatalog.commands.filter { $0.defaultBindings.contains(binding) } #expect(owners.map(\.id) == ["show-logs"]) - let source = try String(contentsOf: URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources/Lithe/Views/Workbench/WorkbenchView.swift"), encoding: .utf8) - let plugin = try #require(source.range(of: ".action(\"Plugins…\")")) - let logs = try #require(source.range(of: ".action(\"Logs and Diagnostics…\")")) - let theme = try #require(source.range(of: ".action(\"Theme…\")")) - #expect(plugin.lowerBound < logs.lowerBound && logs.lowerBound < theme.lowerBound) let coordinator = LogWindowCoordinator() var opened = 0 coordinator.present = { opened += 1 } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 4e6b7ab39..3e1d96deb 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -10040,7 +10040,21 @@ "logs.window.restoreDirectory": "Restore Default Directory", "logs.window.maintenance": "Log directory and cleanup", "logs.window.clearStorage": "Clear Logs (used {size})…", - "logs.window.enableDiagnostics": "Enable" + "logs.window.enableDiagnostics": "Enable", + "logs.window.filterHelp": "Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results.", + "logs.window.channelHelp": "Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction.", + "logs.window.traceHelp": "The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table.", + "logs.window.timeHelp": "Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it.", + "logs.window.followHelp": "Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs.", + "logs.window.timeRange": "Time range", + "logs.window.startTime": "Start time", + "logs.window.endTime": "End time", + "logs.window.reversedTime": "The end time must be after the start time.", + "logs.language.windowTitle": "Language Server Logs", + "logs.language.allServers": "All language servers", + "logs.language.outputHelp": "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.", + "logs.language.outputTitle": "Server Logs", + "logs.language.empty": "No matching language server logs" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 92b346e6a..ea68222fa 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -10040,7 +10040,21 @@ "logs.window.restoreDirectory": "恢复默认目录", "logs.window.maintenance": "日志目录与清理", "logs.window.clearStorage": "清理日志(已用 {size})…", - "logs.window.enableDiagnostics": "开启" + "logs.window.enableDiagnostics": "开启", + "logs.window.filterHelp": "空格分隔的条件同时满足(AND)。level>=warn 包含 WARN 和 ERROR;level:info 精确匹配级别。event:、channel: 和 op: 按前缀匹配;server:jdtls 和 key:value 匹配字段中的文本。/正则/ 搜索消息,忽略大小写;前置 - 排除该条件。含空格的值用双引号包住。左侧通道、级别按钮和时间范围与表达式取交集;语法错误时保留上一次有效结果。", + "logs.window.channelHelp": "点击选择一个通道;按住 ⌘ 点击可添加或移除通道,按住 Shift 点击可连续选择。选中的通道之间是“或”,再与级别、时间和文本条件取交集。“全部通道”清除通道限制。", + "logs.window.traceHelp": "调用链按时间顺序列出同一操作 ID 的日志。+N ms 是距离首条日志的时间差,不是该事件的执行耗时;右侧条形表示它在整个操作时间跨度中的位置。点击记录可定位到上方表格。", + "logs.window.timeHelp": "选择开始与结束日期,再编辑时、分、秒。界面使用本地时区;点击“应用”后包含结束秒内的全部记录。范围同样限制实时日志,并与其他条件取交集。也可拖动事件分布选择范围;“清除时间范围”移除限制。", + "logs.window.followHelp": "实时跟随按记录顺序追加日志并滚动到最新记录。写入器按短批次交付,突发日志反映实际活动。向上滚动会暂停自动跟随,日志收集继续;恢复跟随可回到最新记录。", + "logs.window.timeRange": "时间范围", + "logs.window.startTime": "开始时间", + "logs.window.endTime": "结束时间", + "logs.window.reversedTime": "结束时间不能早于开始时间。", + "logs.language.windowTitle": "语言服务器日志", + "logs.language.allServers": "全部语言服务器", + "logs.language.outputHelp": "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。", + "logs.language.outputTitle": "服务器日志", + "logs.language.empty": "没有匹配的语言服务器日志" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index ae072277f..d31fed906 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -96758,6 +96758,160 @@ "key": "Enable diagnostics", "arguments": [] } + }, + { + "id": "logs.window.filterHelp", + "description": "Log filter syntax and intersection rules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Spaces combine conditions with AND. level>=warn includes WARN and ERROR; level:info is exact. event:, channel: and op: match prefixes. server:jdtls and key:value match field substrings. /regex/ searches messages, ignoring case; - excludes a condition. Use double quotes around values containing spaces. Channel, level and time controls also intersect with the expression. Invalid expressions keep the last valid results.", + "arguments": [] + } + }, + { + "id": "logs.window.channelHelp", + "description": "Channel multi-selection instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Click a channel to select it. Command-click adds or removes a channel; Shift-click selects a range. Selected channels are combined with OR, then intersected with level, time and text filters. All channels clears the channel restriction.", + "arguments": [] + } + }, + { + "id": "logs.window.traceHelp", + "description": "Operation trace timing explanation", + "platforms": [ + "macos" + ], + "macos": { + "key": "The call chain lists records sharing an operation ID in time order. +N ms is the offset from the first record, not its execution duration. The bar shows that offset across the operation span. Click a record to locate it in the log table.", + "arguments": [] + } + }, + { + "id": "logs.window.timeHelp", + "description": "Inclusive local second precision range explanation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose the start and end dates, then edit hours, minutes and seconds. Times use your local time zone; Apply includes the entire final second. The range also applies to live logs and intersects with other filters. Dragging the histogram selects a range too; Clear time range removes it.", + "arguments": [] + } + }, + { + "id": "logs.window.followHelp", + "description": "Live follow and batching behavior", + "platforms": [ + "macos" + ], + "macos": { + "key": "Live follow appends logs in their recorded order and scrolls to the latest record. The writer delivers short batches; bursts reflect real activity. Scrolling up pauses automatic scrolling while collection continues. Resume to return to the latest logs.", + "arguments": [] + } + }, + { + "id": "logs.window.timeRange", + "description": "Time range control", + "platforms": [ + "macos" + ], + "macos": { + "key": "Time range", + "arguments": [] + } + }, + { + "id": "logs.window.startTime", + "description": "Start date and time label", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start time", + "arguments": [] + } + }, + { + "id": "logs.window.endTime", + "description": "End date and time label", + "platforms": [ + "macos" + ], + "macos": { + "key": "End time", + "arguments": [] + } + }, + { + "id": "logs.window.reversedTime", + "description": "Invalid time range validation", + "platforms": [ + "macos" + ], + "macos": { + "key": "The end time must be after the start time.", + "arguments": [] + } + }, + { + "id": "logs.language.windowTitle", + "description": "Language server output window title", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language Server Logs", + "arguments": [] + } + }, + { + "id": "logs.language.allServers", + "description": "All server selection in the dedicated LSP log window", + "platforms": [ + "macos" + ], + "macos": { + "key": "All language servers", + "arguments": [] + } + }, + { + "id": "logs.language.outputHelp", + "description": "Explain dedicated upstream language server evidence", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.", + "arguments": [] + } + }, + { + "id": "logs.language.outputTitle", + "description": "Heading for the dedicated language server log output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server Logs", + "arguments": [] + } + }, + { + "id": "logs.language.empty", + "description": "Empty state when no language server records match", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching language server logs", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/diagnostics-export.json b/shared/platform-feature-matrix/features/diagnostics-export.json index be82325bb..e33bb8b57 100644 --- a/shared/platform-feature-matrix/features/diagnostics-export.json +++ b/shared/platform-feature-matrix/features/diagnostics-export.json @@ -28,7 +28,12 @@ "macos/Sources/Lithe/Application/Features/WorkbenchNotificationFeatureModel.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPalette.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift", - "scripts/macos/probe/probe-macos-logging-ui.sh" + "scripts/macos/probe/probe-macos-logging-ui.sh", + "macos/Sources/Lithe/Views/Diagnostics/LogTimeRangePicker.swift", + "macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/MacWindowFindTarget.swift", + "macos/Tests/LitheTests/LogWindowTests.swift", + "macos/Sources/Lithe/Views/Diagnostics/LogChannelList.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -44,5 +49,5 @@ "verificationStatus": "pending" }, "owner": "Diagnostics", - "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。" + "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。 日志窗口自适应回归:调整窗口宽度,确认事件/消息/字段使用空余宽度、窄窗口可横向滚动且自动列宽不写偏好;详情和调用链左上对齐、动作说明不盖状态栏。原生发送 ⌘F 后筛选框接收焦点,⌘点击两个通道能同时保留,全部通道清除限制;ⓘ 说明表达式交集、通道选择、时间和调用链偏移。时间分布刻度含秒,日历与时分秒草稿应用后包含结束秒,清除移除范围。实时追加只更新受影响行、跟随平滑滚动,暂停不重建已有单元格;真实应用连续突发的整帧流畅度仍需验收。" } diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index 7ec5dc0df..5fbd7d233 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -33,7 +33,11 @@ "macos/Sources/Lithe/Views/Diagnostics/LogWindowPalette.swift", "macos/Sources/Lithe/Views/Diagnostics/LogWindowPresentation.swift", "macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift", - "scripts/macos/probe/probe-macos-logging-ui.sh" + "scripts/macos/probe/probe-macos-logging-ui.sh", + "macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift", + "macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift", + "macos/Sources/Lithe/Views/App/RootView.swift", + "macos/Tests/LitheTests/LogWindowReviewTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -46,5 +50,5 @@ "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。" + "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。 语言服务器日志拆窗:状态菜单的“查看日志”打开独立的“语言服务器日志”窗口,不再改写应用日志窗口的通道筛选;窗口用同一份脱敏 v2 记录,按 Core 解析的服务器通道过滤,可切换服务器或查看全部 lsp 通道,菜单底部入口默认全部服务器。运行 LogWindowTests 与 LogWindowReviewTests 验证;真实 Java 项目整机验收保持 pending。" } From a4c39fe08cd9f960bb40be48ef12be7202ba59a0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 11:27:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(macos):=20=E8=AF=AD=E8=A8=80=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E6=97=A5=E5=BF=97=E6=81=A2=E5=A4=8D=E4=B8=BA?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E9=87=8C=E7=9A=84=E2=80=9CLSP=20?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E2=80=9D=E6=A0=87=E7=AD=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态栏语言服务器菜单的“查看日志”改为打开编辑器标签页“LSP 日志”, 删除上一版的独立语言服务器日志窗口;每个工作区只有一个该标签, 再次打开其他服务器时复用并切换服务器。 - 标签页参与混合标签顺序、拖动排序和分组,⌘W 与关闭按钮走统一关闭路径, 切换文档时让出选中,重置工作区内容时移除并停止查询。 - 顶部工具栏:服务器选择、“服务器日志”说明 ⓘ、最低级别(信息/警告/错误)、 “清空视图”。清空只隐藏当前服务器已显示的记录,切换服务器后各自保留清空点, 不删除已采集的诊断日志。 - 数据来自结构化日志的 lsp/ 通道,按 Core 解析出的通道查询; 不提供“全部服务器”视图,也不提供 Server Trace 和 RPC 捕获。 - 新增 i18n 词条并重新生成 Localizable.strings;更新三份 Agent Note 与 lsp-status-logs 功能矩阵条目。 - 测试:LogWindowTests 改为验证入口打开编辑器标签且不影响应用日志窗口; LogWindowReviewTests 新增按服务器显示与清空的用例。 --- ...guage-tooling-and-lsp-runtime-ownership.md | 11 +- .../2026-10-12-editor-tab-split-docking.md | 5 +- ...026-11-03-structured-logging-log-window.md | 40 +++-- macos/Resources/en.lproj/Localizable.strings | 14 +- .../zh-Hans.lproj/Localizable.strings | 14 +- .../Composition/AppModelFeatureGraph.swift | 9 +- .../Application/Composition/AppServices.swift | 1 - .../Features/EditorSessionCoordinator.swift | 14 +- .../Features/EditorTabOrderFeatureModel.swift | 2 +- .../Features/LanguageServerLogsModel.swift | 122 +++++++++++---- .../Application/Features/LogWindowModel.swift | 11 ++ macos/Sources/Lithe/LitheApp.swift | 15 -- .../AppModel/AppModel+FeatureState.swift | 1 + .../AppModel+LanguageServerRuntime.swift | 25 ++- .../Models/AppModel/AppModel+Media.swift | 1 + .../AppModel/AppModel+WorkbenchClose.swift | 5 + .../Lithe/Models/AppModel/AppModel.swift | 1 + .../Lithe/Models/Editor/EditorTabItem.swift | 2 + macos/Sources/Lithe/Views/App/RootView.swift | 2 - .../Lithe/Views/Editor/EditorAreaView.swift | 35 ++++- .../Language/LanguageServerLogsView.swift | 144 +++++++++++++----- .../Workbench/LanguageServerStatusView.swift | 2 +- .../LitheTests/LogWindowReviewTests.swift | 64 +++++--- macos/Tests/LitheTests/LogWindowTests.swift | 20 +-- shared/i18n/locales/en-US.json | 7 +- shared/i18n/locales/zh-CN.json | 7 +- shared/i18n/messages.json | 27 +++- .../features/lsp-status-logs.json | 4 +- 28 files changed, 431 insertions(+), 174 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 1f3c35058..721df5916 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 @@ -67,15 +67,16 @@ languageID 对应描述元数据已有的 `languageServerLaunch.executableNames` 运行投影、日志选择及订阅必须保留;不能清空被 `removeDuplicates` 去重的共享投影后, 指望日志或无变化的状态通知重新填回。 -“查看日志”打开或聚焦独立的“语言服务器日志”窗口,不新增底部工具窗口,也不占用编辑器标签。 -窗口读取已脱敏的 v2 磁盘日志与实时广播,只看 `lsp` 通道;切换服务器仍在同一窗口, -服务器通道由 Core 解析,Swift 不用 providerID 拼通道名。“清空视图”只隐藏当前已显示的记录, -不删除持久诊断日志,也不建立另一份日志存储。窗口与应用日志窗口的关系见 +“查看日志”打开或聚焦编辑器区的“LSP 日志”标签页,不新增底部工具窗口,也不另开窗口。 +每个工作区窗口只有一个这样的标签页,切换服务器仍在同一标签页里完成;菜单底部的“查看日志”打开列表中的第一个服务器。 +标签页读取已脱敏的 v2 磁盘日志和实时广播,一次只显示一个服务器的通道,没有“全部语言服务器”选项; +通道由 Core 解析,Swift 不用 providerID 拼通道名。“清空视图”只隐藏所选服务器已经显示的记录, +每个服务器各有自己的清空点,不删除持久诊断日志,也不建立另一份日志存储。标签页与应用日志窗口的关系见 [应用级日志窗口与诊断导出](../feature/2026-11-03-structured-logging-log-window.md)。 正确做法是按当前会话事件显示真实内容。不要在 Swift 再实现 JSON-RPC(语言服务器 使用的请求响应协议)抓包器,或把现有文本日志复制成 Server Trace、RPC Messages -三个假通道。当前 Core 没有提供原始跟踪/消息采集,日志标签的工具栏明确提示不可用; +三个假通道。当前 Core 没有提供原始跟踪/消息采集,LSP 日志标签页的 ⓘ 说明明确写出不可用; 将来确需采集时,应在 Core 既有协议所有者处设计显式启用、脱敏和预算,再扩展契约。 本次没有新缓存目录、下载或安装资源写入,运行时只读发行基线不变。 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 17c0276ba..3bfc65ff3 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,7 +24,10 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 关闭或移走最后一个标签后合并空组;新标签进入当前聚焦的组。文件和会话生命周期不交给布局树。 分隔器继续使用 `LitheSplitPaneView`,尺寸变化留在局部视图,只在释放后提交比例。 分组是当前项目会话状态,本次不增加重启恢复编辑分屏或跨窗口传输。 -LSP 日志已不在编辑器标签里显示,改为独立的“语言服务器日志”窗口,不参与编辑分组。 +LSP 日志是普通编辑器标签(`EditorTabItem.languageLogs`),不登记底部工具窗口。单个工作区只有一个 LSP 日志标签, +再次查看另一个服务器时复用这个标签并切换服务器;标签参与重排和分组。日志标签不拥有文件缓冲区: +选中它要清除活动文件、媒体和终端投影,关闭后通过混合标签选择恢复原来的内容,不能关闭未保存的文件。 +文件通过功能模型直接打开时,日志标签也必须让出活动选择;工作区重置会移除日志标签并停止它的查询。 与数据库 PR #17 集成后,数据库标签继续使用其共享工作区和查询状态,统一保留在同一个编辑分组,只允许组内重排;不能直接开放跨组移动,否则两个分组会显示同一数据库上下文。数据库工具窗口及 Services 工具窗口仍支持停靠分组。 ### 终端标签使用独立的 AppKit 拖放 diff --git a/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md b/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md index 3e06da8bb..49da5415e 100644 --- a/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md +++ b/.agents/notes/implemented/feature/2026-11-03-structured-logging-log-window.md @@ -4,7 +4,7 @@ ## 先说结论 -macOS 的应用日志只在一个地方看:用 ⌘⇧L 打开的独立日志窗口,所有项目共用。语言服务状态里的“查看日志”打开另一个“语言服务器日志”窗口,读的是同一份脱敏记录,只显示 `lsp` 通道,不会改动应用日志窗口的筛选。窗口读取磁盘上已经由 Core 脱敏的 v2 日志,以及写入器的实时广播。过滤语法、重复折叠、事件分布、调用链和复制格式都由 Rust Core 计算,SwiftUI 只负责读文件、订阅和画表格。导出对话框和窗口使用同一套 Core 查询,导出的永远是脱敏版。语言服务的日志编辑器标签页、100 条内存日志和设置页里的日志时间线已经删除。Windows 仍然使用 v1 日志,没有迁移。 +macOS 的应用日志只在一个地方看:用 ⌘⇧L 打开的独立日志窗口,所有项目共用。语言服务状态里的“查看日志”打开编辑器区的“LSP 日志”标签页,读的是同一份脱敏记录,一次只显示一个服务器的通道,不会改动应用日志窗口的筛选。窗口读取磁盘上已经由 Core 脱敏的 v2 日志,以及写入器的实时广播。过滤语法、重复折叠、事件分布、调用链和复制格式都由 Rust Core 计算,SwiftUI 只负责读文件、订阅和画表格。导出对话框和窗口使用同一套 Core 查询,导出的永远是脱敏版。语言服务原来那份 100 条的内存日志和设置页里的日志时间线已经删除。Windows 仍然使用 v1 日志,没有迁移。 ## 问题 @@ -28,18 +28,28 @@ macOS 的应用日志只在一个地方看:用 ⌘⇧L 打开的独立日志 窗口已经打开时,请求只套用过滤条件并把窗口提到前面,不会再开一个窗口。 -### 语言服务器日志单独成窗 +### 语言服务器日志在编辑器标签页里看 -语言服务状态菜单里的“查看日志”不发 `LogWindowOpenRequest`,而是打开 id 为 `language-server-logs` 的“语言服务器日志”窗口。 -原因是两类用户目的不同:查语言服务器的人要连续阅读某个服务器的会话事件、stderr(标准错误输出)和服务消息; -查应用问题的人在应用日志窗口里按通道、级别和时间筛选。合并在一个窗口时,点一次“查看日志”就会覆盖用户在应用日志窗口里刚设好的通道筛选。 +语言服务状态菜单里的“查看日志”不发 `LogWindowOpenRequest`,而是打开编辑器区的“LSP 日志”标签页(`EditorTabItem.languageLogs`)。 +每个工作区窗口最多有一个这样的标签页;已经打开时,再次“查看日志”只切换服务器并聚焦这个标签页。 -正确做法:`AppModel.showLanguageServerLogs(providerID:)` 只把提供方和状态栏服务器列表交给 `LanguageServerLogsCoordinator`; -`LanguageServerLogsModel` 内部持有一个只查 `lsp` 前缀、不折叠重复记录的 `LogWindowModel`,选中服务器后用 Core 的 -`windowEngineChannel` 换成该服务器的通道。数据来源仍是 `MacLogStore` 的磁盘 v2 记录和实时广播,所以这里看到的就是应用日志窗口和导出里的同一份脱敏证据。 -窗口顶部可以切换服务器,“全部语言服务器”查看整个 `lsp` 前缀;状态菜单底部的“查看日志”默认打开全部服务器。 +原因是两类用户目的不同。查语言服务器的人要一边看代码,一边连续阅读某个服务器的会话事件、stderr(标准错误输出)和服务器消息, +编辑器标签页正适合这种用法,也是用户原来查看 LSP 日志的位置。查应用问题的人在应用日志窗口里按通道、级别和时间筛选。 +如果语言服务器日志借用应用日志窗口,点一次“查看日志”就会覆盖用户刚设好的通道筛选;如果单独开一个窗口,阅读日志时就离开了编辑区,用户要在窗口之间来回切换。 -不要这样做:为语言服务器另建内存缓冲或第二份日志文件;在 Swift 里用 providerID 拼通道名;为了打开这个窗口改写应用日志窗口的查询。 +正确做法: + +- `AppModel.showLanguageServerLogs(providerID:)` 调用 `LanguageServerLogsModel.open(providerID:)`,把 `.languageLogs` 放进混合标签顺序并聚焦它。状态菜单里某个服务器的“查看日志”传该服务器的 id;菜单底部的“查看日志”传列表中的第一个服务器。 +- `LanguageServerLogsModel` 持有一个不折叠重复记录的 `LogWindowModel`。选中服务器后,先用 Core 的 `windowEngineChannel(providerID:)` 把 id 换成该服务器的通道,拿到通道后才开始查询。因此标签页不会显示未过滤的整个 `lsp` 前缀,也没有“全部语言服务器”选项。 +- 标签页顶部从左到右是:服务器选择、“服务器日志”和 ⓘ 说明、最低级别(信息、警告、错误,默认信息)、“清空视图”。 +- “清空视图”只隐藏当前服务器已经显示的记录,不删除磁盘上的日志。模型为每个服务器分别保存清空点(每个日志会话已显示到的 seq),切换服务器时换入该服务器自己的清空点。所以清空 Java 后切到 Go,Go 的记录照常显示;切回 Java,之前清空的记录仍然隐藏,只显示清空之后的新记录。 +- 生命周期属于标签页。关闭标签页或重置工作区时调用 `stop()`,查询、订阅和清空点一起丢弃;切到别的标签页只是视图消失,查询继续,清空点保留。 + +数据来源仍是 `MacLogStore` 的磁盘 v2 记录和实时广播,所以标签页里看到的就是应用日志窗口和诊断导出里的同一份脱敏证据。 + +已知限制:通道只由服务器 id 决定,不区分工作区窗口,同一个服务器在其他窗口产生的记录也会出现在这里。Server Trace 和 RPC 消息捕获没有接入 v2 日志,ⓘ 说明里写明了这一点。 + +不要这样做:为语言服务器另建内存缓冲或第二份日志文件;在 Swift 里用 providerID 拼通道名;为了打开这个标签页改写应用日志窗口的查询;在通道解析完成前就开始查询;用一个共享的清空点同时清空所有服务器。 ### 设置里没有日志页 @@ -163,18 +173,20 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 ## 考虑过的备选方案 -- 保留编辑器标签页和设置页时间线两套浏览:来源和生命周期继续不同,无法回答“导出的是否就是看到的”。 +- 保留旧 LSP 日志标签页的 100 条内存缓冲和设置页时间线两套浏览:来源和生命周期继续不同,无法回答“导出的是否就是看到的”。 - 设置里保留一个只放日志目录、保留与清理和详细诊断开关的“日志存储”页:日志功能又会分散在两个地方,用户要在设置和窗口之间来回找。窗口工具栏的“⋯”菜单已经能放下这几项。 - 在 Swift 里实现过滤、折叠和复制格式:开发更快,但 Windows 迁移要重写,两端行为会漂移。 - 提示里保存 seq 定位记录:显示提示时记录可能还没写盘,seq 未分配;改用通道、事件和 op 组成的引用。 - 导出提供“包含原文”选项:磁盘上根本没有原文。要支持就得另存一份未脱敏日志,扩大隐私面。 - 每个事件单独调用 Core、每次打开文件:实现简单,但跨语言调用和磁盘开销随日志量线性增长;选择按批查询和读取尾部。 - 两端一次迁移:扩大验证范围并阻塞 macOS;Windows 保留 v1 兼容,以后单独迁移。 -- 语言服务器的“查看日志”继续打开应用日志窗口并切换通道(PR4 的做法):入口少一个,但每次打开都会覆盖应用日志窗口的通道筛选,表格视图也不适合连续阅读服务器输出。改为独立窗口,数据来源不变。 +- 语言服务器的“查看日志”继续打开应用日志窗口并切换通道(PR4 的做法):入口少一个,但每次打开都会覆盖应用日志窗口的通道筛选,表格视图也不适合连续阅读服务器输出。 +- 语言服务器日志单独开一个“语言服务器日志”窗口(#1700 的早期版本):不再干扰应用日志窗口,但阅读日志时离开了编辑区,用户要在窗口之间切换,也不是用户原来查看 LSP 日志的位置。最终改回编辑器标签页,数据来源不变。 +- 标签页提供“全部语言服务器”:多个服务器的输出交错在一起,连续阅读时分不清来源。需要对照多个服务器时,用应用日志窗口筛选 `lsp` 通道。 ## 后果 -收益:应用日志只有一个查看入口,语言服务器日志窗口也读同一份数据,看到的、复制的和导出的是同一份脱敏证据;错误提示可以一键跳到对应记录;过滤和折叠在 Core 里,有 fixture 覆盖,Windows 可以复用。 +收益:应用日志只有一个查看入口,LSP 日志标签页也读同一份数据,看到的、复制的和导出的是同一份脱敏证据;错误提示可以一键跳到对应记录;过滤和折叠在 Core 里,有 fixture 覆盖,Windows 可以复用。 代价:Core 的诊断 API 变大,每个新字段都要同步契约、fixture 和 Swift 桥接。实时跟随每个批次都要调用一次 Core 查询,事件分布也由 Core 对有界窗口重新计算;高吞吐下的成本还需要整机测量,如果不可接受,应扩展 Core 的增量摘要接口,而不是在 Swift 里复制统计规则。门禁零额度意味着每个新的 `try?`、空 `catch` 或丢弃结果都必须当场记录或写明例外类别。 @@ -202,6 +214,8 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 - `scripts/logging/` - `shared/logging/` - `macos/Sources/Lithe/Views/Diagnostics/` +- `macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift` +- `macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift` - `macos/Sources/Lithe/Services/Diagnostics/` - `macos/Sources/Lithe/Platform/MacOS/Logging/` - `macos/Sources/Lithe/Platform/MacOS/Diagnostics/` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 9b6e80038..539231684 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -323,7 +323,6 @@ "All configurations" = "All configurations"; "All conflicts resolved. Continue to finish, or abort to undo." = "All conflicts resolved. Continue to finish, or abort to undo."; "All conversations" = "All conversations"; -"All language servers" = "All language servers"; "All messages" = "All messages"; "All projects" = "All projects"; "All projects · project can override" = "All projects · project can override"; @@ -738,6 +737,7 @@ "Clear debug output" = "Clear debug output"; "Clear log search" = "Clear log search"; "Clear only the display; log files are kept" = "Clear only the display; log files are kept"; +"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"; @@ -2073,11 +2073,11 @@ "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 Logs" = "Language Server Logs"; "Language Server Settings..." = "Language Server Settings..."; "Language Service" = "Language Service"; "Language Services" = "Language Services"; @@ -2383,6 +2383,7 @@ "Method Breakpoints" = "Method Breakpoints"; "Microphone in use" = "Microphone in use"; "Minimap" = "Minimap"; +"Minimum level" = "Minimum level"; "Minimum version" = "Minimum version"; "Missing" = "Missing"; "Mixed Reset (Keep Changes Unstaged)" = "Mixed Reset (Keep Changes Unstaged)"; @@ -3799,7 +3800,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 the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics." = "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics."; +"Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics." = "Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics."; "Side-by-side" = "Side-by-side"; "Side-by-side view" = "Side-by-side view"; "Sign in again" = "Sign in again"; @@ -5169,11 +5170,12 @@ "logs.filter.regexTooLong" = "Regular expression is too long"; "logs.filter.unterminatedQuote" = "Close the quoted value"; "logs.filter.unterminatedRegex" = "Close the regular expression with /"; -"logs.language.allServers" = "All language servers"; +"logs.language.clearHelp" = "Clear only the selected server's visible logs. Collected diagnostic logs are kept."; "logs.language.empty" = "No matching language server logs"; -"logs.language.outputHelp" = "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics."; +"logs.language.minimumLevel" = "Minimum level"; +"logs.language.outputHelp" = "Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics."; "logs.language.outputTitle" = "Server Logs"; -"logs.language.windowTitle" = "Language Server Logs"; +"logs.language.tabTitle" = "LSP Logs"; "logs.window.absoluteTime" = "absolute time"; "logs.window.allChannels" = "all channels"; "logs.window.autoOffIn" = "Auto-off in %1$@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index be898cab5..1436dffb9 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -323,7 +323,6 @@ "All configurations" = "全部配置"; "All conflicts resolved. Continue to finish, or abort to undo." = "冲突已全部解决。继续以完成操作,或中止以撤销。"; "All conversations" = "全部会话"; -"All language servers" = "全部语言服务器"; "All messages" = "全部消息"; "All projects" = "所有项目"; "All projects · project can override" = "所有项目 · 项目可覆盖"; @@ -738,6 +737,7 @@ "Clear debug output" = "清除调试输出"; "Clear log search" = "清除日志搜索"; "Clear only the display; log files are kept" = "只清空显示,保留磁盘上的日志文件"; +"Clear only the selected server's visible logs. Collected diagnostic logs are kept." = "仅清空所选服务器的可见日志,保留已采集的诊断日志。"; "Clear path filter" = "清除路径筛选"; "Clear run output" = "清除运行输出"; "Clear search" = "清除搜索"; @@ -2073,11 +2073,11 @@ "Korean characters" = "韩文字符"; "Kotlin Support" = "Kotlin 支持"; "LSP" = "LSP"; +"LSP Logs" = "LSP 日志"; "Labels" = "标签"; "Labels and assignees" = "标签和负责人"; "Language" = "语言"; "Language Server" = "语言服务器"; -"Language Server Logs" = "语言服务器日志"; "Language Server Settings..." = "语言服务器设置..."; "Language Service" = "语言服务"; "Language Services" = "语言服务"; @@ -2383,6 +2383,7 @@ "Method Breakpoints" = "方法断点"; "Microphone in use" = "麦克风使用中"; "Minimap" = "代码缩略图"; +"Minimum level" = "最低级别"; "Minimum version" = "最低版本"; "Missing" = "缺失"; "Mixed Reset (Keep Changes Unstaged)" = "混合重置(保留未暂存更改)"; @@ -3799,7 +3800,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 the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics." = "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。"; +"Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics." = "显示所选服务器的会话事件、stderr 和服务器消息,来自已脱敏的诊断日志。不提供 Server Trace 和 RPC 消息捕获。应用日志在“日志与诊断”中查看。"; "Side-by-side" = "并排"; "Side-by-side view" = "并排视图"; "Sign in again" = "重新登录"; @@ -5169,11 +5170,12 @@ "logs.filter.regexTooLong" = "正则表达式过长"; "logs.filter.unterminatedQuote" = "请补齐引号"; "logs.filter.unterminatedRegex" = "请用 / 结束正则表达式"; -"logs.language.allServers" = "全部语言服务器"; +"logs.language.clearHelp" = "仅清空所选服务器的可见日志,保留已采集的诊断日志。"; "logs.language.empty" = "没有匹配的语言服务器日志"; -"logs.language.outputHelp" = "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。"; +"logs.language.minimumLevel" = "最低级别"; +"logs.language.outputHelp" = "显示所选服务器的会话事件、stderr 和服务器消息,来自已脱敏的诊断日志。不提供 Server Trace 和 RPC 消息捕获。应用日志在“日志与诊断”中查看。"; "logs.language.outputTitle" = "服务器日志"; -"logs.language.windowTitle" = "语言服务器日志"; +"logs.language.tabTitle" = "LSP 日志"; "logs.window.absoluteTime" = "绝对时间"; "logs.window.allChannels" = "全部通道"; "logs.window.autoOffIn" = "自动关闭倒计时 %1$@"; diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index 36ac9a12c..5f0636a72 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -39,6 +39,8 @@ final class AppModelFeatureGraph { let debugLaunchPreparation: DebugLaunchPreparationCoordinator let debugSessionCleanup: DebugSessionCleanupCoordinator let javaTestDebugWorkflow: JavaTestDebugWorkflowCoordinator + /// Nil only when the application has no log store (some tests). + let languageServerLogs: LanguageServerLogsModel? init(settings: AppSettings, services: AppServices) { keyboardShortcut = KeyboardShortcutFeatureModel(settings: settings) @@ -126,11 +128,16 @@ final class AppModelFeatureGraph { debugLaunchPreparation = DebugLaunchPreparationCoordinator(notify: notify) debugSessionCleanup = DebugSessionCleanupCoordinator() javaTestDebugWorkflow = JavaTestDebugWorkflowCoordinator(notify: notify) + let languageServerLogs = services.logWindowLogging.map { + LanguageServerLogsModel(logging: $0, sink: services.logSink) + } + self.languageServerLogs = languageServerLogs editorSession = EditorSessionCoordinator( document: document, media: media, terminalPlacement: terminalPlacement, - tabOrder: editorTabOrder + tabOrder: editorTabOrder, + languageServerLogs: languageServerLogs ) } } diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index ae4cdfe86..5cfe567ca 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -11,7 +11,6 @@ import LitheLanguageIntelligenceModule @MainActor final class AppServices { var logWindowCoordinator: LogWindowCoordinator? - var languageServerLogsCoordinator: LanguageServerLogsCoordinator? var logWindowLogging: (any LogWindowLogging)? /// Process log sink supplied by the composition root; modules receive the same instance. var logSink: (any LogSink)? diff --git a/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift b/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift index fbfc44aa4..f2d77a190 100644 --- a/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/EditorSessionCoordinator.swift @@ -7,22 +7,28 @@ final class EditorSessionCoordinator { private let document: DocumentFeatureModel private let media: MediaDocumentFeatureModel private let tabOrder: EditorTabOrderFeatureModel + private let languageServerLogs: LanguageServerLogsModel? private var observations: [AnyCancellable] init( document: DocumentFeatureModel, media: MediaDocumentFeatureModel, terminalPlacement: TerminalPlacementFeatureModel, - tabOrder: EditorTabOrderFeatureModel + tabOrder: EditorTabOrderFeatureModel, + languageServerLogs: LanguageServerLogsModel? = nil ) { self.document = document self.media = media self.tabOrder = tabOrder + self.languageServerLogs = languageServerLogs 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 @@ -36,6 +42,8 @@ final class EditorSessionCoordinator { } func resetContent() { + tabOrder.remove(.languageLogs) + languageServerLogs?.stop() document.reset() media.reset() } diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index a376180de..7be829f77 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -289,7 +289,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/LanguageServerLogsModel.swift b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift index 4c7de6aa1..ee502c44f 100644 --- a/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift +++ b/macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift @@ -2,72 +2,128 @@ import Combine import Foundation import LitheModuleAPI -struct LanguageServerLogsRequest: Equatable { - let providerID: String - let servers: [LSPStatusBarServer] -} - -/// Separate presentation from application diagnostics, backed by the same redacted evidence. +/// Backs the editor-area "LSP Logs" tab with the redacted v2 evidence the +/// application log window and diagnostics export also read. Core resolves each +/// server's channel; this model only keeps the selected server, the minimum +/// level and a clear point per server. +/// +/// The tab owns the lifetime: `open` starts it and `stop` runs when the tab +/// closes or the workspace resets. A view disappearing (another tab selected) +/// does not stop it, because stopping discards the clear points. @MainActor -final class LanguageServerLogsCoordinator: ObservableObject { - @Published private(set) var request: LanguageServerLogsRequest? - var present: (() -> Void)? - func open(providerID: String, servers: [LSPStatusBarServer]) { - request = .init(providerID: providerID, servers: servers) - present?() +final class LanguageServerLogsModel: ObservableObject { + enum MinimumLevel: String, CaseIterable, Hashable { + case info, warning, error + + /// v2 level names accepted by `LogWindowQuery.levels`. + var levels: [String] { + switch self { + case .info: ["INFO", "WARN", "ERROR"] + case .warning: ["WARN", "ERROR"] + case .error: ["ERROR"] + } + } } -} -@MainActor -final class LanguageServerLogsModel: ObservableObject { let logs: LogWindowModel - @Published private(set) var servers: [LSPStatusBarServer] = [] @Published private(set) var providerID = "" + @Published private(set) var minimumLevel = MinimumLevel.info + /// Channel resolution failure; query failures stay on `logs.error`. @Published private(set) var error: String? private let logging: any LogWindowLogging private let sink: (any LogSink)? private var selectionTask: Task? private var revision = 0 private var running = false + private var logsStarted = false + /// The server whose channel the inner query currently shows. + private var appliedProviderID: String? + /// "Clear View" hides only the selected server's rows; switching servers + /// swaps these points instead of clearing every server at once. + private var clearPoints: [String: [String: UInt64]] = [:] init(logging: any LogWindowLogging, sink: (any LogSink)? = nil) { - self.logging = logging; self.sink = sink + self.logging = logging + self.sink = sink var preferences = LogWindowPreferences() preferences.collapse = false - preferences.query.channelPrefixes = ["lsp"] + preferences.query.levels = MinimumLevel.info.levels logs = LogWindowModel(logging: logging, preferences: preferences, sink: sink) } + deinit { selectionTask?.cancel() } - func apply(_ request: LanguageServerLogsRequest) { - servers = request.servers - select(request.providerID) - } - func start() { - guard !running else { return } + + /// Starts the tab's query for `providerID`. An empty ID waits until the view + /// picks the first known server. + func open(providerID: String) { running = true - logs.start() + select(providerID) } + func select(_ id: String) { + guard running else { providerID = id; return } + guard id != providerID || id != appliedProviderID else { return } providerID = id + error = nil revision += 1 let revision = revision selectionTask?.cancel() + selectionTask = nil + guard !id.isEmpty else { return } selectionTask = Task { [weak self, logging] in do { - let channel = id.isEmpty ? "lsp" : try await logging.windowEngineChannel(providerID: id) - guard let self, !Task.isCancelled, self.revision == revision else { return } - self.error = nil - self.logs.changeQuery { $0.channelPrefixes = [channel] } + let channel = try await logging.windowEngineChannel(providerID: id) + guard let self, !Task.isCancelled, self.running, self.revision == revision else { return } + self.show(channel: channel, for: id) } catch { - guard let self, !Task.isCancelled, self.revision == revision else { return } + guard let self, !Task.isCancelled, self.running, self.revision == revision else { return } self.error = error.localizedDescription self.sink?.emit(AppLoggingDiagnosticsFailed(error: error)) } } } + + func setMinimumLevel(_ level: MinimumLevel) { + guard level != minimumLevel else { return } + minimumLevel = level + guard logsStarted else { return } + logs.changeQuery { $0.levels = level.levels } + } + + /// Hides the selected server's current rows. Collected logs are not deleted. + func clear() { + guard logsStarted else { return } + logs.clearDisplay() + } + + func retry() { + if error != nil { select(providerID) } else { logs.retry() } + } + func stop() { - running = false; revision += 1 - selectionTask?.cancel(); selectionTask = nil - logs.stop() + running = false + revision += 1 + selectionTask?.cancel() + selectionTask = nil + if logsStarted { logs.stop() } + logsStarted = false + appliedProviderID = nil + clearPoints = [:] + error = nil + } + + private func show(channel: String, for id: String) { + if let applied = appliedProviderID { clearPoints[applied] = logs.displayWatermarks } + logs.displayWatermarks = clearPoints[id] ?? [:] + appliedProviderID = id + let levels = minimumLevel.levels + logs.changeQuery { + $0.channelPrefixes = [channel] + $0.levels = levels + } + if !logsStarted { + logsStarted = true + logs.start() + } } } diff --git a/macos/Sources/Lithe/Application/Features/LogWindowModel.swift b/macos/Sources/Lithe/Application/Features/LogWindowModel.swift index b4b80119a..f79267b7e 100644 --- a/macos/Sources/Lithe/Application/Features/LogWindowModel.swift +++ b/macos/Sources/Lithe/Application/Features/LogWindowModel.swift @@ -354,6 +354,17 @@ final class LogWindowModel: ObservableObject { if liveFailed { retry() } } func toggleFollow() { if state.follows { state.follows = false; state.scrolledUp = false } else { resume() } } + /// Clear points per log session. A caller that shows several independent views + /// through one model (the LSP Logs tab keeps one per server) swaps them before + /// `changeQuery`; the next query then omits records at or below these points. + var displayWatermarks: [String: UInt64] { + get { watermarks } + set { + watermarks = newValue + activeQuery.afterSequences = newValue + state.cleared = !newValue.isEmpty + } + } func clearDisplay() { watermarks = latestSequences activeQuery.afterSequences = watermarks diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index b6f89de55..b918b8660 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -492,8 +492,6 @@ struct LitheApp: App { @StateObject private var logWindowCoordinator: LogWindowCoordinator @StateObject private var logWindowModel: LogWindowModel @StateObject private var logExportFeature: DiagnosticsFeatureModel - @StateObject private var languageServerLogsCoordinator: LanguageServerLogsCoordinator - @StateObject private var languageServerLogsModel: LanguageServerLogsModel private let applicationLogWriter: MacLogStore init() { @@ -523,7 +521,6 @@ struct LitheApp: App { } ) let logWindowCoordinator = LogWindowCoordinator() - let languageServerLogsCoordinator = LanguageServerLogsCoordinator() let applicationLogWriter = MacServiceContainer.makeApplicationLogWriter { [weak settings] in Task { @MainActor [weak settings] in settings?.setCustomLogDirectory(nil) } } @@ -540,8 +537,6 @@ struct LitheApp: App { } self.applicationLogWriter = applicationLogWriter _logWindowCoordinator = StateObject(wrappedValue: logWindowCoordinator) - _languageServerLogsCoordinator = StateObject(wrappedValue: languageServerLogsCoordinator) - _languageServerLogsModel = StateObject(wrappedValue: LanguageServerLogsModel(logging: applicationLogWriter, sink: applicationLogWriter)) settings.logWindowSink = applicationLogWriter let logWindowModel = LogWindowModel(logging: applicationLogWriter, preferences: settings.logWindowPreferences, sink: applicationLogWriter, save: { [weak settings] in settings?.logWindowPreferences = $0 }) @@ -590,7 +585,6 @@ struct LitheApp: App { networkSessionProvider: networkSessionProvider ).services services.logWindowCoordinator = logWindowCoordinator - services.languageServerLogsCoordinator = languageServerLogsCoordinator logWindowModel.environment = .init(appVersion: logWindowModel.environment.appVersion, osVersion: logWindowModel.environment.osVersion, architecture: logWindowModel.environment.architecture, plugins: services.pluginManager.snapshots.filter { $0.isEnabled }.map(\.id.rawValue)) @@ -921,15 +915,6 @@ struct LitheApp: App { .defaultSize(width: 1340, height: 786) .windowResizability(.contentMinSize) - Window(L10n.string("Language Server Logs"), id: LitheWindowID.languageServerLogs) { - LanguageServerLogsView(feature: languageServerLogsModel, coordinator: languageServerLogsCoordinator) - .environment(\.locale, settings.language.locale) - .preferredColorScheme(settings.themePreference.preferredColorScheme) - .id(settings.language) - } - .defaultSize(width: 1100, height: 720) - .windowResizability(.contentMinSize) - Window(settingsWindowTitle(for: settings.language), id: LitheWindowID.settings) { SettingsWindowHost(projectSessions: projectSessions, settings: settings) .tint(LitheTheme.accent) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 8b10da54e..61f9c1d43 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -152,6 +152,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 bcb2f39a3..c6049bb24 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -49,8 +49,31 @@ extension AppModel { ) } + /// Opens the editor-area "LSP Logs" tab on `providerID`. The status menu's + /// bottom entry passes the first listed server. func showLanguageServerLogs(providerID: String) { - services.languageServerLogsCoordinator?.open(providerID: providerID, servers: statusBarLanguageServers) + guard let logs = languageServerLogsFeature else { return } + logs.open(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) + languageServerLogsFeature?.stop() + if wasFocused, let fallback = order.focusedItem ?? order.items.last { selectEditorTab(fallback) } } @discardableResult diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift index 0768dd143..9b61feb31 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Media.swift @@ -88,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+WorkbenchClose.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+WorkbenchClose.swift index 6f7f3337c..fc301449c 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 7053f6958..5f14da371 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -209,6 +209,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { var runtimeFeature: RuntimeSettingsFeatureModel { featureGraph.runtime } var toolchainSettingsFeature: ToolchainSettingsFeatureModel { featureGraph.toolchainSettings } var languageToolingFeature: LanguageToolingFeatureModel { featureGraph.languageTooling } + var languageServerLogsFeature: LanguageServerLogsModel? { featureGraph.languageServerLogs } let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking var workspaceFeature: WorkspaceFeatureModel { featureGraph.workspace } diff --git a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift index e5ed6e997..be2d6752b 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorTabItem.swift @@ -6,6 +6,8 @@ enum EditorTabItem: Hashable, Identifiable { case terminal(UUID) case media(UUID) case repositoryDiff(UUID) + /// The single "LSP Logs" utility tab opened from the language server status menu. + 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/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 7ca128cd0..84e1b6f95 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -6,7 +6,6 @@ import SwiftUI enum LitheWindowID { static let logs = "logs" - static let languageServerLogs = "language-server-logs" static let welcome = "welcome" static let settings = "settings" static let project = "project" @@ -297,7 +296,6 @@ private struct ActiveSessionChrome: View { ) .onAppear { session.services.logWindowCoordinator?.present = { openWindow(id: LitheWindowID.logs) } - session.services.languageServerLogsCoordinator?.present = { openWindow(id: LitheWindowID.languageServerLogs) } } .onReceive(session.workbenchFeature.$settingsCategoryRequest.dropFirst()) { _ in SettingsWindowChrome.ownerWindow = NSApp.orderedWindows.first { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index e7386556e..ddb200985 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 @@ -750,6 +779,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) @@ -793,6 +823,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 }) { @@ -1126,7 +1157,9 @@ struct EditorAreaView: View { .allowsHitTesting(paneDiffID == id) .accessibilityHidden(paneDiffID != id) } - if case .agentConversation(let tab) = paneItem { + if paneItem == .languageLogs, let logs = model.languageServerLogsFeature { + LanguageServerLogsView(feature: 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/LanguageServerLogsView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift index 96882c8f5..b30e0a609 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift @@ -1,63 +1,127 @@ -import SwiftUI import LitheModuleAPI +import SwiftUI -/// Server output has its own window; application diagnostics keep their separate entry. +/// Editor-area "LSP Logs" tab: one server at a time, read from the v2 log store. struct LanguageServerLogsView: View { + @EnvironmentObject private var model: AppModel @ObservedObject var feature: LanguageServerLogsModel - @ObservedObject var coordinator: LanguageServerLogsCoordinator + + private var providerIDs: [String] { + Set(model.statusBarLanguageServers.map(\.id) + + (feature.providerID.isEmpty ? [] : [feature.providerID])).sorted() + } var body: some View { VStack(spacing: 0) { - HStack(spacing: 10) { - LitheSettingsSelect(selection: Binding(get: { feature.providerID }, set: feature.select), - options: [""] + feature.servers.map(\.id), width: 200, - accessibilityLabel: "Language Server", title: serverTitle, localizesTitles: false) - Text("Server Logs").foregroundStyle(LitheTheme.accent) - LitheInfoTip("Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.") - Spacer(minLength: 0) - }.padding(12).font(LitheTheme.uiFont(size: 12)) - Divider() - if let error = feature.error { Text(error).foregroundStyle(LitheTheme.error).padding(8) } - LanguageServerLogOutput(model: feature.logs) + 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) + LanguageServerLogOutput(logs: feature.logs, channelError: feature.error, retry: feature.retry) + // 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 + ":" + feature.minimumLevel.rawValue) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .background(LitheTheme.editor) - .frame(minWidth: 720, minHeight: 400) - .onAppear { - if let request = coordinator.request { feature.apply(request) } - feature.start() - } - .onReceive(coordinator.$request) { request in - if let request { feature.apply(request) } + .onAppear { selectInitialProvider() } + .onChange(of: providerIDs) { _ in selectInitialProvider() } + } + + private var serverPicker: some View { + LitheSettingsSelect(selection: Binding(get: { feature.providerID }, set: feature.select), + options: providerIDs, width: 180, + accessibilityLabel: "Language Server", title: providerName, localizesTitles: false) + } + + private var levelPicker: some View { + LitheSettingsSelect(selection: Binding(get: { feature.minimumLevel }, set: feature.setMinimumLevel), + 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 the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics.") } - .onDisappear { feature.stop() } } - private func serverTitle(_ id: String) -> String { - id.isEmpty ? L10n.string("All language servers") : feature.servers.first { $0.id == id }?.displayName ?? id + + 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.select(first) } + } + + private func providerName(_ id: String) -> String { + model.statusBarLanguageServers.first { $0.id == id }?.displayName ?? id + } + + private func levelTitle(_ level: LanguageServerLogsModel.MinimumLevel) -> String { + switch level { + case .info: "Info" + case .warning: "Warning" + case .error: "Error" + } } } +/// Observes the inner log query separately so live batches redraw only the output. private struct LanguageServerLogOutput: View { - @ObservedObject var model: LogWindowModel - @State private var wrapsLines = false + @ObservedObject var logs: LogWindowModel + let channelError: String? + let retry: () -> Void + private var output: String { - model.state.records.map { record in - let diagnostic = record.fields["diagnostic"] ?? record.fields["error"] - let detail = diagnostic.map { $0.isEmpty ? "" : "\n" + $0 } ?? "" - return "[\(record.level)] \(record.ts) \(record.channel) \(record.event)\n\(record.msg)\(detail)\n" - }.joined() + logs.state.records.map { record in + let time = Date(timeIntervalSince1970: TimeInterval(record.epochMs) / 1000) + .formatted(date: .omitted, time: .standard) + // Structured identifiers: the shared console colors the level prefix. + // Upstream messages and field values are shown unchanged. + let fields = record.fields.sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + return "[\(record.level)] \(time) \(record.msg)\(fields.isEmpty ? "" : "\n" + fields)\n" + } + .joined() } + var body: some View { VStack(spacing: 0) { - if let error = model.error { - HStack { - Text(error).foregroundStyle(LitheTheme.error) - Button("Retry") { model.retry() } - }.padding(8) + if let error = channelError ?? logs.error { + HStack(spacing: 8) { + Text(error).foregroundStyle(LitheTheme.error).lineLimit(2) + Button("Retry", action: retry).buttonStyle(.litheNoPress) + Spacer(minLength: 0) + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 12) + .padding(.bottom, 6) } - OutputTextView(output: output, searchRoots: [], fileExists: { _ in false }, - emptyMessage: L10n.string("No matching language server logs"), wrapsLines: wrapsLines, - showsConsoleToolbar: true, onClearOutput: model.clearDisplay, - onToggleWrapsLines: { wrapsLines.toggle() }, onOpenLocation: { _, _, _ in }) + OutputTextView( + output: output, + searchRoots: [], + fileExists: { _ in false }, + emptyMessage: L10n.string("No matching language server logs"), + fontSize: 12, + onOpenLocation: { _, _, _ in } + ) } } } diff --git a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift index 396389148..b6f734e7e 100644 --- a/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift +++ b/macos/Sources/Lithe/Views/Workbench/LanguageServerStatusView.swift @@ -60,7 +60,7 @@ enum LanguageServerStatusMenu { + servers.map { serverItem($0, viewLogs: viewLogs, restart: restart, stop: stop) } + [ .separator, - .action(L10n.string(key: "native.inline.viewLogs"), localizesTitle: false) { viewLogs("") }, + .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) } }, diff --git a/macos/Tests/LitheTests/LogWindowReviewTests.swift b/macos/Tests/LitheTests/LogWindowReviewTests.swift index c248f7970..970e5ee87 100644 --- a/macos/Tests/LitheTests/LogWindowReviewTests.swift +++ b/macos/Tests/LitheTests/LogWindowReviewTests.swift @@ -8,7 +8,7 @@ import LitheModuleAPI @Suite("Log window review", .serialized) @MainActor struct LogWindowReviewTests { - @Test func serverSelectionUsesCoreChannelsAndKeepsOtherEvidenceOut() async throws { + @Test func serverTabShowsOneServerAndClearsOnlyThatServer() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let writer = MacLogStore(defaultDirectory: root) defer { try? FileManager.default.removeItem(at: root) } @@ -16,23 +16,48 @@ struct LogWindowReviewTests { writer.emit(LspMessageLogged(diagnostic: "Go initialization", server: LogSegment("go")!, severity: "info")) writer.emit(AppSessionStarted()) let feature = LanguageServerLogsModel(logging: writer) - let loaded = TestGate() - let observation = feature.logs.$state.sink { if !$0.records.isEmpty { loaded.open() } } - defer { observation.cancel(); feature.stop() } - feature.apply(.init(providerID: "java", servers: [])) - feature.start() - try #require(await loaded.waitUntilOpen()) - #expect(feature.logs.state.records.allSatisfy { $0.channel == "lsp/java" }) - let allLoaded = TestGate() - let all = feature.logs.$state.sink { state in - if Set(state.records.map(\.channel)) == ["lsp/java", "lsp/go"] { allLoaded.open() } + defer { feature.stop() } + // Levels start at Info and there is no "all servers" choice. + #expect(LanguageServerLogsModel.MinimumLevel.allCases == [.info, .warning, .error]) + #expect(feature.logs.query.levels == ["INFO", "WARN", "ERROR"]) + + let java = try await showServer("java", channel: "lsp/java", in: feature) + #expect(feature.logs.query.channelPrefixes == ["lsp/java"]) + feature.clear() + #expect(feature.logs.state.records.isEmpty && feature.logs.state.cleared) + + // Clearing Java leaves Go untouched. + _ = try await showServer("go", channel: "lsp/go", in: feature) + #expect(feature.logs.query.channelPrefixes == ["lsp/go"]) + #expect(!feature.logs.state.cleared) + + // Back on Java the clear point still applies: only the newer row returns. + let returned = TestGate() + let observation = feature.logs.$state.sink { state in + if !state.records.isEmpty, state.records.allSatisfy({ $0.channel == "lsp/java" }) { returned.open() } } - defer { all.cancel() } - feature.select("") - try #require(await allLoaded.waitUntilOpen()) + defer { observation.cancel() } + feature.select("java") + writer.emit(LspMessageLogged(diagnostic: "Java ready", server: LogSegment("java")!, severity: "info")) + try #require(await returned.waitUntilOpen()) + #expect(feature.logs.query.channelPrefixes == ["lsp/java"]) + #expect(Set(feature.logs.state.records.map(LogWindowModel.recordID)).isDisjoint(with: java)) await writer.finishSession() } + /// Selects `id` and waits until the tab shows only `channel`; returns the shown record IDs. + private func showServer(_ id: String, channel: String, in feature: LanguageServerLogsModel) async throws -> Set { + let loaded = TestGate() + let observation = feature.logs.$state.sink { state in + if !state.records.isEmpty, state.records.allSatisfy({ $0.channel == channel }) { loaded.open() } + } + defer { observation.cancel() } + if feature.providerID.isEmpty { feature.open(providerID: id) } else { feature.select(id) } + try #require(await loaded.waitUntilOpen()) + #expect(feature.providerID == id) + return Set(feature.logs.state.records.map(LogWindowModel.recordID)) + } + /// Optional local review: read a task-owned copy of real user evidence, never fabricate records. @Test func renderFinalReviewFromExistingSession() async throws { guard let source = ProcessInfo.processInfo.environment["LITHE_REVIEW_LOG_SESSION"], @@ -101,15 +126,16 @@ struct LogWindowReviewTests { try screenshot(try #require(panel.contentView), output: output, name: dark ? "final-settings-menu-dark.png" : "final-settings-menu-light.png") presenter.dismiss() } - let serverCoordinator = LanguageServerLogsCoordinator() - serverCoordinator.open(providerID: "java", servers: [.init(id: "java", displayName: "jdtls", status: .stopped, version: nil)]) let serverFeature = LanguageServerLogsModel(logging: writer) serverFeature.logs.changeQuery { $0.session = "all" } let serverLoaded = TestGate() - let serverObservation = serverFeature.logs.$state.sink { if !$0.records.isEmpty { serverLoaded.open() } } + let serverObservation = serverFeature.logs.$state.sink { state in + if !state.records.isEmpty, state.records.allSatisfy({ $0.channel == "lsp/java" }) { serverLoaded.open() } + } defer { serverObservation.cancel(); serverFeature.stop() } - let host = NSHostingView(rootView: LanguageServerLogsView(feature: serverFeature, coordinator: serverCoordinator) - .environment(\.locale, settings.language.locale).environment(\.colorScheme, .light)) + serverFeature.open(providerID: "java") + let host = NSHostingView(rootView: LanguageServerLogsView(feature: serverFeature).environmentObject(app) + .environmentObject(settings).environment(\.locale, settings.language.locale).environment(\.colorScheme, .light)) let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 1400, height: 900), styleMask: [.titled, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false; window.contentView = host; window.makeKeyAndOrderFront(nil) diff --git a/macos/Tests/LitheTests/LogWindowTests.swift b/macos/Tests/LitheTests/LogWindowTests.swift index 49a5a4cee..dc781c945 100644 --- a/macos/Tests/LitheTests/LogWindowTests.swift +++ b/macos/Tests/LitheTests/LogWindowTests.swift @@ -387,7 +387,7 @@ struct LogWindowTests { #expect(roundTrip.records == result.records) #expect(roundTrip.opSummary?.durationMs == 0) } - @Test func languageServerEntryOpensItsOwnWindowWithoutChangingApplicationLogs() async throws { + @Test func languageServerEntryOpensEditorTabWithoutChangingApplicationLogs() async throws { let directory = root() let writer = MacLogStore(defaultDirectory: directory) let store = WindowPreferenceStore() @@ -395,19 +395,21 @@ struct LogWindowTests { let container = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode, applicationLogging: writer) defer { container.moduleLifecycleCoordinator.stop(); try? FileManager.default.removeItem(at: directory) } let model = AppModel(settings: settings, services: container.services) - let coordinator = LanguageServerLogsCoordinator() - container.services.languageServerLogsCoordinator = coordinator let applicationLogs = LogWindowCoordinator() container.services.logWindowCoordinator = applicationLogs - let opened = TestGate() - coordinator.present = { opened.open() } try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in - let tabs = model.editorTabItems + let feature = try #require(model.languageServerLogsFeature) model.showLanguageServerLogs(providerID: "c++") - #expect(await opened.waitUntilOpen()) - #expect(coordinator.request?.providerID == "c++") + #expect(model.editorTabItems.filter { $0 == .languageLogs }.count == 1) + #expect(model.editorTabOrderFeature.focusedItem == .languageLogs) + #expect(feature.providerID == "c++") #expect(applicationLogs.request == nil) - #expect(model.editorTabItems == tabs) + // Opening again reuses the one tab and switches its server. + model.showLanguageServerLogs(providerID: "java") + #expect(model.editorTabItems.filter { $0 == .languageLogs }.count == 1) + #expect(feature.providerID == "java") + model.closeLanguageServerLogsTab() + #expect(!model.editorTabItems.contains(.languageLogs)) } await writer.finishSession() } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 3e1d96deb..3a22754e4 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -10050,9 +10050,10 @@ "logs.window.startTime": "Start time", "logs.window.endTime": "End time", "logs.window.reversedTime": "The end time must be after the start time.", - "logs.language.windowTitle": "Language Server Logs", - "logs.language.allServers": "All language servers", - "logs.language.outputHelp": "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.", + "logs.language.tabTitle": "LSP Logs", + "logs.language.minimumLevel": "Minimum level", + "logs.language.outputHelp": "Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics.", + "logs.language.clearHelp": "Clear only the selected server's visible logs. Collected diagnostic logs are kept.", "logs.language.outputTitle": "Server Logs", "logs.language.empty": "No matching language server logs" }, diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index ea68222fa..66940bfae 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -10050,9 +10050,10 @@ "logs.window.startTime": "开始时间", "logs.window.endTime": "结束时间", "logs.window.reversedTime": "结束时间不能早于开始时间。", - "logs.language.windowTitle": "语言服务器日志", - "logs.language.allServers": "全部语言服务器", - "logs.language.outputHelp": "显示所选语言服务器的会话事件、标准错误输出和服务消息。应用日志请从“日志与诊断…”单独查看。", + "logs.language.tabTitle": "LSP 日志", + "logs.language.minimumLevel": "最低级别", + "logs.language.outputHelp": "显示所选服务器的会话事件、stderr 和服务器消息,来自已脱敏的诊断日志。不提供 Server Trace 和 RPC 消息捕获。应用日志在“日志与诊断”中查看。", + "logs.language.clearHelp": "仅清空所选服务器的可见日志,保留已采集的诊断日志。", "logs.language.outputTitle": "服务器日志", "logs.language.empty": "没有匹配的语言服务器日志" }, diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index d31fed906..c80a9ad8b 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -96859,35 +96859,46 @@ } }, { - "id": "logs.language.windowTitle", - "description": "Language server output window title", + "id": "logs.language.tabTitle", + "description": "Editor tab title for the language server logs view", "platforms": [ "macos" ], "macos": { - "key": "Language Server Logs", + "key": "LSP Logs", "arguments": [] } }, { - "id": "logs.language.allServers", - "description": "All server selection in the dedicated LSP log window", + "id": "logs.language.minimumLevel", + "description": "Accessibility label of the minimum log level picker in the LSP Logs tab", "platforms": [ "macos" ], "macos": { - "key": "All language servers", + "key": "Minimum level", "arguments": [] } }, { "id": "logs.language.outputHelp", - "description": "Explain dedicated upstream language server evidence", + "description": "Info tip explaining which language server evidence the LSP Logs tab shows", "platforms": [ "macos" ], "macos": { - "key": "Shows the selected language server's session events, stderr and server messages. Application logs are available separately in Logs and Diagnostics.", + "key": "Shows the selected server's session events, stderr and server messages from the redacted diagnostic logs. Server Trace and RPC message capture are not available. Application logs are in Logs and Diagnostics.", + "arguments": [] + } + }, + { + "id": "logs.language.clearHelp", + "description": "Tooltip of the Clear View button in the LSP Logs tab", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear only the selected server's visible logs. Collected diagnostic logs are kept.", "arguments": [] } }, diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index 5fbd7d233..0c21386c6 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -36,7 +36,7 @@ "scripts/macos/probe/probe-macos-logging-ui.sh", "macos/Sources/Lithe/Application/Features/LanguageServerLogsModel.swift", "macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift", - "macos/Sources/Lithe/Views/App/RootView.swift", + "macos/Sources/Lithe/Views/Editor/EditorAreaView.swift", "macos/Tests/LitheTests/LogWindowReviewTests.swift" ], "implementationStatus": "implemented", @@ -50,5 +50,5 @@ "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。 语言服务器日志拆窗:状态菜单的“查看日志”打开独立的“语言服务器日志”窗口,不再改写应用日志窗口的通道筛选;窗口用同一份脱敏 v2 记录,按 Core 解析的服务器通道过滤,可切换服务器或查看全部 lsp 通道,菜单底部入口默认全部服务器。运行 LogWindowTests 与 LogWindowReviewTests 验证;真实 Java 项目整机验收保持 pending。" + "verification": "PR4 B1:查看日志通过 diagnostics.resolveEngineLogSubject 打开统一应用日志窗口并选择通道;已删除旧编辑器标签页和 100 条 v1 内存缓冲。验证同一窗口复用、记录引用定位、可重绑 ⌘⇧L、齿轮菜单顺序、Core 过滤与复制、调用链、虚拟表格滚动、实时批次、清空不删除文件。中英文、深浅主题和真实 Java / Git 整机验收保持 pending。导出对话框、设置页瘦身和 UI 探针属于 B2。状态栏继续显示已注册、已启用插件的真实状态;停止与重启保持会话身份和过期回调隔离。Windows 保持 v1,不宣称两端一致。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR3 审查回归:详细诊断先开启再启动慢初始化服务器,确认 initialized 之前没有 setTrace,初始化前关闭可撤销待启用状态;令日志根不可用,不使用 logDir 的插件仍能启动,依赖它的模板明确失败,恢复日志存储后可查看写入失败计数;trace verbose 中的文档源码和 RPC 正文不得进入 v2 日志。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:历史与实时批次均遵守时间框选;上一会话高序号不影响当前会话清空后的新日志;窗口查看 previous 时点击当前错误引用会切回 current;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。 LSP 日志标签页:状态菜单的“查看日志”打开或聚焦编辑器区唯一的“LSP 日志”标签页,不改写应用日志窗口的通道筛选;标签页读取同一份脱敏 v2 记录,按 Core 解析的通道一次只显示一个服务器,没有全部服务器选项,菜单底部入口打开第一个服务器;最低级别默认信息,清空视图按服务器分别记录清空点,切换服务器后互不影响。运行 LogWindowTests 与 LogWindowReviewTests 验证标签打开、复用与关闭、单服务器通道和按服务器清空;真实 Java 项目整机验收保持 pending。" }