git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: column move rightward, stale server info, abandoned connection, table quote, filter parenthesization
commitf28fd22d2ad18445a03ecfa07650cc74ce48ad7c authoriko <mail@iko.soy> dateSun 21 Jun 2026, 11:49 (3 months ago) parent4b452fa
7 files changed+75−10
ParseableViewer/App/AppState.swift+4 ParseableViewer/ViewModels/LiveTailViewModel.swift+6−3 ParseableViewer/ViewModels/QueryViewModel.swift+17−7 ParseableViewer/Views/ConnectionSheet.swift+7 ParseableViewer/Views/SQLEditorView.swift+6 ParseableViewer/Views/ServerInfoView.swift+7 ParseableViewerTests/QueryViewModelTests.swift+28
ParseableViewer/App/AppState.swift+4
@@ -141,6 +141,10 @@ final class AppState {
141141 if activeConnection?.id != connection.id {
142142 selectedStream = nil
143143 streamLoadError = nil
144+ // Drop the previous server's cached `about` so views seeding from it
145+ // (ServerInfoView) don't paint the old server's details while the new
146+ // one loads. It's repopulated below once the new server responds.
147+ serverAbout = nil
144148 // Reset the sidebar search filter and tab too: a stale filter would
145149 // silently hide the new server's streams, and a stream-specific tab
146150 // would have no valid selection behind it.
ParseableViewer/ViewModels/LiveTailViewModel.swift+6−3
@@ -278,9 +278,12 @@ final class LiveTailViewModel {
278278 let toIndex = columnOrder.firstIndex(of: targetColumn),
279279 fromIndex != toIndex else { return }
280280 let item = columnOrder.remove(at: fromIndex)
281− // Removing an element before the target shifts the target down by one, so
282− // inserting at the original `toIndex` would land the column past its target.
283− let adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex
281+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
282+ // lands the column *after* the target, a leftward move lands it *before*.
283+ // After removing an earlier element the target shifts down by one, so
284+ // inserting at `toIndex` lands after the target for rightward moves; for
285+ // leftward moves the target is unshifted and `toIndex` lands before it.
286+ let adjusted = toIndex
284287 columnOrder.insert(item, at: adjusted)
285288 saveColumnConfig()
286289 }
ParseableViewer/ViewModels/QueryViewModel.swift+17−7
@@ -193,9 +193,12 @@ final class QueryViewModel {
193193 let toIndex = columnOrder.firstIndex(of: targetColumn),
194194 fromIndex != toIndex else { return }
195195 let item = columnOrder.remove(at: fromIndex)
196− // Removing an element before the target shifts the target down by one, so
197− // inserting at the original `toIndex` would land the column past its target.
198− let adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex
196+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
197+ // lands the column *after* the target, a leftward move lands it *before*.
198+ // After removing an earlier element the target shifts down by one, so
199+ // inserting at `toIndex` lands after the target for rightward moves; for
200+ // leftward moves the target is unshifted and `toIndex` lands before it.
201+ let adjusted = toIndex
199202 columnOrder.insert(item, at: adjusted)
200203 saveColumnConfig()
201204 updateSQLColumns()
@@ -627,6 +630,9 @@ final class QueryViewModel {
627630 var depth = 0
628631 var whereEnd: String.Index?
629632 var tailStart: String.Index?
633+ // Track whether the existing WHERE body has a top-level OR — only then does
634+ // AND-ing a new condition need parentheses to preserve precedence.
635+ var bodyHasTopLevelOr = false
630636 for token in SQLTokenizer.tokenize(sql) {
631637 switch token.kind {
632638 case .leftParen:
@@ -638,6 +644,8 @@ final class QueryViewModel {
638644 whereEnd = token.range.upperBound
639645 } else if tailKeywords.contains(kw), tailStart == nil {
640646 tailStart = token.range.lowerBound
647+ } else if kw == "OR", whereEnd != nil, tailStart == nil {
648+ bodyHasTopLevelOr = true
641649 }
642650 default:
643651 break
@@ -646,16 +654,18 @@ final class QueryViewModel {
646654
647655 if let whereEnd {
648656 // Parenthesize the existing WHERE body before AND-ing the new
649− // condition, otherwise an existing top-level OR silently changes
650− // meaning: `WHERE a = 1 OR b = 2` would become
657+ // condition only when it has a top-level OR, otherwise the OR
658+ // silently changes meaning: `WHERE a = 1 OR b = 2` would become
651659 // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
652660 // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
653− // `(a = 1 OR b = 2) AND c = 3`.
661+ // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
662+ // left un-parenthesized so simple filters stay readable.
654663 let bodyEnd = tailStart ?? sql.endIndex
655664 let head = sql[..<whereEnd]
656665 let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
657666 let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
658− sql = "\(head) (\(body)) AND \(condition)"
667+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
668+ sql = "\(head) \(wrappedBody) AND \(condition)"
659669 if !tail.isEmpty {
660670 sql += " \(tail)"
661671 }
ParseableViewer/Views/ConnectionSheet.swift+7
@@ -123,6 +123,13 @@ struct ConnectionSheet: View {
123123 .accessibilityLabel("Test connection")
124124
125125 Button("Cancel") {
126+ // A new connection is persisted on the first save so a retry
127+ // updates it in place. If the user abandons it via Cancel
128+ // (e.g. after a failed connect), remove that never-connected
129+ // entry so it doesn't linger in the sidebar.
130+ if let created = createdConnection {
131+ appState.removeConnection(created)
132+ }
126133 password = ""
127134 dismiss()
128135 }
ParseableViewer/Views/SQLEditorView.swift+6
@@ -221,6 +221,12 @@ struct SQLEditorView: NSViewRepresentable {
221221 if item.kind == .table, wordStart > 0,
222222 nsText.character(at: wordStart - 1) == 0x22 {
223223 wordStart -= 1
224+ // Mirror on the trailing side: if a matching closing quote follows
225+ // the word (caret inside an already-quoted `FROM "my|"`), absorb it
226+ // too so the inserted `"name"` doesn't leave a dangling `"name""`.
227+ if wordEnd < nsText.length, nsText.character(at: wordEnd) == 0x22 {
228+ wordEnd += 1
229+ }
224230 }
225231
226232 let replaceRange = NSRange(location: wordStart, length: wordEnd - wordStart)
ParseableViewer/Views/ServerInfoView.swift+7
@@ -126,6 +126,13 @@ struct ServerInfoView: View {
126126 // Key on the active connection so switching (or reconnecting to a
127127 // different) server reloads instead of showing the previous server's info.
128128 .task(id: appState.activeConnection?.id) {
129+ // Drop the previous server's details immediately on switch so they
130+ // aren't shown while the newly-selected server's info loads. (Local
131+ // @State persists across the .task id change, so it must be cleared
132+ // explicitly.) The manual Refresh button keeps the current values.
133+ about = nil
134+ isHealthy = false
135+ errorMessage = nil
129136 await loadInfo()
130137 }
131138 }
ParseableViewerTests/QueryViewModelTests.swift+28
@@ -259,6 +259,15 @@ final class QueryViewModelTests: XCTestCase {
259259 XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
260260 }
261261
262+ func testAddColumnFilterParenthesizesExistingOr() {
263+ // An existing top-level OR must be parenthesized before AND-ing the new
264+ // condition so precedence is preserved.
265+ let vm = QueryViewModel()
266+ vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"a\" = 1 OR \"b\" = 2"
267+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
268+ XCTAssertTrue(vm.sqlQuery.contains("(\"a\" = 1 OR \"b\" = 2) AND \"host\" = 'web-1'"))
269+ }
270+
262271 func testAddColumnFilterIntValue() {
263272 let vm = QueryViewModel()
264273 vm.sqlQuery = "SELECT * FROM \"logs\""
@@ -348,6 +357,25 @@ final class QueryViewModelTests: XCTestCase {
348357 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
349358 }
350359
360+ func testMoveColumnRightwardLandsAfterTarget() {
361+ // Rightward move (e.g. the "Move Right" context action, which passes the
362+ // adjacent column as the target) must land the column *after* the target,
363+ // not produce a no-op.
364+ let vm = QueryViewModel()
365+ vm.columns = ["a", "b", "c"]
366+ vm.columnOrder = ["a", "b", "c"]
367+ vm.moveColumn("b", to: "c")
368+ XCTAssertEqual(vm.columnOrder, ["a", "c", "b"])
369+ }
370+
371+ func testMoveColumnLeftwardLandsBeforeTarget() {
372+ let vm = QueryViewModel()
373+ vm.columns = ["a", "b", "c"]
374+ vm.columnOrder = ["a", "b", "c"]
375+ vm.moveColumn("b", to: "a")
376+ XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
377+ }
378+
351379 func testMoveColumnByIndexSet() {
352380 let vm = QueryViewModel()
353381 vm.columns = ["a", "b", "c"]