@@ -1,17 +1,39 @@
11import SwiftUI
22
3−/// Shared column width logic used by both header and row views.
4−func columnWidth(for column: String) -> CGFloat {
5− switch column {
6− case "p_timestamp", "timestamp", "@timestamp", "time":
7− return 200
8− case "level", "severity", "log_level":
9− return 80
10− case "message", "msg", "body", "log":
11− return 400
12− default:
13− return 160
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)
7+}
8+
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)
13+
14+/// Computes the ideal width for a single column by sampling record content.
15+func idealColumnWidth(for column: String, records: [LogRecord]) -> CGFloat {
16+ let padding: CGFloat = 28 // horizontal padding (6*2) + sort indicator + buffer
17+ let minWidth: CGFloat = 50
18+ let maxWidth: CGFloat = 600
19+ let sampleCount = min(records.count, 200)
20+
21+ var widest = measureTextWidth(column, font: headerMeasureFont)
22+ for i in 0..<sampleCount {
23+ let text = records[i][column]?.displayString ?? ""
24+ widest = max(widest, measureTextWidth(text, font: cellMeasureFont))
1425 }
26+
27+ return min(max(widest + padding, minWidth), maxWidth)
28+}
29+
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)
35+ }
36+ return widths
1537}
1638
1739/// Shared log-level color mapping.
@@ -25,6 +47,8 @@ func levelColor(for value: String) -> Color {
2547 }
2648}
2749
50+// MARK: - LogTableView
51+
2852struct LogTableView: View {
2953 let records: [LogRecord]
3054 let columns: [String]
@@ -34,6 +58,7 @@ struct LogTableView: View {
3458 @State private var selectedIndex: Int?
3559 @State private var cachedSorted: [LogRecord] = []
3660 @State private var sortTask: Task<Void, Never>?
61+ @State private var columnWidths: [String: CGFloat] = [:]
3762
3863 var body: some View {
3964 if records.isEmpty {
@@ -54,6 +79,7 @@ struct LogTableView: View {
5479 LogRowView(
5580 record: cachedSorted[index],
5681 columns: columns,
82+ columnWidths: columnWidths,
5783 isSelected: selectedIndex == index,
5884 isAlternate: index % 2 == 1
5985 )
@@ -66,7 +92,9 @@ struct LogTableView: View {
6692 LogHeaderView(
6793 columns: columns,
6894 sortColumn: $sortColumn,
69− sortAscending: $sortAscending
95+ sortAscending: $sortAscending,
96+ columnWidths: $columnWidths,
97+ records: records
7098 )
7199 }
72100 }
@@ -78,10 +106,16 @@ struct LogTableView: View {
78106 .frame(minWidth: 300, idealWidth: 350)
79107 }
80108 }
81− .onChange(of: records) { _, _ in debouncedRebuildSort() }
109+ .onChange(of: records) { _, _ in
110+ columnWidths = computeColumnWidths(columns: columns, records: records)
111+ debouncedRebuildSort()
112+ }
82113 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
83114 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
84− .onAppear { rebuildSort() }
115+ .onAppear {
116+ columnWidths = computeColumnWidths(columns: columns, records: records)
117+ rebuildSort()
118+ }
85119 }
86120 }
87121
@@ -113,39 +147,102 @@ struct LogTableView: View {
113147 }
114148}
115149
150+// MARK: - ColumnResizeHandle
151+
152+struct ColumnResizeHandle: View {
153+ @Binding var columnWidth: CGFloat
154+ @State private var isHovering = false
155+ @State private var initialWidth: CGFloat?
156+
157+ var body: some View {
158+ Color.clear
159+ .frame(width: 6)
160+ .overlay(
161+ Rectangle()
162+ .fill(Color.secondary.opacity(isHovering ? 0.5 : 0.2))
163+ .frame(width: 1)
164+ )
165+ .contentShape(Rectangle())
166+ .onHover { hovering in
167+ isHovering = hovering
168+ if hovering {
169+ NSCursor.resizeLeftRight.push()
170+ } else {
171+ NSCursor.pop()
172+ }
173+ }
174+ .gesture(
175+ DragGesture(minimumDistance: 1)
176+ .onChanged { gesture in
177+ if initialWidth == nil {
178+ initialWidth = columnWidth
179+ }
180+ let newWidth = (initialWidth ?? columnWidth) + gesture.translation.width
181+ columnWidth = min(max(newWidth, 40), 1000)
182+ }
183+ .onEnded { _ in
184+ initialWidth = nil
185+ }
186+ )
187+ }
188+}
189+
190+// MARK: - LogHeaderView
191+
116192struct LogHeaderView: View {
117193 let columns: [String]
118194 @Binding var sortColumn: String?
119195 @Binding var sortAscending: Bool
196+ @Binding var columnWidths: [String: CGFloat]
197+ let records: [LogRecord]
120198
121199 var body: some View {
122200 HStack(spacing: 0) {
123201 ForEach(columns, id: \.self) { column in
124− Button {
125− if sortColumn == column {
126− sortAscending.toggle()
127− } else {
128− sortColumn = column
129− sortAscending = true
130− }
131− } label: {
132− HStack(spacing: 2) {
133− Text(column)
134− .font(.caption.weight(.semibold))
135− .lineLimit(1)
202+ HStack(spacing: 0) {
203+ Button {
136204 if sortColumn == column {
137− Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
138− .font(.caption2)
205+ sortAscending.toggle()
206+ } else {
207+ sortColumn = column
208+ sortAscending = true
139209 }
210+ } label: {
211+ HStack(spacing: 2) {
212+ Text(column)
213+ .font(.caption.weight(.semibold))
214+ .lineLimit(1)
215+ if sortColumn == column {
216+ Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
217+ .font(.caption2)
218+ }
219+ }
220+ }
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)")
227+
228+ Spacer(minLength: 0)
229+
230+ ColumnResizeHandle(
231+ columnWidth: Binding(
232+ get: { columnWidths[column] ?? 120 },
233+ set: { columnWidths[column] = $0 }
234+ )
235+ )
236+ }
237+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
238+ .contextMenu {
239+ Button("Auto-fit Column") {
240+ columnWidths[column] = idealColumnWidth(for: column, records: records)
241+ }
242+ Button("Auto-fit All Columns") {
243+ columnWidths = computeColumnWidths(columns: columns, records: records)
140244 }
141245 }
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)")
149246 }
150247 }
151248 .background(Color(nsColor: .controlBackgroundColor))
@@ -153,9 +250,12 @@ struct LogHeaderView: View {
153250 }
154251}
155252
253+// MARK: - LogRowView
254+
156255struct LogRowView: View {
157256 let record: LogRecord
158257 let columns: [String]
258+ let columnWidths: [String: CGFloat]
159259 let isSelected: Bool
160260 let isAlternate: Bool
161261
@@ -166,7 +266,7 @@ struct LogRowView: View {
166266 Text(value?.displayString ?? "")
167267 .font(.system(.caption, design: .monospaced))
168268 .lineLimit(1)
169− .frame(width: columnWidth(for: column), alignment: .leading)
269+ .frame(width: columnWidths[column] ?? 120, alignment: .leading)
170270 .padding(.horizontal, 6)
171271 .padding(.vertical, 3)
172272 .foregroundStyle(colorForValue(column: column, value: value))