Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 拼通道名;为了打开这个标签页改写应用日志窗口的查询;在通道解析完成前就开始查询;用一个共享的清空点同时清空所有服务器。

### 设置里没有日志页

Expand Down Expand Up @@ -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、排除、字段、前缀和正则语法,以及它与通道、
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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<String> = ["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))
Expand Down Expand Up @@ -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
Expand Down
63 changes: 55 additions & 8 deletions macos/Sources/Lithe/Views/Diagnostics/LogWindowTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 }
Expand Down
81 changes: 81 additions & 0 deletions macos/Sources/Lithe/Views/Language/LanguageServerLogText.swift
Original file line number Diff line number Diff line change
@@ -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[..<evicted].reduce(0) { $0 + $1.bytes }
text.removeSubrange(text.startIndex..<text.utf8.index(text.startIndex, offsetBy: bytes))
lines.removeFirst(evicted)
}
for record in records[lines.count...] { append(record) }
return text
}

private func rebuild(_ records: [Record]) -> 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
}
}
19 changes: 4 additions & 15 deletions macos/Sources/Lithe/Views/Language/LanguageServerLogsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"),
Expand Down
Loading
Loading