@@ -1,17 +1,39 @@
1import SwiftUI
1import SwiftUI
3−/// Shared column width logic used by both header and row views.
3+/// Computes the display width needed for a text string using the given font.
4+private func measureTextWidth(_ text: String, font: NSFont) -> CGFloat {
5+ let attributes: [NSAttributedString.Key: Any] = [.font: font]
6+ return ceil((text as NSString).size(withAttributes: attributes).width)
9+/// Font used for header text measurement.
10+private let headerMeasureFont = NSFont.systemFont(ofSize: 10, weight: .semibold)
11+/// Font used for cell text measurement.
12+private let cellMeasureFont = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)
14+/// Computes the ideal width for a single column by sampling record content.
4−func columnWidth(for column: String) -> CGFloat {
15+func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
5− switch column {
16+ let padding: CGFloat = 28 // horizontal padding (6*2) + sort indicator + buffer
6− case "p_timestamp", "timestamp", "@timestamp", "time":
17+ let minWidth: CGFloat = 50
7− return 200
18+ let maxWidth: CGFloat = 600
8− case "level", "severity", "log_level":
19+ let sampleCount = min(records.count, 200)
10− case "message", "msg", "body", "log":
21+ var widest = measureTextWidth(column, font: headerMeasureFont)
11− return 400
22+ for i in 0..<sampleCount {
12− default:
23+ let text = records[i][column]?.displayString ?? ""
13− return 160
24+ widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
27+ return min(max(widest + padding, minWidth), maxWidth)
30+/// Computes ideal widths for all columns by sampling record content.
31+func computeColumnWidths(columns: [String], records: [LogRecord]) -> [String: CGFloat] {
32+ var widths: [String: CGFloat] = [:]
33+ for column in columns {
34+ widths[column] = idealColumnWidth(for: column, records: records)
17/// Shared log-level color mapping.
39/// Shared log-level color mapping.
@@ -25,6 +47,8 @@ func levelColor(for value: String) -> Color {
50+// MARK: - LogTableView
28struct LogTableView: View {
52struct LogTableView: View {
29 let records: [LogRecord]
53 let records: [LogRecord]
30 let columns: [String]
54 let columns: [String]
@@ -34,6 +58,7 @@ struct LogTableView: View {
34 @State private var selectedIndex: Int?
58 @State private var selectedIndex: Int?
35 @State private var cachedSorted: [LogRecord] = []
59 @State private var cachedSorted: [LogRecord] = []
36 @State private var sortTask: Task<Void, Never>?
60 @State private var sortTask: Task<Void, Never>?
61+ @State private var columnWidths: [String: CGFloat] = [:]
38 var body: some View {
63 var body: some View {
39 if records.isEmpty {
64 if records.isEmpty {
@@ -54,6 +79,7 @@ struct LogTableView: View {
54 LogRowView(
79 LogRowView(
55 record: cachedSorted[index],
80 record: cachedSorted[index],
56 columns: columns,
81 columns: columns,
82+ columnWidths: columnWidths,
57 isSelected: selectedIndex == index,
83 isSelected: selectedIndex == index,
58 isAlternate: index % 2 == 1
84 isAlternate: index % 2 == 1
@@ -66,7 +92,9 @@ struct LogTableView: View {
66 LogHeaderView(
92 LogHeaderView(
67 columns: columns,
93 columns: columns,
68 sortColumn: $sortColumn,
94 sortColumn: $sortColumn,
69− sortAscending: $sortAscending
95+ sortAscending: $sortAscending,
96+ columnWidths: $columnWidths,
@@ -78,10 +106,16 @@ struct LogTableView: View {
78 .frame(minWidth: 300, idealWidth: 350)
106 .frame(minWidth: 300, idealWidth: 350)
81− .onChange(of: records) { _, _ in debouncedRebuildSort() }
109+ .onChange(of: records) { _, _ in
110+ columnWidths = computeColumnWidths(columns: columns, records: records)
111+ debouncedRebuildSort()
82 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
113 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
83 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
114 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
116+ columnWidths = computeColumnWidths(columns: columns, records: records)
84− .onAppear { rebuildSort() }
117+ rebuildSort()
@@ -113,39 +147,102 @@ struct LogTableView: View {
150+// MARK: - ColumnResizeHandle
152+struct ColumnResizeHandle: View {
153+ @Binding var columnWidth: CGFloat
154+ @State private var isHovering = false
155+ @State private var initialWidth: CGFloat?
157+ var body: some View {
162+ .fill(Color.secondary.opacity(isHovering ? 0.5 : 0.2))
165+ .contentShape(Rectangle())
166+ .onHover { hovering in
167+ isHovering = hovering
169+ NSCursor.resizeLeftRight.push()
175+ DragGesture(minimumDistance: 1)
176+ .onChanged { gesture in
177+ if initialWidth == nil {
178+ initialWidth = columnWidth
180+ let newWidth = (initialWidth ?? columnWidth) + gesture.translation.width
181+ columnWidth = min(max(newWidth, 40), 1000)
190+// MARK: - LogHeaderView
116struct LogHeaderView: View {
192struct LogHeaderView: View {
117 let columns: [String]
193 let columns: [String]
118 @Binding var sortColumn: String?
194 @Binding var sortColumn: String?
119 @Binding var sortAscending: Bool
195 @Binding var sortAscending: Bool
196+ @Binding var columnWidths: [String: CGFloat]
197+ let records: [LogRecord]
121 var body: some View {
199 var body: some View {
122 HStack(spacing: 0) {
200 HStack(spacing: 0) {
123 ForEach(columns, id: \.self) { column in
201 ForEach(columns, id: \.self) { column in
125− if sortColumn == column {
126− sortAscending.toggle()
129− sortAscending = true
132− HStack(spacing: 2) {
202+ HStack(spacing: 0) {
133− Text(column)
203+ Button {
134− .font(.caption.weight(.semibold))
136 if sortColumn == column {
204 if sortColumn == column {
137− Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
205+ sortAscending.toggle()
138− .font(.caption2)
206+ } else {
208+ sortAscending = true
211+ HStack(spacing: 2) {
213+ .font(.caption.weight(.semibold))
215+ if sortColumn == column {
216+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
221+ .buttonStyle(.plain)
222+ .padding(.horizontal, 6)
223+ .padding(.vertical, 4)
224+ .accessibilityLabel(sortColumn == column
225+ ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
226+ : "Sort by \(column)")
228+ Spacer(minLength: 0)
231+ columnWidth: Binding(
232+ get: { columnWidths[column] ?? 120 },
233+ set: { columnWidths[column] = $0 }
237+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
239+ Button("Auto-fit Column") {
240+ columnWidths[column] = idealColumnWidth(for: column, records: records)
242+ Button("Auto-fit All Columns") {
243+ columnWidths = computeColumnWidths(columns: columns, records: records)
142− .buttonStyle(.plain)
143− .frame(width: columnWidth(for: column), alignment: .leading)
144− .padding(.horizontal, 6)
145− .padding(.vertical, 4)
146− .accessibilityLabel(sortColumn == column
147− ? "Sort by \(column), \(sortAscending ? "ascending" : "descending")"
148− : "Sort by \(column)")
151 .background(Color(nsColor: .controlBackgroundColor))
248 .background(Color(nsColor: .controlBackgroundColor))
@@ -153,9 +250,12 @@ struct LogHeaderView: View {
253+// MARK: - LogRowView
156struct LogRowView: View {
255struct LogRowView: View {
157 let record: LogRecord
256 let record: LogRecord
158 let columns: [String]
257 let columns: [String]
258+ let columnWidths: [String: CGFloat]
159 let isSelected: Bool
259 let isSelected: Bool
160 let isAlternate: Bool
260 let isAlternate: Bool
@@ -166,7 +266,7 @@ struct LogRowView: View {
166 Text(value?.displayString ?? "")
266 Text(value?.displayString ?? "")
167 .font(.system(.caption, design: .monospaced))
267 .font(.system(.caption, design: .monospaced))
168 .lineLimit(1)
268 .lineLimit(1)
169− .frame(width: columnWidth(for: column), alignment: .leading)
269+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
170 .padding(.horizontal, 6)
270 .padding(.horizontal, 6)
171 .padding(.vertical, 3)
271 .padding(.vertical, 3)
172 .foregroundStyle(colorForValue(column: column, value: value))
272 .foregroundStyle(colorForValue(column: column, value: value))