git.iko.soy/parseable-macos
on master help sign in
Auto-hide empty columns in query results (#17)

* Auto-hide columns that have no values in any row After each query execution, columns where every record has a null, missing, or empty-string value are automatically hidden. These auto-hidden columns are tracked separately from user-hidden columns so they don't pollute persisted column configuration. The column manager shows an "empty" label on auto-hidden columns, and users can still manually unhide them via the eye toggle or "Show All". https://claude.ai/code/session_01VekovnYSb8p7MSLTFNRxvg * Fix stale auto-hidden columns persisting across queries Clear autoHiddenColumns before recomputing so that stale state from a previous query doesn't inflate the hidden count and prevent the new auto-hide from being applied. This ensures columns with data always become visible again on the next query. https://claude.ai/code/session_01VekovnYSb8p7MSLTFNRxvg --------- Co-authored-by: Claude <noreply@anthropic.com>

commitdb9e984b0831da28a9af6c43c23d6472faa1a9fa authoriko <mail@iko.soy> dateSun 15 Feb 2026, 14:13 (8 months ago) parenta99b1f3
3 files changed+193−12
ParseableViewer/ViewModels/QueryViewModel.swift+37−2 ParseableViewer/Views/QueryView.swift+16−10 ParseableViewerTests/QueryViewModelTests.swift+140
ParseableViewer/ViewModels/QueryViewModel.swift+37−2
@@ -10,6 +10,7 @@ final class QueryViewModel {
1010 var columns: [String] = []
1111 var columnOrder: [String] = []
1212 var hiddenColumns: Set<String> = []
13+ var autoHiddenColumns: Set<String> = []
1314 private var currentStream: String?
1415 var isLoading = false
1516 var errorMessage: String?
@@ -115,15 +116,19 @@ final class QueryViewModel {
115116 }
116117
117118 var visibleColumns: [String] {
118− columnOrder.filter { !hiddenColumns.contains($0) }
119+ let allHidden = hiddenColumns.union(autoHiddenColumns)
120+ return columnOrder.filter { !allHidden.contains($0) }
119121 }
120122
121123 func toggleColumnVisibility(_ column: String) {
122124 if hiddenColumns.contains(column) {
123125 hiddenColumns.remove(column)
126+ } else if autoHiddenColumns.contains(column) {
127+ autoHiddenColumns.remove(column)
124128 } else {
125129 // Don't allow hiding all columns
126− let visibleCount = columnOrder.count - hiddenColumns.count
130+ let allHidden = hiddenColumns.union(autoHiddenColumns)
131+ let visibleCount = columnOrder.count - allHidden.count
127132 if visibleCount > 1 {
128133 hiddenColumns.insert(column)
129134 }
@@ -133,6 +138,7 @@ final class QueryViewModel {
133138
134139 func showAllColumns() {
135140 hiddenColumns.removeAll()
141+ autoHiddenColumns.removeAll()
136142 saveColumnConfig()
137143 }
138144
@@ -153,6 +159,7 @@ final class QueryViewModel {
153159 func resetColumnConfig() {
154160 columnOrder = columns
155161 hiddenColumns.removeAll()
162+ autoHiddenColumns.removeAll()
156163 saveColumnConfig()
157164 }
158165
@@ -280,6 +287,18 @@ final class QueryViewModel {
280287 let extracted = extractColumns(from: results)
281288 applyColumnConfig(extractedColumns: extracted, stream: stream)
282289
290+ // Auto-hide columns that have no values in any row.
291+ // Clear first so stale state from a previous query doesn't
292+ // interfere with the visible-count check.
293+ autoHiddenColumns = []
294+ let empty = emptyColumns(in: results, columns: columnOrder)
295+ let candidates = empty.subtracting(hiddenColumns)
296+ let visibleCount = columnOrder.count - hiddenColumns.count
297+ // Ensure at least one column remains visible
298+ if candidates.count < visibleCount {
299+ autoHiddenColumns = candidates
300+ }
301+
283302 // Record in history
284303 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
285304 addToHistory(entry)
@@ -340,6 +359,21 @@ final class QueryViewModel {
340359 return ordered
341360 }
342361
362+ /// Returns the set of columns that have no meaningful value in any record.
363+ /// A value is considered empty if it is missing (nil), `.null`, or `.string("")`.
364+ func emptyColumns(in records: [LogRecord], columns: [String]) -> Set<String> {
365+ var empty = Set(columns)
366+ for record in records {
367+ if empty.isEmpty { break }
368+ for column in empty {
369+ if let value = record[column], value != .null, value != .string("") {
370+ empty.remove(column)
371+ }
372+ }
373+ }
374+ return empty
375+ }
376+
343377 func exportAsJSON() -> String {
344378 guard !results.isEmpty else { return "[]" }
345379 let encoder = JSONEncoder()
@@ -390,6 +424,7 @@ final class QueryViewModel {
390424 columns = []
391425 columnOrder = []
392426 hiddenColumns = []
427+ autoHiddenColumns = []
393428 currentStream = nil
394429 resultCount = 0
395430 queryDuration = nil
ParseableViewer/Views/QueryView.swift+16−10
@@ -192,7 +192,7 @@ struct QueryView: View {
192192 Image(systemName: "eye")
193193 Text("Columns")
194194 .font(.caption)
195− if !viewModel.hiddenColumns.isEmpty {
195+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
196196 Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
197197 .font(.caption)
198198 .foregroundStyle(.secondary)
@@ -368,7 +368,7 @@ struct ColumnManagerView: View {
368368 Text("Columns")
369369 .font(.headline)
370370 Spacer()
371− if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
371+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
372372 Button("Reset") {
373373 viewModel.resetColumnConfig()
374374 }
@@ -383,27 +383,33 @@ struct ColumnManagerView: View {
383383
384384 List {
385385 ForEach(viewModel.columnOrder, id: \.self) { column in
386+ let isHidden = viewModel.hiddenColumns.contains(column)
387+ || viewModel.autoHiddenColumns.contains(column)
386388 HStack {
387389 Button {
388390 viewModel.toggleColumnVisibility(column)
389391 } label: {
390− Image(systemName: viewModel.hiddenColumns.contains(column)
391− ? "eye.slash" : "eye")
392− .foregroundStyle(viewModel.hiddenColumns.contains(column)
393− ? .secondary : .primary)
392+ Image(systemName: isHidden ? "eye.slash" : "eye")
393+ .foregroundStyle(isHidden ? .secondary : .primary)
394394 .frame(width: 20)
395395 }
396396 .buttonStyle(.plain)
397− .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
397+ .help(isHidden ? "Show column" : "Hide column")
398398
399399 Text(column)
400400 .font(.system(.body, design: .monospaced))
401401 .lineLimit(1)
402− .foregroundStyle(viewModel.hiddenColumns.contains(column)
403− ? .secondary : .primary)
402+ .foregroundStyle(isHidden ? .secondary : .primary)
404403
405404 Spacer()
406405
406+ if viewModel.autoHiddenColumns.contains(column) {
407+ Text("empty")
408+ .font(.caption2)
409+ .foregroundStyle(.tertiary)
410+ .padding(.trailing, 4)
411+ }
412+
407413 Image(systemName: "line.3.horizontal")
408414 .foregroundStyle(.tertiary)
409415 .font(.caption)
@@ -416,7 +422,7 @@ struct ColumnManagerView: View {
416422 }
417423 .listStyle(.plain)
418424
419− if !viewModel.hiddenColumns.isEmpty {
425+ if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
420426 Divider()
421427 Button("Show All Columns") {
422428 viewModel.showAllColumns()
ParseableViewerTests/QueryViewModelTests.swift+140
@@ -428,5 +428,145 @@ final class QueryViewModelTests: XCTestCase {
428428 XCTAssertFalse(csv.contains("secret"))
429429 XCTAssertFalse(csv.contains("hidden"))
430430 }
431+
432+ // MARK: - Empty column detection
433+
434+ func testEmptyColumnsDetectsAllNulls() {
435+ let vm = QueryViewModel()
436+ let records: [LogRecord] = [
437+ ["a": .string("val"), "b": .null],
438+ ["a": .string("val2"), "b": .null],
439+ ]
440+ let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
441+ XCTAssertEqual(empty, ["b"])
442+ }
443+
444+ func testEmptyColumnsDetectsMissingKeys() {
445+ let vm = QueryViewModel()
446+ let records: [LogRecord] = [
447+ ["a": .string("val")],
448+ ["a": .string("val2")],
449+ ]
450+ let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
451+ XCTAssertEqual(empty, ["b"])
452+ }
453+
454+ func testEmptyColumnsDetectsEmptyStrings() {
455+ let vm = QueryViewModel()
456+ let records: [LogRecord] = [
457+ ["a": .string("val"), "b": .string("")],
458+ ["a": .string("val2"), "b": .string("")],
459+ ]
460+ let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
461+ XCTAssertEqual(empty, ["b"])
462+ }
463+
464+ func testEmptyColumnsMixedNullAndMissing() {
465+ let vm = QueryViewModel()
466+ let records: [LogRecord] = [
467+ ["a": .string("val"), "b": .null],
468+ ["a": .string("val2")],
469+ ["a": .string("val3"), "b": .string("")],
470+ ]
471+ let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
472+ XCTAssertEqual(empty, ["b"])
473+ }
474+
475+ func testEmptyColumnsNotEmptyIfAnyRecordHasValue() {
476+ let vm = QueryViewModel()
477+ let records: [LogRecord] = [
478+ ["a": .string("val"), "b": .null],
479+ ["a": .string("val2"), "b": .string("data")],
480+ ]
481+ let empty = vm.emptyColumns(in: records, columns: ["a", "b"])
482+ XCTAssertTrue(empty.isEmpty)
483+ }
484+
485+ func testEmptyColumnsNoRecords() {
486+ let vm = QueryViewModel()
487+ let empty = vm.emptyColumns(in: [], columns: ["a", "b"])
488+ XCTAssertEqual(empty, ["a", "b"])
489+ }
490+
491+ func testEmptyColumnsNoColumns() {
492+ let vm = QueryViewModel()
493+ let records: [LogRecord] = [["a": .string("val")]]
494+ let empty = vm.emptyColumns(in: records, columns: [])
495+ XCTAssertTrue(empty.isEmpty)
496+ }
497+
498+ func testAutoHiddenColumnsExcludedFromVisibleColumns() {
499+ let vm = QueryViewModel()
500+ vm.columns = ["a", "b", "c"]
501+ vm.columnOrder = ["a", "b", "c"]
502+ vm.autoHiddenColumns = ["b"]
503+ XCTAssertEqual(vm.visibleColumns, ["a", "c"])
504+ }
505+
506+ func testToggleAutoHiddenColumnShowsIt() {
507+ let vm = QueryViewModel()
508+ vm.columns = ["a", "b", "c"]
509+ vm.columnOrder = ["a", "b", "c"]
510+ vm.autoHiddenColumns = ["b"]
511+ vm.toggleColumnVisibility("b")
512+ XCTAssertFalse(vm.autoHiddenColumns.contains("b"))
513+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
514+ }
515+
516+ func testShowAllColumnsClearsAutoHidden() {
517+ let vm = QueryViewModel()
518+ vm.columns = ["a", "b", "c"]
519+ vm.columnOrder = ["a", "b", "c"]
520+ vm.hiddenColumns = ["a"]
521+ vm.autoHiddenColumns = ["b"]
522+ vm.showAllColumns()
523+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
524+ XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
525+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
526+ }
527+
528+ func testResetColumnConfigClearsAutoHidden() {
529+ let vm = QueryViewModel()
530+ vm.columns = ["a", "b", "c"]
531+ vm.columnOrder = ["c", "b", "a"]
532+ vm.autoHiddenColumns = ["b"]
533+ vm.resetColumnConfig()
534+ XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
535+ XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
536+ }
537+
538+ func testClearResultsClearsAutoHidden() {
539+ let vm = QueryViewModel()
540+ vm.columns = ["a", "b"]
541+ vm.columnOrder = ["a", "b"]
542+ vm.autoHiddenColumns = ["b"]
543+ vm.clearResults()
544+ XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
545+ }
546+
547+ func testCSVExportExcludesAutoHiddenColumns() {
548+ let vm = QueryViewModel()
549+ vm.results = [
550+ ["name": .string("Alice"), "age": .int(30), "empty_col": .null],
551+ ]
552+ vm.columns = ["name", "age", "empty_col"]
553+ vm.columnOrder = ["name", "age", "empty_col"]
554+ vm.autoHiddenColumns = ["empty_col"]
555+ let csv = vm.exportAsCSV()
556+
557+ let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
558+ XCTAssertEqual(lines[0], "name,age")
559+ XCTAssertFalse(csv.contains("empty_col"))
560+ }
561+
562+ func testBoolZeroValuesNotTreatedAsEmpty() {
563+ let vm = QueryViewModel()
564+ let records: [LogRecord] = [
565+ ["a": .bool(false)],
566+ ["a": .int(0)],
567+ ]
568+ let empty = vm.emptyColumns(in: records, columns: ["a"])
569+ XCTAssertTrue(empty.isEmpty)
570+ }
431571}
432572