git.iko.soy/parseable-macos
on master help sign in
Add column visibility, reordering, and per-stream persistence (#12)
d7cb626iko
ParseableViewer/ViewModels/QueryViewModel.swift+99−3
@@ -8,6 +8,9 @@ final class QueryViewModel {
8 didSet { updateFilteredResults() }
8 didSet { updateFilteredResults() }
9 }
9 }
10 var columns: [String] = []
10 var columns: [String] = []
11+ var columnOrder: [String] = []
12+ var hiddenColumns: Set<String> = []
13+ private var currentStream: String?
11 var isLoading = false
14 var isLoading = false
12 var errorMessage: String?
15 var errorMessage: String?
13 var resultCount = 0
16 var resultCount = 0
@@ -108,6 +111,94 @@ final class QueryViewModel {
108 return timeRangeOption.dateRange().end
111 return timeRangeOption.dateRange().end
109 }
112 }
110
113
114+ var visibleColumns: [String] {
115+ columnOrder.filter { !hiddenColumns.contains($0) }
116+ }
117+
118+ func toggleColumnVisibility(_ column: String) {
119+ if hiddenColumns.contains(column) {
120+ hiddenColumns.remove(column)
121+ } else {
122+ // Don't allow hiding all columns
123+ let visibleCount = columnOrder.count - hiddenColumns.count
124+ if visibleCount > 1 {
125+ hiddenColumns.insert(column)
126+ }
127+ }
128+ saveColumnConfig()
129+ }
130+
131+ func showAllColumns() {
132+ hiddenColumns.removeAll()
133+ saveColumnConfig()
134+ }
135+
136+ func moveColumn(from source: IndexSet, to destination: Int) {
137+ columnOrder.move(fromOffsets: source, toOffset: destination)
138+ saveColumnConfig()
139+ }
140+
141+ func moveColumn(_ column: String, to targetColumn: String) {
142+ guard let fromIndex = columnOrder.firstIndex(of: column),
143+ let toIndex = columnOrder.firstIndex(of: targetColumn),
144+ fromIndex != toIndex else { return }
145+ let item = columnOrder.remove(at: fromIndex)
146+ columnOrder.insert(item, at: toIndex)
147+ saveColumnConfig()
148+ }
149+
150+ func resetColumnConfig() {
151+ columnOrder = columns
152+ hiddenColumns.removeAll()
153+ saveColumnConfig()
154+ }
155+
156+ // MARK: - Column Configuration Persistence
157+
158+ struct ColumnConfiguration: Codable {
159+ var order: [String]
160+ var hidden: Set<String>
161+ }
162+
163+ private static func columnConfigKey(for stream: String) -> String {
164+ "parseable_column_config_\(stream)"
165+ }
166+
167+ private func saveColumnConfig() {
168+ guard let stream = currentStream else { return }
169+ let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
170+ if let data = try? JSONEncoder().encode(config) {
171+ UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
172+ }
173+ }
174+
175+ static func loadColumnConfig(for stream: String) -> ColumnConfiguration? {
176+ guard let data = UserDefaults.standard.data(forKey: columnConfigKey(for: stream)) else { return nil }
177+ return try? JSONDecoder().decode(ColumnConfiguration.self, from: data)
178+ }
179+
180+ func applyColumnConfig(extractedColumns: [String], stream: String?) {
181+ columns = extractedColumns
182+ currentStream = stream
183+
184+ guard let stream, let config = Self.loadColumnConfig(for: stream) else {
185+ columnOrder = extractedColumns
186+ hiddenColumns = []
187+ return
188+ }
189+
190+ // Merge saved order with actual columns: keep saved order for columns
191+ // that still exist, then append any new columns at the end
192+ let extractedSet = Set(extractedColumns)
193+ var merged = config.order.filter { extractedSet.contains($0) }
194+ let mergedSet = Set(merged)
195+ for col in extractedColumns where !mergedSet.contains(col) {
196+ merged.append(col)
197+ }
198+ columnOrder = merged
199+ hiddenColumns = config.hidden.intersection(extractedSet)
200+ }
201+
111 private(set) var filteredResults: [LogRecord] = []
202 private(set) var filteredResults: [LogRecord] = []
112
203
113 private func updateFilteredResults() {
204 private func updateFilteredResults() {
@@ -181,8 +272,10 @@ final class QueryViewModel {
181 // for auto-generated queries where we control the LIMIT)
272 // for auto-generated queries where we control the LIMIT)
182 resultsTruncated = usedAutoLimit && results.count == limit
273 resultsTruncated = usedAutoLimit && results.count == limit
183
274
184− // Extract columns in a single pass, with priority fields first
275+ // Extract columns in a single pass, with priority fields first,
276+ // then apply any saved column configuration for this stream
185− columns = extractColumns(from: results)
277+ let extracted = extractColumns(from: results)
278+ applyColumnConfig(extractedColumns: extracted, stream: stream)
186
279
187 // Record in history
280 // Record in history
188 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
281 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
@@ -241,7 +334,7 @@ final class QueryViewModel {
241 }
334 }
242
335
243 func exportAsCSV() -> String {
336 func exportAsCSV() -> String {
244− Self.buildCSV(records: results, columns: columns)
337+ Self.buildCSV(records: results, columns: visibleColumns)
245 }
338 }
246
339
247 /// Builds a CSV string from records and columns. Safe to call from any thread.
340 /// Builds a CSV string from records and columns. Safe to call from any thread.
@@ -277,6 +370,9 @@ final class QueryViewModel {
277 func clearResults() {
370 func clearResults() {
278 results = []
371 results = []
279 columns = []
372 columns = []
373+ columnOrder = []
374+ hiddenColumns = []
375+ currentStream = nil
280 resultCount = 0
376 resultCount = 0
281 queryDuration = nil
377 queryDuration = nil
282 selectedLogEntry = nil
378 selectedLogEntry = nil
ParseableViewer/Views/LogTableView.swift+19−1
@@ -54,6 +54,7 @@ struct LogTableView: View {
54 let columns: [String]
54 let columns: [String]
55 @Binding var selectedRecord: LogRecord?
55 @Binding var selectedRecord: LogRecord?
56 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
56 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
57+ var onMoveColumn: ((String, String) -> Void)?
57 @State private var sortColumn: String?
58 @State private var sortColumn: String?
58 @State private var sortAscending = false
59 @State private var sortAscending = false
59 @State private var selectedIndex: Int?
60 @State private var selectedIndex: Int?
@@ -96,7 +97,8 @@ struct LogTableView: View {
96 sortColumn: $sortColumn,
97 sortColumn: $sortColumn,
97 sortAscending: $sortAscending,
98 sortAscending: $sortAscending,
98 columnWidths: $columnWidths,
99 columnWidths: $columnWidths,
99− records: records
100+ records: records,
101+ onMoveColumn: onMoveColumn
100 )
102 )
101 }
103 }
102 }
104 }
@@ -197,6 +199,7 @@ struct LogHeaderView: View {
197 @Binding var sortAscending: Bool
199 @Binding var sortAscending: Bool
198 @Binding var columnWidths: [String: CGFloat]
200 @Binding var columnWidths: [String: CGFloat]
199 let records: [LogRecord]
201 let records: [LogRecord]
202+ var onMoveColumn: ((String, String) -> Void)?
200
203
201 var body: some View {
204 var body: some View {
202 HStack(spacing: 0) {
205 HStack(spacing: 0) {
@@ -238,6 +241,21 @@ struct LogHeaderView: View {
238 }
241 }
239 .frame(width: columnWidths[column] ?? 120, alignment: .leading)
242 .frame(width: columnWidths[column] ?? 120, alignment: .leading)
240 .contextMenu {
243 .contextMenu {
244+ if let onMove = onMoveColumn {
245+ if column != columns.first {
246+ Button("Move Left") {
247+ let idx = columns.firstIndex(of: column)!
248+ onMove(column, columns[columns.index(before: idx)])
249+ }
250+ }
251+ if column != columns.last {
252+ Button("Move Right") {
253+ let idx = columns.firstIndex(of: column)!
254+ onMove(column, columns[columns.index(after: idx)])
255+ }
256+ }
257+ Divider()
258+ }
241 Button("Auto-fit Column") {
259 Button("Auto-fit Column") {
242 columnWidths[column] = idealColumnWidth(for: column, records: records)
260 columnWidths[column] = idealColumnWidth(for: column, records: records)
243 }
261 }
ParseableViewer/Views/QueryView.swift+101−2
@@ -7,6 +7,7 @@ struct QueryView: View {
7 @State private var saveQueryName = ""
7 @State private var saveQueryName = ""
8 @State private var exportError: String?
8 @State private var exportError: String?
9 @State private var showExportError = false
9 @State private var showExportError = false
10+ @State private var showColumnPopover = false
10
11
11 var body: some View {
12 var body: some View {
12 @Bindable var vm = viewModel
13 @Bindable var vm = viewModel
@@ -178,6 +179,28 @@ struct QueryView: View {
178 }
179 }
179
180
180 Spacer()
181 Spacer()
182+
183+ if !viewModel.columns.isEmpty {
184+ Button {
185+ showColumnPopover.toggle()
186+ } label: {
187+ HStack(spacing: 4) {
188+ Image(systemName: "eye")
189+ Text("Columns")
190+ .font(.caption)
191+ if !viewModel.hiddenColumns.isEmpty {
192+ Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
193+ .font(.caption)
194+ .foregroundStyle(.secondary)
195+ }
196+ }
197+ }
198+ .accessibilityLabel("Manage column visibility and order")
199+ .help("Show, hide, and reorder columns")
200+ .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
201+ ColumnManagerView(viewModel: viewModel)
202+ }
203+ }
181 }
204 }
182 .padding(6)
205 .padding(6)
183 }
206 }
@@ -185,7 +208,7 @@ struct QueryView: View {
185 // Results table
208 // Results table
186 LogTableView(
209 LogTableView(
187 records: viewModel.filteredResults,
210 records: viewModel.filteredResults,
188− columns: viewModel.columns,
211+ columns: viewModel.visibleColumns,
189 selectedRecord: $vm.selectedLogEntry,
212 selectedRecord: $vm.selectedLogEntry,
190 onCellFilter: { column, value, exclude in
213 onCellFilter: { column, value, exclude in
191 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
214 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
@@ -195,6 +218,9 @@ struct QueryView: View {
195 stream: appState.selectedStream
218 stream: appState.selectedStream
196 )
219 )
197 }
220 }
221+ },
222+ onMoveColumn: { from, to in
223+ viewModel.moveColumn(from, to: to)
198 }
224 }
199 )
225 )
200 }
226 }
@@ -286,7 +312,7 @@ struct QueryView: View {
286
312
287 private func exportCSV() {
313 private func exportCSV() {
288 let results = viewModel.results
314 let results = viewModel.results
289− let columns = viewModel.columns
315+ let columns = viewModel.visibleColumns
290 saveToFile(type: "csv") {
316 saveToFile(type: "csv") {
291 QueryViewModel.buildCSV(records: results, columns: columns)
317 QueryViewModel.buildCSV(records: results, columns: columns)
292 }
318 }
@@ -318,3 +344,76 @@ struct QueryView: View {
318 }
344 }
319 }
345 }
320}
346}
347+
348+// MARK: - Column Manager Popover
349+
350+struct ColumnManagerView: View {
351+ let viewModel: QueryViewModel
352+
353+ var body: some View {
354+ VStack(alignment: .leading, spacing: 0) {
355+ HStack {
356+ Text("Columns")
357+ .font(.headline)
358+ Spacer()
359+ if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
360+ Button("Reset") {
361+ viewModel.resetColumnConfig()
362+ }
363+ .font(.caption)
364+ }
365+ }
366+ .padding(.horizontal, 12)
367+ .padding(.top, 10)
368+ .padding(.bottom, 6)
369+
370+ Divider()
371+
372+ List {
373+ ForEach(viewModel.columnOrder, id: \.self) { column in
374+ HStack {
375+ Button {
376+ viewModel.toggleColumnVisibility(column)
377+ } label: {
378+ Image(systemName: viewModel.hiddenColumns.contains(column)
379+ ? "eye.slash" : "eye")
380+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
381+ ? .secondary : .primary)
382+ .frame(width: 20)
383+ }
384+ .buttonStyle(.plain)
385+ .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
386+
387+ Text(column)
388+ .font(.system(.body, design: .monospaced))
389+ .lineLimit(1)
390+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
391+ ? .secondary : .primary)
392+
393+ Spacer()
394+
395+ Image(systemName: "line.3.horizontal")
396+ .foregroundStyle(.tertiary)
397+ .font(.caption)
398+ }
399+ .padding(.vertical, 2)
400+ }
401+ .onMove { source, destination in
402+ viewModel.moveColumn(from: source, to: destination)
403+ }
404+ }
405+ .listStyle(.plain)
406+
407+ if !viewModel.hiddenColumns.isEmpty {
408+ Divider()
409+ Button("Show All Columns") {
410+ viewModel.showAllColumns()
411+ }
412+ .font(.caption)
413+ .padding(.horizontal, 12)
414+ .padding(.vertical, 8)
415+ }
416+ }
417+ .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))
418+ }
419+}
ParseableViewerTests/QueryViewModelTests.swift+159
@@ -91,6 +91,7 @@ final class QueryViewModelTests: XCTestCase {
91 ["name": .string("Bob"), "age": .int(25)]
91 ["name": .string("Bob"), "age": .int(25)]
92 ]
92 ]
93 vm.columns = ["name", "age"]
93 vm.columns = ["name", "age"]
94+ vm.columnOrder = ["name", "age"]
94 let csv = vm.exportAsCSV()
95 let csv = vm.exportAsCSV()
95
96
96 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
97 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
@@ -106,6 +107,7 @@ final class QueryViewModelTests: XCTestCase {
106 ["msg": .string("hello, world"), "note": .string("has \"quotes\"")]
107 ["msg": .string("hello, world"), "note": .string("has \"quotes\"")]
107 ]
108 ]
108 vm.columns = ["msg", "note"]
109 vm.columns = ["msg", "note"]
110+ vm.columnOrder = ["msg", "note"]
109 let csv = vm.exportAsCSV()
111 let csv = vm.exportAsCSV()
110
112
111 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
113 let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
@@ -269,5 +271,162 @@ final class QueryViewModelTests: XCTestCase {
269 vm.addColumnFilter(column: "msg", value: .string("it's a test"), exclude: false)
271 vm.addColumnFilter(column: "msg", value: .string("it's a test"), exclude: false)
270 XCTAssertTrue(vm.sqlQuery.contains("'it''s a test'"))
272 XCTAssertTrue(vm.sqlQuery.contains("'it''s a test'"))
271 }
273 }
274+
275+ // MARK: - Column visibility
276+
277+ func testVisibleColumnsExcludesHidden() {
278+ let vm = QueryViewModel()
279+ vm.columns = ["a", "b", "c"]
280+ vm.columnOrder = ["a", "b", "c"]
281+ vm.hiddenColumns = ["b"]
282+ XCTAssertEqual(vm.visibleColumns, ["a", "c"])
283+ }
284+
285+ func testToggleColumnVisibilityHidesColumn() {
286+ let vm = QueryViewModel()
287+ vm.columns = ["a", "b", "c"]
288+ vm.columnOrder = ["a", "b", "c"]
289+ vm.toggleColumnVisibility("b")
290+ XCTAssertTrue(vm.hiddenColumns.contains("b"))
291+ XCTAssertEqual(vm.visibleColumns, ["a", "c"])
292+ }
293+
294+ func testToggleColumnVisibilityShowsColumn() {
295+ let vm = QueryViewModel()
296+ vm.columns = ["a", "b", "c"]
297+ vm.columnOrder = ["a", "b", "c"]
298+ vm.hiddenColumns = ["b"]
299+ vm.toggleColumnVisibility("b")
300+ XCTAssertFalse(vm.hiddenColumns.contains("b"))
301+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
302+ }
303+
304+ func testCannotHideAllColumns() {
305+ let vm = QueryViewModel()
306+ vm.columns = ["a", "b"]
307+ vm.columnOrder = ["a", "b"]
308+ vm.toggleColumnVisibility("a")
309+ XCTAssertTrue(vm.hiddenColumns.contains("a"))
310+ // Trying to hide the last visible column should be prevented
311+ vm.toggleColumnVisibility("b")
312+ XCTAssertFalse(vm.hiddenColumns.contains("b"))
313+ XCTAssertEqual(vm.visibleColumns, ["b"])
314+ }
315+
316+ func testShowAllColumns() {
317+ let vm = QueryViewModel()
318+ vm.columns = ["a", "b", "c"]
319+ vm.columnOrder = ["a", "b", "c"]
320+ vm.hiddenColumns = ["a", "b"]
321+ vm.showAllColumns()
322+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
323+ XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
324+ }
325+
326+ // MARK: - Column reordering
327+
328+ func testMoveColumnByName() {
329+ let vm = QueryViewModel()
330+ vm.columns = ["a", "b", "c"]
331+ vm.columnOrder = ["a", "b", "c"]
332+ vm.moveColumn("c", to: "a")
333+ XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
334+ }
335+
336+ func testMoveColumnByIndexSet() {
337+ let vm = QueryViewModel()
338+ vm.columns = ["a", "b", "c"]
339+ vm.columnOrder = ["a", "b", "c"]
340+ vm.moveColumn(from: IndexSet(integer: 2), to: 0)
341+ XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
342+ }
343+
344+ func testResetColumnConfig() {
345+ let vm = QueryViewModel()
346+ vm.columns = ["a", "b", "c"]
347+ vm.columnOrder = ["c", "b", "a"]
348+ vm.hiddenColumns = ["b"]
349+ vm.resetColumnConfig()
350+ XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
351+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
352+ }
353+
354+ func testVisibleColumnsRespectsOrder() {
355+ let vm = QueryViewModel()
356+ vm.columns = ["a", "b", "c"]
357+ vm.columnOrder = ["c", "a", "b"]
358+ vm.hiddenColumns = ["a"]
359+ XCTAssertEqual(vm.visibleColumns, ["c", "b"])
360+ }
361+
362+ // MARK: - Column config persistence
363+
364+ func testApplyColumnConfigWithNoSavedConfig() {
365+ let vm = QueryViewModel()
366+ vm.applyColumnConfig(extractedColumns: ["x", "y", "z"], stream: nil)
367+ XCTAssertEqual(vm.columns, ["x", "y", "z"])
368+ XCTAssertEqual(vm.columnOrder, ["x", "y", "z"])
369+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
370+ }
371+
372+ func testApplyColumnConfigMergesNewColumns() {
373+ let vm = QueryViewModel()
374+ // Save a config for "test_stream"
375+ let config = QueryViewModel.ColumnConfiguration(order: ["b", "a"], hidden: ["a"])
376+ if let data = try? JSONEncoder().encode(config) {
377+ UserDefaults.standard.set(data, forKey: "parseable_column_config_test_stream")
378+ }
379+
380+ // Apply with new columns that include an extra column "c"
381+ vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
382+ XCTAssertEqual(vm.columnOrder, ["b", "a", "c"]) // saved order preserved, new "c" appended
383+ XCTAssertEqual(vm.hiddenColumns, ["a"]) // saved hidden preserved
384+
385+ // Clean up
386+ UserDefaults.standard.removeObject(forKey: "parseable_column_config_test_stream")
387+ }
388+
389+ func testApplyColumnConfigRemovesStaleSavedColumns() {
390+ let vm = QueryViewModel()
391+ // Save a config with a column that no longer exists
392+ let config = QueryViewModel.ColumnConfiguration(order: ["old", "a", "b"], hidden: ["old"])
393+ if let data = try? JSONEncoder().encode(config) {
394+ UserDefaults.standard.set(data, forKey: "parseable_column_config_test_stream2")
395+ }
396+
397+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: "test_stream2")
398+ XCTAssertEqual(vm.columnOrder, ["a", "b"]) // "old" removed
399+ XCTAssertTrue(vm.hiddenColumns.isEmpty) // "old" was hidden but no longer exists
400+
401+ // Clean up
402+ UserDefaults.standard.removeObject(forKey: "parseable_column_config_test_stream2")
403+ }
404+
405+ func testClearResultsResetsColumnState() {
406+ let vm = QueryViewModel()
407+ vm.columns = ["a", "b"]
408+ vm.columnOrder = ["b", "a"]
409+ vm.hiddenColumns = ["a"]
410+ vm.clearResults()
411+ XCTAssertTrue(vm.columns.isEmpty)
412+ XCTAssertTrue(vm.columnOrder.isEmpty)
413+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
414+ }
415+
416+ func testCSVExportUsesVisibleColumns() {
417+ let vm = QueryViewModel()
418+ vm.results = [
419+ ["name": .string("Alice"), "age": .int(30), "secret": .string("hidden")],
420+ ]
421+ vm.columns = ["name", "age", "secret"]
422+ vm.columnOrder = ["name", "age", "secret"]
423+ vm.hiddenColumns = ["secret"]
424+ let csv = vm.exportAsCSV()
425+
426+ let lines = csv.components(separatedBy: "\n").filter { !$0.isEmpty }
427+ XCTAssertEqual(lines[0], "name,age")
428+ XCTAssertFalse(csv.contains("secret"))
429+ XCTAssertFalse(csv.contains("hidden"))
430+ }
272}
431}
273
432