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 49da5415e..2c3b3d404 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 @@ -44,12 +44,13 @@ macOS 的应用日志只在一个地方看:用 ⌘⇧L 打开的独立日志 - 标签页顶部从左到右是:服务器选择、“服务器日志”和 ⓘ 说明、最低级别(信息、警告、错误,默认信息)、“清空视图”。 - “清空视图”只隐藏当前服务器已经显示的记录,不删除磁盘上的日志。模型为每个服务器分别保存清空点(每个日志会话已显示到的 seq),切换服务器时换入该服务器自己的清空点。所以清空 Java 后切到 Go,Go 的记录照常显示;切回 Java,之前清空的记录仍然隐藏,只显示清空之后的新记录。 - 生命周期属于标签页。关闭标签页或重置工作区时调用 `stop()`,查询、订阅和清空点一起丢弃;切到别的标签页只是视图消失,查询继续,清空点保留。 +- 输出文本由 `LanguageServerLogText` 按记录身份(与 `LogWindowModel.recordID` 相同的路径、seq、时间)逐条缓存。实时批次只在末尾追加、缓冲满 20000 条后从开头淘汰,所以每批只格式化新增记录,并按缓存的 UTF-8 长度从文本开头截掉被淘汰的行;状态、摘要、选中这类发布不改变记录,一条都不格式化。新查询、清空等其他变化整份重建一次。每批耗时随批次大小增长,而不是随缓冲大小增长,与应用日志窗口在主线程按记录缓存显示内容的做法一致,因此没有移到后台线程。 数据来源仍是 `MacLogStore` 的磁盘 v2 记录和实时广播,所以标签页里看到的就是应用日志窗口和诊断导出里的同一份脱敏证据。 已知限制:通道只由服务器 id 决定,不区分工作区窗口,同一个服务器在其他窗口产生的记录也会出现在这里。Server Trace 和 RPC 消息捕获没有接入 v2 日志,ⓘ 说明里写明了这一点。 -不要这样做:为语言服务器另建内存缓冲或第二份日志文件;在 Swift 里用 providerID 拼通道名;为了打开这个标签页改写应用日志窗口的查询;在通道解析完成前就开始查询;用一个共享的清空点同时清空所有服务器。 +不要这样做:每次状态发布都对全部记录重新格式化并拼接全文(#1700 审查发现的问题);为语言服务器另建内存缓冲或第二份日志文件;在 Swift 里用 providerID 拼通道名;为了打开这个标签页改写应用日志窗口的查询;在通道解析完成前就开始查询;用一个共享的清空点同时清空所有服务器。 ### 设置里没有日志页 @@ -152,8 +153,21 @@ Swift 只负责读日志文件尾部、订阅 `MacLogStore` 的实时批次、 ### 窗口尺寸、筛选与实时呈现 表格把窗口多出来的宽度分给事件、消息和字段列;窄窗口保留原来的可读列宽及横向滚动。 -自动分配只属于当前视口,不写用户偏好。只有表头拖动结束后才保存手动列宽,避免窗口放大、 -隐藏列或延迟的原生列宽通知污染已保存配置。详情与调用链以视口宽度作为最小内容宽度, +每列保存的是“基准宽度”:固定列直接显示基准宽度,三个扩展列按各自基准宽度的比例分掉视口剩余宽度。 +自动分配只属于当前视口,不写用户偏好。只有表头拖动结束后才保存列宽,避免窗口放大、 +隐藏列或延迟的原生列宽通知污染已保存配置。 + +拖动扩展列松手时,`LogTableView.adoptResizedWidths(previous:)` 把松手宽度反推成基准宽度: +设可分配宽度为 S、其他扩展列基准之和为 B,列显示为 b·S/(b+B),反推得 b = r·B/(S−r)。 +这样下一次适配视口、窗口缩窄再放大、重新打开窗口时,这一列都停在松手的位置; +缩窄到没有剩余宽度时显示基准宽度并横向滚动。反推结果低于最小列宽 40pt 时,基准取 40pt, +其余扩展列的基准按同一比例放大,松手宽度照样保持。只剩一个扩展列时它总是填满整行, +松手超过 S−B 时已经没有剩余宽度,这两种情况基准就是松手宽度。 + +不要这样做:把扩展列松手时的显示宽度直接存成基准宽度。下一次适配会在它上面再加一份剩余宽度, +松手后列又变宽,而且自动扩张的部分也被保存(#1700 审查发现的问题:约 483pt 的事件列拖到 493pt 后变成约 750pt)。 + +详情与调用链以视口宽度作为最小内容宽度, 从左上角展开,长内容继续横向滚动;右侧动作说明在自己的滚动区域内,不能覆盖底部状态栏。 筛选框复用共享搜索组件,ⓘ 说明表达式的 AND、排除、字段、前缀和正则语法,以及它与通道、 diff --git a/macos/Sources/Lithe/Application/Features/LogWindowModel.swift b/macos/Sources/Lithe/Application/Features/LogWindowModel.swift index f79267b7e..fbb19ec30 100644 --- a/macos/Sources/Lithe/Application/Features/LogWindowModel.swift +++ b/macos/Sources/Lithe/Application/Features/LogWindowModel.swift @@ -42,6 +42,9 @@ final class LogWindowModel: ObservableObject { static let maximumBookmarks = 50 static let maximumChannels = 64 static let maximumExpressionBytes = 1024 + /// Bounds a saved column width. The table clamps its base widths the same + /// way, so what it lays out is what a reopened window restores. + static func columnWidth(_ width: Double) -> Double { min(2000, max(40, width)) } struct Snapshot { var records: [RustCoreBridge.LogQueryRecord] = [] var channels: [String: RustCoreBridge.LogChannelCounts] = [:] @@ -100,7 +103,7 @@ final class LogWindowModel: ObservableObject { self.logging = logging; self.preferences = preferences; self.sink = sink; self.save = save; self.delay = delay let columns: Set = ["seq", "time", "level", "channel", "event", "message", "fields"] self.preferences.hidden.formIntersection(columns) - self.preferences.widths = preferences.widths.filter { columns.contains($0.key) && $0.value.isFinite }.mapValues { min(2000, max(40, $0)) } + self.preferences.widths = preferences.widths.filter { columns.contains($0.key) && $0.value.isFinite }.mapValues(Self.columnWidth) self.preferences.timeMode = min(2, max(0, preferences.timeMode)) self.preferences.query.expression = Self.bounded(preferences.query.expression) self.preferences.query.channelPrefixes = Array(preferences.query.channelPrefixes.prefix(Self.maximumChannels)) @@ -408,7 +411,7 @@ final class LogWindowModel: ObservableObject { func setColumn(_ column: String, hidden: Bool) { if hidden { preferences.hidden.insert(column) } else { preferences.hidden.remove(column) }; persist() } - func setWidth(_ column: String, width: Double) { preferences.widths[column] = min(2000, max(40, width)); persist() } + func setWidth(_ column: String, width: Double) { preferences.widths[column] = Self.columnWidth(width); persist() } func saveBookmark(name: String) { guard !name.isEmpty, preferences.bookmarks.count < Self.maximumBookmarks else { return } var savedQuery = query; savedQuery.afterSequences = nil diff --git a/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift b/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift index 910e1f093..74ebe190f 100644 --- a/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift +++ b/macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift @@ -245,9 +245,8 @@ struct LogWindowTable: NSViewRepresentable { } 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) + for (identifier, width) in table.adoptResizedWidths(previous: previous) { + model.setWidth(identifier.rawValue, width: width) } table.fitColumnsToViewport() } @@ -403,24 +402,72 @@ final class LogTableView: NSTableView { /// Widths are presentation state: resizing never reloads records or persists /// the space added by the viewport. Narrow windows keep horizontal scrolling. + /// + /// `preferredWidths` holds base widths. Fixed columns show their base; the + /// expanding columns share the viewport's spare width in proportion to theirs. 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 expanding = expandingColumns(visible) + let baseline = visible.reduce(CGFloat.zero) { $0 + base($1) + intercellSpacing.width } let spare = max(0, scroll.contentSize.width - baseline) - let weight = expanding.reduce(CGFloat.zero) { $0 + (preferredWidths[$1.identifier] ?? $1.width) } + let weight = expanding.reduce(CGFloat.zero) { $0 + base($1) } isFittingColumns = true defer { isFittingColumns = false } for column in visible { - let preferred = preferredWidths[column.identifier] ?? column.width + let preferred = base(column) 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 } } } + + /// Turns the widths a header drag left on screen into base widths, so the next + /// fit shows every dragged column where the user released it. Saving the + /// released width of an expanding column as its base would add its share of + /// the spare width again on top. Returns the bases that changed, to persist. + func adoptResizedWidths(previous: [NSUserInterfaceItemIdentifier: CGFloat]) -> [NSUserInterfaceItemIdentifier: CGFloat] { + let visible = tableColumns.filter { !$0.isHidden } + let expanding = expandingColumns(visible) + let resized = visible.filter { previous[$0.identifier] != $0.width } + var changed: [NSUserInterfaceItemIdentifier: CGFloat] = [:] + func adopt(_ column: NSTableColumn, _ width: CGFloat) { + let value = CGFloat(LogWindowModel.columnWidth(Double(width))) + preferredWidths[column.identifier] = value + changed[column.identifier] = value + } + // A fixed column shows its base. Adopt these first: they decide how much + // space the expanding columns share. + for column in resized where !expanding.contains(column) { adopt(column, column.width) } + let viewport = enclosingScrollView?.contentSize.width ?? 0 + for column in resized where expanding.contains(column) { + let released = column.width + let others = expanding.filter { $0 !== column } + let otherBases = others.reduce(CGFloat.zero) { $0 + base($1) } + let shared = visible.reduce(viewport) { $0 - intercellSpacing.width - (expanding.contains($1) ? 0 : base($1)) } + // With base b, shared width S and the other expanding bases B, the column + // shows b·S/(b+B) while b+B < S, and b otherwise. A lone expanding column + // always fills the row, and a release past S-B leaves no spare width: + // in both cases the base is the released width. + guard otherBases > 0, released < shared - otherBases else { adopt(column, released); continue } + let exact = released * otherBases / (shared - released) + if exact >= column.minWidth { adopt(column, exact); continue } + // The base cannot go below the minimum width. Keep the released layout + // by giving the other expanding columns proportionally larger bases. + let scale = column.minWidth / exact + adopt(column, column.minWidth) + for other in others { adopt(other, base(other) * scale) } + } + return changed + } + + private func expandingColumns(_ visible: [NSTableColumn]) -> [NSTableColumn] { + let flexible = visible.filter { ["event", "message", "fields"].contains($0.identifier.rawValue) } + return flexible.isEmpty ? Array(visible.suffix(1)) : flexible + } + + private func base(_ column: NSTableColumn) -> CGFloat { preferredWidths[column.identifier] ?? column.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 } diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerLogText.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogText.swift new file mode 100644 index 000000000..ae818a090 --- /dev/null +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogText.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Console text for the editor-area "LSP Logs" tab, kept in step with the +/// record buffer one record at a time. +/// +/// A live batch appends records to the end of `LogWindowModel.state.records` +/// and, once the buffer holds `LogWindowModel.maximumRecords`, evicts as many +/// from the front. Other publishes (status, live summary, selection) leave the +/// records as they are. Formatting only the appended records and cutting the +/// evicted lines off the front keeps a batch's cost proportional to the batch, +/// not to the buffer. Any other change, such as a new query or a clear, +/// rebuilds the text once. +@MainActor +final class LanguageServerLogText: ObservableObject { + typealias Record = RustCoreBridge.LogQueryRecord + + /// The identity `LogWindowModel.recordID` uses, and the UTF-8 length of the + /// record's line in `text`. The line itself lives only in `text`. + private struct Line { + let relativePath: String + let seq: UInt64 + let ts: String + let bytes: Int + + func shows(_ record: Record) -> Bool { + seq == record.seq && ts == record.ts && relativePath == record.relativePath + } + } + + private static let time = Date.FormatStyle(date: .omitted, time: .standard) + private let format: @MainActor (Record) -> String + private var lines: [Line] = [] + private(set) var text = "" + + init(format: @escaping @MainActor (Record) -> String = LanguageServerLogText.line) { + self.format = format + } + + /// One record as the console shows it. Level, time and field keys are + /// structured identifiers (the shared console colors the level prefix); + /// upstream messages and field values are shown unchanged. + static func line(_ record: Record) -> String { + let time = Date(timeIntervalSince1970: TimeInterval(record.epochMs) / 1000).formatted(Self.time) + 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" + } + + /// Returns the text for `records`, reusing the lines of records it already shows. + func update(_ records: [Record]) -> String { + // The records already shown must survive as one run at the front, after + // the evicted ones; anything else is a new buffer. + guard let first = records.first, + let evicted = lines.firstIndex(where: { $0.shows(first) }), + records.count >= lines.count - evicted, + zip(lines[evicted...], records).allSatisfy({ $0.shows($1) }) + else { return rebuild(records) } + if evicted > 0 { + // Every line ends with a newline, so the cut lands on a character boundary. + let bytes = lines[.. String { + lines.removeAll(keepingCapacity: true) + text = "" + for record in records { append(record) } + return text + } + + private func append(_ record: Record) { + let line = format(record) + lines.append(Line(relativePath: record.relativePath, seq: record.seq, ts: record.ts, bytes: line.utf8.count)) + text += line + } +} diff --git a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift index b30e0a609..ee5c06f63 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift @@ -87,20 +87,9 @@ private struct LanguageServerLogOutput: View { @ObservedObject var logs: LogWindowModel let channelError: String? let retry: () -> Void - - private var output: String { - 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() - } + /// Formats each record once; a publish that leaves the records unchanged + /// formats nothing. + @StateObject private var text = LanguageServerLogText() var body: some View { VStack(spacing: 0) { @@ -115,7 +104,7 @@ private struct LanguageServerLogOutput: View { .padding(.bottom, 6) } OutputTextView( - output: output, + output: text.update(logs.state.records), searchRoots: [], fileExists: { _ in false }, emptyMessage: L10n.string("No matching language server logs"), diff --git a/macos/Tests/LitheTests/LanguageServerLogTextTests.swift b/macos/Tests/LitheTests/LanguageServerLogTextTests.swift new file mode 100644 index 000000000..45b9fb36f --- /dev/null +++ b/macos/Tests/LitheTests/LanguageServerLogTextTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("LSP Logs console text") +@MainActor +struct LanguageServerLogTextTests { + private typealias Record = RustCoreBridge.LogQueryRecord + + private func record(_ seq: UInt64, path: String = "lsp/a.jsonl", msg: String? = nil, + fields: [String: String] = [:]) -> Record { + Record(line: "", channel: "lsp/example", lineNumber: Int(seq), foldKey: "", epochMs: 1_700_000_000_000, + opOffsetMs: nil, relativePath: path, ts: "2026-11-04T00:00:00.\(seq)Z", level: "INFO", seq: seq, + event: "lsp.server.stderr", op: nil, msg: msg ?? "line \(seq)", fields: fields) + } + + /// Counts formatter calls; the count stands in for the work a publish costs. + private final class Formatter { + var calls = 0 + @MainActor func line(_ record: Record) -> String { + calls += 1 + return Self.expected([record]) + } + static func expected(_ records: [Record]) -> String { + records.map { "\($0.relativePath)#\($0.seq) \($0.msg)\n" }.joined() + } + } + + @Test func liveBatchesFormatOnlyTheRecordsTheyAppend() { + let formatter = Formatter() + let text = LanguageServerLogText(format: formatter.line) + let cap = UInt64(LogWindowModel.maximumRecords) + let full = (1...cap).map { record($0) } + #expect(text.update(full) == Formatter.expected(full)) + #expect(formatter.calls == full.count) + + // Status, summary and selection publishes carry the same records. + formatter.calls = 0 + #expect(text.update(full) == Formatter.expected(full)) + #expect(formatter.calls == 0) + + // A full buffer evicts from the front as a batch appends at the end. + let next = Array(full.dropFirst(10)) + (1...10).map { record(cap + $0) } + #expect(text.update(next) == Formatter.expected(next)) + #expect(formatter.calls == 10) + + // A batch below the cap only appends. + formatter.calls = 0 + let grown = next + [record(cap + 11)] + #expect(text.update(grown) == Formatter.expected(grown)) + #expect(formatter.calls == 1) + } + + @Test func anyOtherChangeRebuildsTheText() { + let text = LanguageServerLogText(format: Formatter().line) + _ = text.update((1...5).map { record($0) }) + + // A new query with the same count but other records. + let replaced = (1...5).map { record($0, path: "lsp/b.jsonl") } + #expect(text.update(replaced) == Formatter.expected(replaced)) + + // A record dropped from the middle is not an eviction. + let gapped = [replaced[0]] + replaced[2...] + #expect(text.update(gapped) == Formatter.expected(gapped)) + + // A new query that shares the first record but not the rest. + let diverged = [gapped[0], record(99, path: "lsp/b.jsonl")] + #expect(text.update(diverged) == Formatter.expected(diverged)) + + // "Clear View" empties the buffer; the next batch starts a fresh text. + #expect(text.update([]) == "") + let after = [record(100)] + #expect(text.update(after) == Formatter.expected(after)) + } + + @Test func evictionKeepsMultiByteLinesIntact() { + let text = LanguageServerLogText() + let records = [record(1, msg: "服务器已启动 🚀"), record(2, msg: "e\u{301}"), record(3, msg: "done")] + _ = text.update(records) + let next = Array(records.dropFirst(2)) + [record(4, msg: "第四行")] + #expect(text.update(next) == next.map(LanguageServerLogText.line).joined()) + } + + @Test func defaultLineShowsLevelTimeMessageAndSortedFields() { + let value = record(7, msg: "exited", fields: ["status": "1", "pid": "42"]) + let time = Date(timeIntervalSince1970: 1_700_000_000).formatted(date: .omitted, time: .standard) + #expect(LanguageServerLogText.line(value) == "[INFO] \(time) exited\npid=42 status=1\n") + #expect(LanguageServerLogText.line(record(8, msg: "ready")) == "[INFO] \(time) ready\n") + } +} diff --git a/macos/Tests/LitheTests/LogWindowTests.swift b/macos/Tests/LitheTests/LogWindowTests.swift index dc781c945..479e9b804 100644 --- a/macos/Tests/LitheTests/LogWindowTests.swift +++ b/macos/Tests/LitheTests/LogWindowTests.swift @@ -116,6 +116,83 @@ struct LogWindowTests { #expect(model.preferences.widths == preferences.widths) } + /// A header drag on an expanding column ends where the user released it: the + /// viewport fit after the drag, a narrower window and a reopened window all + /// agree, and only base widths are saved. + @Test func draggedColumnsKeepTheirReleasedWidth() async throws { + let directory = root() + let writer = MacLogStore(defaultDirectory: directory) + await writer.finishSession() + defer { try? FileManager.default.removeItem(at: directory) } + var saved = LogWindowPreferences() + var opened: [(LogWindowModel, NSWindow)] = [] + defer { for (model, window) in opened { model.stop(); window.contentView = nil; window.close() } } + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + func open() throws -> (NSWindow, NSView, LogTableView) { + let model = LogWindowModel(logging: writer, preferences: saved, save: { saved = $0 }) + let host = NSHostingView(rootView: LogWindowTable(model: model)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1900, height: 600), + styleMask: [.borderless, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + opened.append((model, window)) + host.layoutSubtreeIfNeeded() + return (window, host, try #require(descendants(host).compactMap { $0 as? LogTableView }.first)) + } + func width(_ table: LogTableView, _ column: String) -> CGFloat { + table.tableColumn(withIdentifier: .init(column))?.width ?? 0 + } + /// What a header drag does: AppKit resizes the column while the fit is + /// paused, then the header commits the widths it started from. + func drag(_ table: LogTableView, _ column: String, to released: CGFloat) throws { + let previous = Dictionary(uniqueKeysWithValues: table.tableColumns.map { ($0.identifier, $0.width) }) + table.isResizingColumns = true + table.tableColumn(withIdentifier: .init(column))?.width = released + table.isResizingColumns = false + try #require(table.owner).commitColumnWidths(previous: previous) + } + func fillsViewport(_ table: LogTableView) throws -> Bool { + let scroll = try #require(table.enclosingScrollView) + let used = table.tableColumns.filter { !$0.isHidden }.reduce(0) { $0 + $1.width + table.intercellSpacing.width } + return abs(used - scroll.contentSize.width) <= 2 + } + + let (window, host, table) = try open() + let expanded = width(table, "event") + #expect(expanded > 300) + try drag(table, "event", to: expanded + 10) + host.layoutSubtreeIfNeeded() + #expect(abs(width(table, "event") - (expanded + 10)) <= 0.5) + #expect(try fillsViewport(table)) + let base = try #require(saved.widths["event"]) + #expect(base < expanded) + #expect(saved.widths.keys.sorted() == ["event"]) + + // A narrower window shows the base and scrolls; widening again restores + // the released width. Resizing the window saves nothing. + window.setContentSize(NSSize(width: 680, height: 600)) + host.layoutSubtreeIfNeeded() + #expect(abs(width(table, "event") - base) <= 0.5) + window.setContentSize(NSSize(width: 1900, height: 600)) + host.layoutSubtreeIfNeeded() + #expect(abs(width(table, "event") - (expanded + 10)) <= 0.5) + #expect(saved.widths == ["event": base]) + + let (_, _, reopened) = try open() + #expect(abs(width(reopened, "event") - (expanded + 10)) <= 0.5) + + // Narrower than the minimum base allows: the other expanding columns take + // larger bases so the released width still holds after reopening. + try drag(reopened, "event", to: 60) + #expect(abs(width(reopened, "event") - 60) <= 0.5) + #expect(try fillsViewport(reopened)) + #expect(saved.widths["event"] == 40) + let (_, _, again) = try open() + #expect(abs(width(again, "event") - 60) <= 0.5) + #expect(abs(width(again, "message") - width(reopened, "message")) <= 0.5) + } + 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) { diff --git a/shared/platform-feature-matrix/features/lsp-status-logs.json b/shared/platform-feature-matrix/features/lsp-status-logs.json index 48270643a..f5f049bc6 100644 --- a/shared/platform-feature-matrix/features/lsp-status-logs.json +++ b/shared/platform-feature-matrix/features/lsp-status-logs.json @@ -39,7 +39,9 @@ "macos/Sources/Lithe/Views/Editor/EditorAreaView.swift", "macos/Tests/LitheTests/LogWindowReviewTests.swift", "rust/lithe-core/src/lsp/interface/plugin_logging.rs", - "shared/contracts/plugins/v1/fixtures/plugin-logging.json" + "shared/contracts/plugins/v1/fixtures/plugin-logging.json", + "macos/Sources/Lithe/Views/Language/LanguageServerLogText.swift", + "macos/Tests/LitheTests/LanguageServerLogTextTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -52,5 +54,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;一次实时查询失败后暂停并通过重试或恢复跟随重建订阅、补读遗漏记录,关闭后无残留订阅。 LSP 日志标签页:状态菜单的“查看日志”打开或聚焦编辑器区唯一的“LSP 日志”标签页,不改写应用日志窗口的通道筛选;标签页读取同一份脱敏 v2 记录,按 Core 解析的通道一次只显示一个服务器,没有全部服务器选项,菜单底部入口打开第一个服务器;最低级别默认信息,清空视图按服务器分别记录清空点,切换服务器后互不影响。运行 LogWindowTests 与 LogWindowReviewTests 验证标签打开、复用与关闭、单服务器通道和按服务器清空;真实 Java 项目整机验收保持 pending。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍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。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍pending。 #1700 审查修复:表头拖动扩展列松手后按松手宽度反推并保存基准宽度,适配视口、缩窄再放大和重开窗口后列仍停在松手位置,反推低于最小列宽时按比例放大其余扩展列基准;LSP 日志标签页按记录身份缓存输出文本,实时批次只格式化新增记录并从开头截掉淘汰记录,记录不变的发布不格式化。运行 LogWindowTests 的 draggedColumnsKeepTheirReleasedWidth 与 LanguageServerLogTextTests 验证;整机拖动和长输出验收保持 pending。" }