git.iko.soy/parseable-macos
on master help sign in
Add column support to live tail view (#14)

Replace the summary-based list with a full column table that reuses LogHeaderView and LogRowView from the query results table. Columns are extracted from incoming records with priority ordering, support show/hide/reorder via a popover manager, and persist per-stream in UserDefaults. Sorting by any column is supported (auto-scroll is disabled while a custom sort is active). Includes unit tests for all column management operations. https://claude.ai/code/session_01E3ue7YkMckjXY6oaTK6GsZ Co-authored-by: Claude <noreply@anthropic.com>

commit4c45920c4bb77bbe65b9e503d1624af557c24f98 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 13:38 (8 months ago) parent08d5ed3
3 files changed+427−42
ParseableViewer/ViewModels/LiveTailViewModel.swift+132 ParseableViewer/Views/LiveTailView.swift+182−42 ParseableViewerTests/LiveTailViewModelTests.swift+113
ParseableViewer/ViewModels/LiveTailViewModel.swift+132
@@ -14,6 +14,12 @@ final class LiveTailViewModel {
1414 var droppedCount = 0
1515 private(set) var lastPollTime: Date?
1616
17+ // Column management
18+ var columns: [String] = []
19+ var columnOrder: [String] = []
20+ var hiddenColumns: Set<String> = []
21+ private var currentStream: String?
22+
1723 // nonisolated(unsafe) so deinit can invalidate the timer.
1824 // Only mutated from @MainActor methods (start/stop).
1925 nonisolated(unsafe) private var timer: Timer?
@@ -82,6 +88,10 @@ final class LiveTailViewModel {
8288 consecutiveErrors = 0
8389 lastPollTime = nil
8490 lastTimestamp = Date()
91+ columns = []
92+ columnOrder = []
93+ hiddenColumns = []
94+ currentStream = stream
8595
8696 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
8797 Task { @MainActor [weak self] in
@@ -106,6 +116,126 @@ final class LiveTailViewModel {
106116 entries = []
107117 seenFingerprints = []
108118 droppedCount = 0
119+ columns = []
120+ columnOrder = []
121+ hiddenColumns = []
122+ }
123+
124+ // MARK: - Column Management
125+
126+ var visibleColumns: [String] {
127+ columnOrder.filter { !hiddenColumns.contains($0) }
128+ }
129+
130+ func toggleColumnVisibility(_ column: String) {
131+ if hiddenColumns.contains(column) {
132+ hiddenColumns.remove(column)
133+ } else {
134+ let visibleCount = columnOrder.count - hiddenColumns.count
135+ if visibleCount > 1 {
136+ hiddenColumns.insert(column)
137+ }
138+ }
139+ saveColumnConfig()
140+ }
141+
142+ func showAllColumns() {
143+ hiddenColumns.removeAll()
144+ saveColumnConfig()
145+ }
146+
147+ func moveColumn(from source: IndexSet, to destination: Int) {
148+ columnOrder.move(fromOffsets: source, toOffset: destination)
149+ saveColumnConfig()
150+ }
151+
152+ func moveColumn(_ column: String, to targetColumn: String) {
153+ guard let fromIndex = columnOrder.firstIndex(of: column),
154+ let toIndex = columnOrder.firstIndex(of: targetColumn),
155+ fromIndex != toIndex else { return }
156+ let item = columnOrder.remove(at: fromIndex)
157+ columnOrder.insert(item, at: toIndex)
158+ saveColumnConfig()
159+ }
160+
161+ func resetColumnConfig() {
162+ columnOrder = columns
163+ hiddenColumns.removeAll()
164+ saveColumnConfig()
165+ }
166+
167+ // MARK: - Column Configuration Persistence
168+
169+ private static func columnConfigKey(for stream: String) -> String {
170+ "parseable_livetail_column_config_\(stream)"
171+ }
172+
173+ private func saveColumnConfig() {
174+ guard let stream = currentStream else { return }
175+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
176+ if let data = try? JSONEncoder().encode(config) {
177+ UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
178+ }
179+ }
180+
181+ private static func loadColumnConfig(for stream: String) -> QueryViewModel.ColumnConfiguration? {
182+ guard let data = UserDefaults.standard.data(forKey: columnConfigKey(for: stream)) else { return nil }
183+ return try? JSONDecoder().decode(QueryViewModel.ColumnConfiguration.self, from: data)
184+ }
185+
186+ // MARK: - Column Extraction
187+
188+ private func extractColumns(from records: [LogRecord]) -> [String] {
189+ let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
190+ var allKeys = Set<String>()
191+ for record in records {
192+ allKeys.formUnion(record.keys)
193+ }
194+
195+ var ordered: [String] = []
196+ for field in priorityFields {
197+ if allKeys.remove(field) != nil {
198+ ordered.append(field)
199+ }
200+ }
201+ ordered.append(contentsOf: allKeys.sorted())
202+ return ordered
203+ }
204+
205+ private func updateColumns(stream: String) {
206+ let allRecords = entries.map { $0.record }
207+ let extracted = extractColumns(from: allRecords)
208+
209+ if columns.isEmpty {
210+ // First time — apply saved config
211+ columns = extracted
212+ currentStream = stream
213+
214+ if let config = Self.loadColumnConfig(for: stream) {
215+ let extractedSet = Set(extracted)
216+ var merged = config.order.filter { extractedSet.contains($0) }
217+ let mergedSet = Set(merged)
218+ for col in extracted where !mergedSet.contains(col) {
219+ merged.append(col)
220+ }
221+ columnOrder = merged
222+ hiddenColumns = config.hidden.intersection(extractedSet)
223+ } else {
224+ columnOrder = extracted
225+ hiddenColumns = []
226+ }
227+ } else {
228+ // Merge: keep existing order, append any new columns
229+ let existingSet = Set(columnOrder)
230+ var newCols = [String]()
231+ for col in extracted where !existingSet.contains(col) {
232+ newCols.append(col)
233+ }
234+ if !newCols.isEmpty {
235+ columns.append(contentsOf: newCols)
236+ columnOrder.append(contentsOf: newCols)
237+ }
238+ }
109239 }
110240
111241 private func poll(client: ParseableClient, stream: String) async {
@@ -153,6 +283,8 @@ final class LiveTailViewModel {
153283 // and ensure dropped entries can be re-detected if they reappear.
154284 seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
155285 }
286+
287+ updateColumns(stream: stream)
156288 }
157289
158290 lastTimestamp = now
ParseableViewer/Views/LiveTailView.swift+182−42
@@ -3,9 +3,13 @@ import SwiftUI
33struct LiveTailView: View {
44 @Environment(AppState.self) private var appState
55 @State private var viewModel = LiveTailViewModel()
6− @State private var selectedEntry: LiveTailViewModel.LiveTailEntry?
6+ @State private var selectedRecord: LogRecord?
77 @State private var autoScroll = true
88 @State private var pulseOpacity: Double = 1.0
9+ @State private var showColumnPopover = false
10+ @State private var sortColumn: String?
11+ @State private var sortAscending = false
12+ @State private var columnWidths: [String: CGFloat] = [:]
913
1014 var body: some View {
1115 @Bindable var vm = viewModel
@@ -66,11 +70,36 @@ struct LiveTailView: View {
6670 .accessibilityLabel("Filter live tail entries")
6771 }
6872
73+ if !viewModel.columns.isEmpty {
74+ Button {
75+ showColumnPopover.toggle()
76+ } label: {
77+ HStack(spacing: 4) {
78+ Image(systemName: "eye")
79+ Text("Columns")
80+ .font(.caption)
81+ if !viewModel.hiddenColumns.isEmpty {
82+ Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
83+ .font(.caption)
84+ .foregroundStyle(.secondary)
85+ }
86+ }
87+ }
88+ .accessibilityLabel("Manage column visibility and order")
89+ .help("Show, hide, and reorder columns")
90+ .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
91+ LiveTailColumnManagerView(viewModel: viewModel)
92+ }
93+ }
94+
6995 Toggle("Auto-scroll", isOn: $autoScroll)
7096 .toggleStyle(.checkbox)
7197
7298 Button {
7399 viewModel.clear()
100+ selectedRecord = nil
101+ sortColumn = nil
102+ columnWidths = [:]
74103 } label: {
75104 Image(systemName: "trash")
76105 }
@@ -132,37 +161,32 @@ struct LiveTailView: View {
132161
133162 Divider()
134163
135− // Entries list
136− ScrollViewReader { proxy in
137− List(viewModel.filteredEntries, selection: Binding(
138− get: { selectedEntry?.id },
139− set: { id in
140− selectedEntry = viewModel.filteredEntries.first { $0.id == id }
141− }
142− )) { entry in
143− LiveTailEntryRow(entry: entry)
144− .id(entry.id)
145− .tag(entry.id)
146− }
147− .listStyle(.plain)
148− .font(.system(.caption, design: .monospaced))
149− .onChange(of: viewModel.entries.count) { _, _ in
150− guard autoScroll else { return }
151− // When a filter is active, scroll to the last filtered entry;
152− // when no filter, scroll to the absolute last entry.
153− let target = viewModel.filterText.isEmpty
154− ? viewModel.entries.last
155− : viewModel.filteredEntries.last
156− if let target {
157− proxy.scrollTo(target.id, anchor: .bottom)
164+ // Column table or empty state
165+ if viewModel.visibleColumns.isEmpty {
166+ VStack(spacing: 8) {
167+ if !viewModel.isRunning {
168+ Image(systemName: "antenna.radiowaves.left.and.right")
169+ .font(.system(size: 32))
170+ .foregroundStyle(.secondary)
171+ Text("Start live tail to stream log entries")
172+ .foregroundStyle(.secondary)
173+ } else {
174+ ProgressView()
175+ .controlSize(.small)
176+ Text("Waiting for entries...")
177+ .foregroundStyle(.secondary)
158178 }
159179 }
180+ .frame(maxWidth: .infinity, maxHeight: .infinity)
181+ } else {
182+ liveTailTable
160183 }
161184 }
185+ .frame(minWidth: 400)
162186
163187 // Detail pane
164− if let entry = selectedEntry {
165− LogDetailView(record: entry.record)
188+ if let record = selectedRecord {
189+ LogDetailView(record: record)
166190 .frame(minWidth: 280, idealWidth: 320)
167191 }
168192 }
@@ -174,30 +198,146 @@ struct LiveTailView: View {
174198 viewModel.stop()
175199 }
176200 viewModel.clear()
201+ selectedRecord = nil
202+ sortColumn = nil
203+ columnWidths = [:]
204+ }
205+ .onChange(of: viewModel.visibleColumns) { _, newColumns in
206+ // Compute widths for any newly visible columns
207+ let records = viewModel.filteredEntries.map { $0.record }
208+ for col in newColumns where columnWidths[col] == nil {
209+ columnWidths[col] = idealColumnWidth(for: col, records: records)
210+ }
211+ }
212+ }
213+
214+ private var sortedEntries: [LiveTailViewModel.LiveTailEntry] {
215+ let entries = viewModel.filteredEntries
216+ guard let sortColumn else { return entries }
217+ return entries.sorted { a, b in
218+ let aVal = a.record[sortColumn] ?? .null
219+ let bVal = b.record[sortColumn] ?? .null
220+ return sortAscending ? aVal < bVal : bVal < aVal
221+ }
222+ }
223+
224+ private var liveTailTable: some View {
225+ ScrollViewReader { proxy in
226+ ScrollView([.horizontal, .vertical]) {
227+ LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
228+ Section {
229+ let sorted = sortedEntries
230+ ForEach(sorted.indices, id: \.self) { index in
231+ LogRowView(
232+ record: sorted[index].record,
233+ columns: viewModel.visibleColumns,
234+ columnWidths: columnWidths,
235+ isSelected: selectedRecord == sorted[index].record,
236+ isAlternate: index % 2 == 1
237+ )
238+ .id(sorted[index].id)
239+ .onTapGesture {
240+ selectedRecord = sorted[index].record
241+ }
242+ }
243+ } header: {
244+ LogHeaderView(
245+ columns: viewModel.visibleColumns,
246+ sortColumn: $sortColumn,
247+ sortAscending: $sortAscending,
248+ columnWidths: $columnWidths,
249+ records: viewModel.filteredEntries.map { $0.record },
250+ onMoveColumn: { from, to in
251+ viewModel.moveColumn(from, to: to)
252+ }
253+ )
254+ }
255+ }
256+ }
257+ .onChange(of: viewModel.entries.count) { _, _ in
258+ guard autoScroll, sortColumn == nil else { return }
259+ // When a filter is active, scroll to the last filtered entry;
260+ // when no filter, scroll to the absolute last entry.
261+ let target = viewModel.filterText.isEmpty
262+ ? viewModel.entries.last
263+ : viewModel.filteredEntries.last
264+ if let target {
265+ proxy.scrollTo(target.id, anchor: .bottom)
266+ }
267+ }
177268 }
178269 }
179270}
180271
181−struct LiveTailEntryRow: View {
182− let entry: LiveTailViewModel.LiveTailEntry
272+// MARK: - Column Manager Popover
273+
274+struct LiveTailColumnManagerView: View {
275+ let viewModel: LiveTailViewModel
183276
184277 var body: some View {
185− HStack(spacing: 8) {
186− Text(entry.displayTimestamp)
187− .foregroundStyle(.secondary)
188− .frame(width: 90, alignment: .leading)
189−
190− if let level = entry.record["level"] ?? entry.record["severity"] {
191− Text(level.displayString)
192− .fontWeight(.medium)
193− .foregroundStyle(levelColor(for: level.displayString))
194− .frame(width: 50, alignment: .leading)
278+ VStack(alignment: .leading, spacing: 0) {
279+ HStack {
280+ Text("Columns")
281+ .font(.headline)
282+ Spacer()
283+ if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
284+ Button("Reset") {
285+ viewModel.resetColumnConfig()
286+ }
287+ .font(.caption)
288+ }
289+ }
290+ .padding(.horizontal, 12)
291+ .padding(.top, 10)
292+ .padding(.bottom, 6)
293+
294+ Divider()
295+
296+ List {
297+ ForEach(viewModel.columnOrder, id: \.self) { column in
298+ HStack {
299+ Button {
300+ viewModel.toggleColumnVisibility(column)
301+ } label: {
302+ Image(systemName: viewModel.hiddenColumns.contains(column)
303+ ? "eye.slash" : "eye")
304+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
305+ ? .secondary : .primary)
306+ .frame(width: 20)
307+ }
308+ .buttonStyle(.plain)
309+ .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
310+
311+ Text(column)
312+ .font(.system(.body, design: .monospaced))
313+ .lineLimit(1)
314+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
315+ ? .secondary : .primary)
316+
317+ Spacer()
318+
319+ Image(systemName: "line.3.horizontal")
320+ .foregroundStyle(.tertiary)
321+ .font(.caption)
322+ }
323+ .padding(.vertical, 2)
324+ }
325+ .onMove { source, destination in
326+ viewModel.moveColumn(from: source, to: destination)
327+ }
195328 }
329+ .listStyle(.plain)
196330
197− Text(entry.summary)
198− .lineLimit(1)
199− .frame(maxWidth: .infinity, alignment: .leading)
200− .help(entry.summary)
331+ if !viewModel.hiddenColumns.isEmpty {
332+ Divider()
333+ Button("Show All Columns") {
334+ viewModel.showAllColumns()
335+ }
336+ .font(.caption)
337+ .padding(.horizontal, 12)
338+ .padding(.vertical, 8)
339+ }
201340 }
341+ .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))
202342 }
203343}
ParseableViewerTests/LiveTailViewModelTests.swift+113
@@ -278,4 +278,117 @@ final class LiveTailViewModelTests: XCTestCase {
278278 XCTAssertTrue(summary.contains("[debug]"))
279279 XCTAssertTrue(summary.contains("Processing request"))
280280 }
281+
282+ // MARK: - Column Management
283+
284+ func testInitialColumnState() {
285+ let vm = LiveTailViewModel()
286+ XCTAssertTrue(vm.columns.isEmpty)
287+ XCTAssertTrue(vm.columnOrder.isEmpty)
288+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
289+ XCTAssertTrue(vm.visibleColumns.isEmpty)
290+ }
291+
292+ func testVisibleColumnsExcludesHidden() {
293+ let vm = LiveTailViewModel()
294+ vm.columnOrder = ["a", "b", "c"]
295+ vm.hiddenColumns = ["b"]
296+ XCTAssertEqual(vm.visibleColumns, ["a", "c"])
297+ }
298+
299+ func testToggleColumnVisibilityHide() {
300+ let vm = LiveTailViewModel()
301+ vm.columns = ["a", "b", "c"]
302+ vm.columnOrder = ["a", "b", "c"]
303+ vm.hiddenColumns = []
304+
305+ vm.toggleColumnVisibility("b")
306+ XCTAssertTrue(vm.hiddenColumns.contains("b"))
307+ XCTAssertEqual(vm.visibleColumns, ["a", "c"])
308+ }
309+
310+ func testToggleColumnVisibilityShow() {
311+ let vm = LiveTailViewModel()
312+ vm.columns = ["a", "b", "c"]
313+ vm.columnOrder = ["a", "b", "c"]
314+ vm.hiddenColumns = ["b"]
315+
316+ vm.toggleColumnVisibility("b")
317+ XCTAssertFalse(vm.hiddenColumns.contains("b"))
318+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
319+ }
320+
321+ func testCannotHideLastVisibleColumn() {
322+ let vm = LiveTailViewModel()
323+ vm.columns = ["a"]
324+ vm.columnOrder = ["a"]
325+ vm.hiddenColumns = []
326+
327+ vm.toggleColumnVisibility("a")
328+ // Should not hide the last visible column
329+ XCTAssertFalse(vm.hiddenColumns.contains("a"))
330+ XCTAssertEqual(vm.visibleColumns, ["a"])
331+ }
332+
333+ func testShowAllColumns() {
334+ let vm = LiveTailViewModel()
335+ vm.columns = ["a", "b", "c"]
336+ vm.columnOrder = ["a", "b", "c"]
337+ vm.hiddenColumns = ["a", "c"]
338+
339+ vm.showAllColumns()
340+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
341+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
342+ }
343+
344+ func testMoveColumnByName() {
345+ let vm = LiveTailViewModel()
346+ vm.columns = ["a", "b", "c"]
347+ vm.columnOrder = ["a", "b", "c"]
348+
349+ vm.moveColumn("c", to: "a")
350+ XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
351+ }
352+
353+ func testMoveColumnSamePositionIsNoOp() {
354+ let vm = LiveTailViewModel()
355+ vm.columns = ["a", "b", "c"]
356+ vm.columnOrder = ["a", "b", "c"]
357+
358+ vm.moveColumn("a", to: "a")
359+ XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
360+ }
361+
362+ func testResetColumnConfig() {
363+ let vm = LiveTailViewModel()
364+ vm.columns = ["a", "b", "c"]
365+ vm.columnOrder = ["c", "b", "a"]
366+ vm.hiddenColumns = ["b"]
367+
368+ vm.resetColumnConfig()
369+ XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
370+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
371+ }
372+
373+ func testClearResetsColumns() {
374+ let vm = LiveTailViewModel()
375+ vm.columns = ["a", "b"]
376+ vm.columnOrder = ["a", "b"]
377+ vm.hiddenColumns = ["b"]
378+
379+ vm.clear()
380+ XCTAssertTrue(vm.columns.isEmpty)
381+ XCTAssertTrue(vm.columnOrder.isEmpty)
382+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
383+ }
384+
385+ func testMoveColumnByIndexSet() {
386+ let vm = LiveTailViewModel()
387+ vm.columns = ["a", "b", "c"]
388+ vm.columnOrder = ["a", "b", "c"]
389+
390+ // Move "a" (index 0) to after "c" (index 3, past-the-end insertion)
391+ vm.moveColumn(from: IndexSet(integer: 0), to: 3)
392+ XCTAssertEqual(vm.columnOrder, ["b", "c", "a"])
393+ }
281394}