From 6ed9dc8f8768b9da60c0b12a20d206257eaf5108 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 26 Jun 2026 13:55:59 +0700 Subject: [PATCH 1/5] fix: resolve reported data grid, editor, and filter UX issues Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- CHANGELOG.md | 11 ++++ .../Window/SuggestionController+Window.swift | 25 +++++---- .../Window/SuggestionController.swift | 24 +++++++- .../Find/PanelView/FindPanelHostingView.swift | 3 +- .../JumpToDefinitionModel.swift | 1 + .../QueryExecutionCoordinator+Helpers.swift | 29 ++++------ .../Core/Storage/ColumnLayoutPersister.swift | 7 ++- .../Core/Storage/ColumnLayoutPersisting.swift | 7 +++ TablePro/Models/Query/QueryTabState.swift | 5 ++ .../Views/Filter/FilterValueTextField.swift | 44 +++++++++++++-- .../Main/Child/MainEditorContentView.swift | 11 +++- .../Extensions/DataGridView+Selection.swift | 10 ++++ .../Views/Results/InlineErrorBanner.swift | 14 +++-- .../Views/Results/SortableHeaderView.swift | 23 +++++--- .../Models/Query/ColumnLayoutStateTests.swift | 56 +++++++++++++++++++ .../FileColumnLayoutPersisterTests.swift | 38 +++++++++++++ .../Filter/FilterValueTextFieldTests.swift | 41 ++++++++++++++ 17 files changed, 298 insertions(+), 51 deletions(-) create mode 100644 TableProTests/Models/Query/ColumnLayoutStateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c83a34977..b833f6246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The Columns and Add Row buttons and clicks in the SQL editor no longer stop working after the autocomplete window opens. The autocomplete now stays inside the editor and closes when you click outside it. +- Resizing a data grid column by dragging its edge no longer sorts the column by accident. +- Hidden columns stay hidden after you resize or reorder columns in the data grid; they no longer reappear on their own. +- Data grid column widths no longer snap back to their previous size when the grid reloads, sorts, or paginates right after a resize. +- SQL errors from data grid filters now show in a selectable, copyable banner above the results instead of a small alert window. +- The filter bar's autocomplete no longer pops up on empty space; it appears only while you are typing a word. +- Pressing Escape to close the filter autocomplete no longer also closes the filter bar. +- The SQL editor no longer leaks an Escape key handler each time you close a tab with the Find bar open. + ## [0.53.0] - 2026-06-25 ### Added diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index db2af3208..21615a98a 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -14,8 +14,11 @@ internal final class SuggestionPanel: NSPanel { } extension SuggestionController { - /// Will constrain the window's frame to be within the visible screen - public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont) { + /// Will constrain the window's frame to be within the visible screen and, when provided, the editor's bounds. + /// + /// `editorFrame` is the editor pane in screen coordinates. The panel flips above the cursor when it would + /// extend past the editor's bottom edge, so it never overlaps sibling chrome below the editor. + public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont, editorFrame: NSRect? = nil) { guard let window = self.window, let screenFrame = window.screen?.visibleFrame else { return @@ -39,14 +42,16 @@ extension SuggestionController { newWindowOrigin.x = maxX } - // Check if the window will go below the screen + let lowerLimit = max(screenFrame.minY, editorFrame?.minY ?? screenFrame.minY) + let upperLimit = min(screenFrame.maxY, editorFrame?.maxY ?? screenFrame.maxY) + + // Check if the window will drop below the editor (or screen) bottom. // We determine whether the window drops down or upwards by choosing which // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` - if newWindowOrigin.y - windowSize.height < screenFrame.minY { - // If the cursor itself is below the screen, then position the window - // at the bottom of the screen with some padding - if newWindowOrigin.y < screenFrame.minY { - newWindowOrigin.y = screenFrame.minY + padding + if newWindowOrigin.y - windowSize.height < lowerLimit { + // If the cursor itself is below the lower limit, pin the window there with some padding + if newWindowOrigin.y < lowerLimit { + newWindowOrigin.y = lowerLimit + padding } else { // Place above the cursor newWindowOrigin.y += cursorRect.height @@ -55,8 +60,8 @@ extension SuggestionController { isWindowAboveCursor = true window.setFrameOrigin(newWindowOrigin) } else { - // If the window goes above the screen, position it below the screen with padding - let maxY = screenFrame.maxY - padding + // If the window goes above the upper limit, pin it there with padding + let maxY = upperLimit - padding if newWindowOrigin.y > maxY { newWindowOrigin.y = maxY } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index cd65a6bcb..f4e21b15c 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -39,6 +39,7 @@ public final class SuggestionController: NSWindowController { /// Closes autocomplete when first responder changes away from the active text view private var firstResponderKVO: NSKeyValueObservation? private var localEventMonitor: Any? + private var mouseEventMonitor: Any? private var sizeObservers: Set = [] // MARK: - Initialization @@ -124,7 +125,14 @@ public final class SuggestionController: NSWindowController { self.popover = popover } else { self.showWindow(attachedTo: parentWindow) - self.constrainWindowToScreenEdges(cursorRect: cursorRect, font: textView.font) + let editorFrame = textView.view.window.map { window in + window.convertToScreen(textView.view.convert(textView.view.bounds, to: nil)) + } + self.constrainWindowToScreenEdges( + cursorRect: cursorRect, + font: textView.font, + editorFrame: editorFrame + ) } } } @@ -230,6 +238,16 @@ public final class SuggestionController: NSWindowController { private func setupEventMonitors() { removeEventMonitors() + mouseEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + ) { [weak self] event in + guard let self else { return event } + if let panel = self.window, event.window === panel { + return event + } + self.close() + return event + } localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self else { return event } @@ -265,5 +283,9 @@ public final class SuggestionController: NSWindowController { NSEvent.removeMonitor(monitor) localEventMonitor = nil } + if let monitor = mouseEventMonitor { + NSEvent.removeMonitor(monitor) + mouseEventMonitor = nil + } } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift index dedb9bdbe..30271934b 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -53,7 +53,8 @@ final class FindPanelHostingView: NSHostingView { // MARK: - Event Monitor Management func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + guard let self else { return event } if event.keyCode == 53 { // if esc pressed self.viewModel?.dismiss?() return nil // do not play "beep" sound diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift index dabbc62d8..796c23978 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/JumpToDefinition/JumpToDefinitionModel.swift @@ -69,6 +69,7 @@ final class JumpToDefinitionModel { if let textView { BezelNotification.show(symbolName: "questionmark", over: textView) } + cancelHover() return } if links.count == 1 { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index e8c8db7ed..0facbfa9e 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -500,26 +500,19 @@ extension QueryExecutionCoordinator { errorMessage: error.localizedDescription ) + guard AppSettingsManager.shared.ai.enabled else { return } + let errorMessage = error.localizedDescription let queryCopy = sql - Task { [weak self, parent] in - guard let self else { return } - if AppSettingsManager.shared.ai.enabled { - let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption( - title: String(localized: "Query Execution Failed"), - message: errorMessage, - window: parent.contentWindow - ) - if wantsAIFix { - parent.showAIChatPanel() - parent.aiViewModel?.handleFixError(query: queryCopy, error: errorMessage) - } - } else { - AlertHelper.showErrorSheet( - title: String(localized: "Query Execution Failed"), - message: errorMessage, - window: parent.contentWindow - ) + Task { [parent] in + let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption( + title: String(localized: "Query Execution Failed"), + message: errorMessage, + window: parent.contentWindow + ) + if wantsAIFix { + parent.showAIChatPanel() + parent.aiViewModel?.handleFixError(query: queryCopy, error: errorMessage) } } } diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index af63e3460..aa96e4836 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -40,6 +40,12 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { guard !layout.columnWidths.isEmpty else { return } + cacheLayout(layout, for: tableName, connectionId: connectionId) + writeEntries(loadEntries(for: connectionId), for: connectionId) + } + + func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { + guard !layout.columnWidths.isEmpty else { return } let persisted = PersistedColumnLayout( columnWidths: layout.columnWidths, @@ -49,7 +55,6 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { var entries = loadEntries(for: connectionId) entries[tableName] = persisted cache[connectionId] = entries - writeEntries(entries, for: connectionId) } func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { diff --git a/TablePro/Core/Storage/ColumnLayoutPersisting.swift b/TablePro/Core/Storage/ColumnLayoutPersisting.swift index 085597c76..ba8bb4fa3 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersisting.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersisting.swift @@ -9,5 +9,12 @@ import Foundation protocol ColumnLayoutPersisting: AnyObject { func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) + func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) func clear(for tableName: String, connectionId: UUID) } + +extension ColumnLayoutPersisting { + func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { + save(layout, for: tableName, connectionId: connectionId) + } +} diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index e92eb34dc..1062abfa0 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -321,6 +321,11 @@ struct ColumnLayoutState: Equatable { var columnWidths: [String: CGFloat] = [:] var columnOrder: [String]? var hiddenColumns: Set = [] + + mutating func applyGeometry(from other: ColumnLayoutState) { + columnWidths = other.columnWidths + columnOrder = other.columnOrder + } } struct TabExecutionState: Equatable { diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index ef1be7b97..f9b470055 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -50,6 +50,15 @@ struct FilterValueTextField: NSViewRepresentable { return matches } + static func shouldOfferTokenCompletion(fieldText: String, cursor: Int) -> Bool { + let nsText = fieldText as NSString + guard nsText.length > 0 else { return false } + let clamped = min(max(cursor, 0), nsText.length) + guard clamped > 0, + let scalar = Unicode.Scalar(nsText.character(at: clamped - 1)) else { return false } + return !CharacterSet.whitespaces.contains(scalar) + } + static func splice(into current: String, range: NSRange, insertText: String) -> (text: String, caret: Int)? { let ns = current as NSString guard range.location >= 0, range.location + range.length <= ns.length else { return nil } @@ -75,6 +84,18 @@ struct FilterValueTextField: NSViewRepresentable { } } + enum EscapeOutcome: Equatable { + case dismissPopup + case consume + case closeBar + } + + static func escapeOutcome(popupVisible: Bool, recentlyDismissedPopup: Bool) -> EscapeOutcome { + if popupVisible { return .dismissPopup } + if recentlyDismissedPopup { return .consume } + return .closeBar + } + func makeNSView(context: Context) -> NSTextField { let textField = SubstitutionDisabledTextField() textField.bezelStyle = .roundedBezel @@ -154,6 +175,7 @@ struct FilterValueTextField: NSViewRepresentable { private var windowKeyObserver: NSObjectProtocol? private var latestReplacementRange: NSRange? private var completionGeneration = 0 + private var escapeDismissedPopup = false private static let completionDebounce: UInt64 = 50_000_000 private var submitsOnAccept: Bool { @@ -230,6 +252,7 @@ struct FilterValueTextField: NSViewRepresentable { func controlTextDidChange(_ notification: Notification) { guard let textField = notification.object as? NSTextField else { return } + escapeDismissedPopup = false text.wrappedValue = textField.stringValue updateSuggestions(for: textField) } @@ -257,11 +280,18 @@ struct FilterValueTextField: NSViewRepresentable { return true } if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - if suggestionPopover != nil { + switch FilterValueTextField.escapeOutcome( + popupVisible: suggestionPopover != nil, + recentlyDismissedPopup: escapeDismissedPopup + ) { + case .dismissPopup: + escapeDismissedPopup = true dismissSuggestions() - return true + case .consume: + escapeDismissedPopup = false + case .closeBar: + onCancel() } - onCancel() return true } return false @@ -306,8 +336,13 @@ struct FilterValueTextField: NSViewRepresentable { dismissSuggestions() return } + let nsText = fieldText as NSString let editor = textField.currentEditor() as? NSTextView - let cursor = editor?.selectedRange().location ?? (fieldText as NSString).length + let cursor = min(editor?.selectedRange().location ?? nsText.length, nsText.length) + guard FilterValueTextField.shouldOfferTokenCompletion(fieldText: fieldText, cursor: cursor) else { + dismissSuggestions() + return + } completionGeneration &+= 1 let generation = completionGeneration @@ -388,6 +423,7 @@ struct FilterValueTextField: NSViewRepresentable { case .accept(let submitting): self.acceptCurrentSelection(submitting: submitting) case .dismiss: + self.escapeDismissedPopup = true self.dismissSuggestions() case .passThrough: return nsEvent diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d6119cf52..a95066409 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -452,10 +452,13 @@ struct MainEditorContentView: View { Divider() } - if let error = tab.display.activeResultSet?.errorMessage { + if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { InlineErrorBanner( message: error, - onDismiss: { tab.display.activeResultSet?.errorMessage = nil } + onDismiss: { + tab.display.activeResultSet?.errorMessage = nil + tab.execution.errorMessage = nil + } ) Divider() } @@ -679,7 +682,9 @@ struct MainEditorContentView: View { set: { newValue in coordinator.isUpdatingColumnLayout = true if let index = tabManager.selectedTabIndex { - tabManager.mutate(at: index) { $0.columnLayout = newValue } + tabManager.mutate(at: index) { tab in + tab.columnLayout.applyGeometry(from: newValue) + } } Task { @MainActor in coordinator.isUpdatingColumnLayout = false diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index 9bb56063a..e13a663f3 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -9,9 +9,19 @@ import SwiftUI extension TableViewCoordinator { func tableViewColumnDidResize(_ notification: Notification) { guard !isRebuildingColumns else { return } + cacheCurrentColumnLayout() scheduleLayoutPersist() } + private func cacheCurrentColumnLayout() { + guard tabType == .table, + let connectionId, + let tableName, + !tableName.isEmpty, + let layout = captureColumnLayout() else { return } + layoutPersister.cacheLayout(layout, for: tableName, connectionId: connectionId) + } + func tableViewColumnDidMove(_ notification: Notification) { guard !isRebuildingColumns else { return } invalidateColumnIndexCache() diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index bddfec039..316b36be3 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -13,14 +13,16 @@ struct InlineErrorBanner: View { var onDismiss: (() -> Void)? var body: some View { - HStack(spacing: 8) { + HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.red) - Text(message) - .font(.subheadline) - .lineLimit(3) - .textSelection(.enabled) - Spacer() + ScrollView(.vertical) { + Text(message) + .font(.subheadline) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 96) Button { ClipboardService.shared.writeText(message) } label: { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 43c879b26..e3b7aa571 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -100,13 +100,7 @@ final class SortableHeaderView: NSTableHeaderView { return } let point = convert(event.locationInWindow, from: nil) - let zone = Self.resizeZoneWidth - let inResizeZone = tableView.tableColumns.enumerated().contains { index, column in - guard column.resizingMask.contains(.userResizingMask) else { return false } - let edge = headerRect(ofColumn: index).maxX - return abs(point.x - edge) <= zone - } - if inResizeZone { + if isInResizeZone(point: point) { NSCursor.resizeLeftRight.set() updateFunnelHover(column: nil) } else { @@ -120,6 +114,16 @@ final class SortableHeaderView: NSTableHeaderView { updateFunnelHover(column: nil) } + private func isInResizeZone(point: NSPoint) -> Bool { + guard let tableView else { return false } + let zone = Self.resizeZoneWidth + return tableView.tableColumns.enumerated().contains { index, column in + guard column.resizingMask.contains(.userResizingMask) else { return false } + let edge = headerRect(ofColumn: index).maxX + return abs(point.x - edge) <= zone + } + } + private func hoverableColumn(at point: NSPoint) -> Int? { guard let tableView else { return nil } let columnIndex = column(at: point) @@ -213,6 +217,11 @@ final class SortableHeaderView: NSTableHeaderView { } let pointInHeader = convert(event.locationInWindow, from: nil) + if isInResizeZone(point: pointInHeader) { + super.mouseDown(with: event) + return + } + let columnIndex = column(at: pointInHeader) guard columnIndex >= 0, columnIndex < tableView.numberOfColumns else { super.mouseDown(with: event) diff --git a/TableProTests/Models/Query/ColumnLayoutStateTests.swift b/TableProTests/Models/Query/ColumnLayoutStateTests.swift new file mode 100644 index 000000000..f0673a02c --- /dev/null +++ b/TableProTests/Models/Query/ColumnLayoutStateTests.swift @@ -0,0 +1,56 @@ +// +// ColumnLayoutStateTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("ColumnLayoutState.applyGeometry") +struct ColumnLayoutStateTests { + @Test("Applying geometry updates widths and order") + func updatesWidthsAndOrder() { + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 60] + layout.columnOrder = ["id"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120, "name": 200] + incoming.columnOrder = ["name", "id"] + + layout.applyGeometry(from: incoming) + + #expect(layout.columnWidths == ["id": 120, "name": 200]) + #expect(layout.columnOrder == ["name", "id"]) + } + + @Test("Applying geometry preserves the existing hidden-column set") + func preservesHiddenColumns() { + var layout = ColumnLayoutState() + layout.hiddenColumns = ["secret", "internal_id"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120] + incoming.columnOrder = ["id"] + + layout.applyGeometry(from: incoming) + + #expect(layout.hiddenColumns == ["secret", "internal_id"]) + } + + @Test("Applying geometry ignores the incoming hidden-column set") + func ignoresIncomingHiddenColumns() { + var layout = ColumnLayoutState() + layout.hiddenColumns = ["secret"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120] + incoming.hiddenColumns = [] + + layout.applyGeometry(from: incoming) + + #expect(layout.hiddenColumns == ["secret"]) + } +} diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index e3b9c99ba..d5a98ea73 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -263,6 +263,44 @@ struct FileColumnLayoutPersisterTests { #expect(restored?.columnWidths == ["id": 60]) } + @Test("cacheLayout makes the layout readable without writing the file") + func cacheLayoutIsInMemoryOnly() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let persister = FileColumnLayoutPersister(storageDirectory: directory) + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 140] + persister.cacheLayout(layout, for: "users", connectionId: connectionId) + + #expect(persister.load(for: "users", connectionId: connectionId)?.columnWidths == ["id": 140]) + + let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("A cached layout does not survive a fresh persister instance until saved") + func cacheLayoutIsNotPersistedToDisk() { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) + defer { cleanup(directory) } + + let connectionId = UUID() + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 140] + + do { + let persister = FileColumnLayoutPersister(storageDirectory: directory) + persister.cacheLayout(layout, for: "users", connectionId: connectionId) + } + + let restored = FileColumnLayoutPersister(storageDirectory: directory) + .load(for: "users", connectionId: connectionId) + #expect(restored == nil) + } + @Test("Reading an empty JSON object returns nil for any table lookup") func emptyEntriesFileReturnsNil() throws { let directory = FileManager.default.temporaryDirectory diff --git a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift index debffc3e8..7cb5956d9 100644 --- a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift +++ b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift @@ -133,4 +133,45 @@ struct FilterValueTextFieldTests { #expect(FilterValueTextField.suggestionKeyOutcome(for: .space, submitsOnAccept: true) == .passThrough) #expect(FilterValueTextField.suggestionKeyOutcome(for: nil, submitsOnAccept: true) == .passThrough) } + + @Test("Token completion is offered while typing a partial token") + func testTokenCompletion_offeredForPartialToken() { + #expect(FilterValueTextField.shouldOfferTokenCompletion(fieldText: "cre", cursor: 3)) + #expect(FilterValueTextField.shouldOfferTokenCompletion(fieldText: "id = 1 AND cre", cursor: 14)) + } + + @Test("Token completion is suppressed when the cursor follows whitespace") + func testTokenCompletion_suppressedAfterWhitespace() { + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: " ", cursor: 1)) + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "id = ", cursor: 5)) + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "name AND ", cursor: 9)) + } + + @Test("Token completion is suppressed for an empty field or a leading cursor") + func testTokenCompletion_suppressedForEmptyOrLeadingCursor() { + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "", cursor: 0)) + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "name", cursor: 0)) + } + + @Test("Token completion clamps an out-of-range cursor to the field length") + func testTokenCompletion_clampsCursor() { + #expect(FilterValueTextField.shouldOfferTokenCompletion(fieldText: "name", cursor: 99)) + #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "name ", cursor: 99)) + } + + @Test("Escape dismisses the popup when one is visible") + func testEscapeOutcome_dismissesVisiblePopup() { + #expect(FilterValueTextField.escapeOutcome(popupVisible: true, recentlyDismissedPopup: false) == .dismissPopup) + #expect(FilterValueTextField.escapeOutcome(popupVisible: true, recentlyDismissedPopup: true) == .dismissPopup) + } + + @Test("The Escape right after dismissing the popup is consumed, keeping the filter bar open") + func testEscapeOutcome_consumesGraceEscape() { + #expect(FilterValueTextField.escapeOutcome(popupVisible: false, recentlyDismissedPopup: true) == .consume) + } + + @Test("A clean Escape with no popup closes the filter bar") + func testEscapeOutcome_closesBar() { + #expect(FilterValueTextField.escapeOutcome(popupVisible: false, recentlyDismissedPopup: false) == .closeBar) + } } From 73bd3840c3a06016b55650611f5393c9d61b7cf4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 26 Jun 2026 14:16:59 +0700 Subject: [PATCH 2/5] fix: reconcile error display and refine column-width and filter fixes Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- CHANGELOG.md | 2 +- .../Window/SuggestionController+Window.swift | 5 +++ .../QueryExecutionCoordinator+Helpers.swift | 17 +-------- .../Core/Storage/ColumnLayoutPersister.swift | 7 +--- .../Core/Storage/ColumnLayoutPersisting.swift | 7 ---- TablePro/Core/Utilities/UI/AlertHelper.swift | 24 ------------ TablePro/Models/Query/QueryTabState.swift | 6 +++ .../Views/Filter/FilterValueTextField.swift | 7 +++- .../Main/Child/MainEditorContentView.swift | 24 +++++++++--- .../Views/Main/MainContentCoordinator.swift | 7 ++++ .../Views/Results/DataGridCoordinator.swift | 19 ++++++++++ TablePro/Views/Results/DataGridView.swift | 2 +- .../Extensions/DataGridView+Selection.swift | 10 ----- .../Views/Results/InlineErrorBanner.swift | 5 +++ .../Models/Query/ColumnLayoutStateTests.swift | 24 ++++++++++++ .../FileColumnLayoutPersisterTests.swift | 38 ------------------- .../Filter/FilterValueTextFieldTests.swift | 6 +++ 17 files changed, 100 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b833f6246..10c75978c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resizing a data grid column by dragging its edge no longer sorts the column by accident. - Hidden columns stay hidden after you resize or reorder columns in the data grid; they no longer reappear on their own. - Data grid column widths no longer snap back to their previous size when the grid reloads, sorts, or paginates right after a resize. -- SQL errors from data grid filters now show in a selectable, copyable banner above the results instead of a small alert window. +- Query and filter errors now show in a selectable, copyable banner above the results, with a Fix with AI action when AI is enabled, instead of a small alert window. - The filter bar's autocomplete no longer pops up on empty space; it appears only while you are typing a word. - Pressing Escape to close the filter autocomplete no longer also closes the filter bar. - The SQL editor no longer leaks an Escape key handler each time you close a tab with the Find bar open. diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index 21615a98a..a81895d51 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -57,6 +57,11 @@ extension SuggestionController { newWindowOrigin.y += cursorRect.height } + // Keep the top edge within the upper limit so the panel never overlaps chrome above the editor + if newWindowOrigin.y + windowSize.height > upperLimit { + newWindowOrigin.y = max(lowerLimit, upperLimit - windowSize.height) + } + isWindowAboveCursor = true window.setFrameOrigin(newWindowOrigin) } else { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 0facbfa9e..9c6403a14 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -483,6 +483,7 @@ extension QueryExecutionCoordinator { connection conn: DatabaseConnection ) { parent.currentQueryTask = nil + parent.lastQueryErrorSQL = sql parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.errorMessage = error.localizedDescription tab.execution.isExecuting = false @@ -499,22 +500,6 @@ extension QueryExecutionCoordinator { wasSuccessful: false, errorMessage: error.localizedDescription ) - - guard AppSettingsManager.shared.ai.enabled else { return } - - let errorMessage = error.localizedDescription - let queryCopy = sql - Task { [parent] in - let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption( - title: String(localized: "Query Execution Failed"), - message: errorMessage, - window: parent.contentWindow - ) - if wantsAIFix { - parent.showAIChatPanel() - parent.aiViewModel?.handleFixError(query: queryCopy, error: errorMessage) - } - } } func restoreSchemaAndRunQuery(_ schema: String) async { diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index aa96e4836..af63e3460 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -40,12 +40,6 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { guard !layout.columnWidths.isEmpty else { return } - cacheLayout(layout, for: tableName, connectionId: connectionId) - writeEntries(loadEntries(for: connectionId), for: connectionId) - } - - func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { - guard !layout.columnWidths.isEmpty else { return } let persisted = PersistedColumnLayout( columnWidths: layout.columnWidths, @@ -55,6 +49,7 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { var entries = loadEntries(for: connectionId) entries[tableName] = persisted cache[connectionId] = entries + writeEntries(entries, for: connectionId) } func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { diff --git a/TablePro/Core/Storage/ColumnLayoutPersisting.swift b/TablePro/Core/Storage/ColumnLayoutPersisting.swift index ba8bb4fa3..085597c76 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersisting.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersisting.swift @@ -9,12 +9,5 @@ import Foundation protocol ColumnLayoutPersisting: AnyObject { func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) - func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) func clear(for tableName: String, connectionId: UUID) } - -extension ColumnLayoutPersisting { - func cacheLayout(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { - save(layout, for: tableName, connectionId: connectionId) - } -} diff --git a/TablePro/Core/Utilities/UI/AlertHelper.swift b/TablePro/Core/Utilities/UI/AlertHelper.swift index e761369e9..6ba2fa9b9 100644 --- a/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -238,28 +238,4 @@ final class AlertHelper { alert.runModal() } } - - // MARK: - Query Error with AI Option - - static func showQueryErrorWithAIOption( - title: String, - message: String, - window: NSWindow? - ) async -> Bool { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.alertStyle = .critical - alert.addButton(withTitle: String(localized: "OK")) - alert.addButton(withTitle: String(localized: "Ask AI to Fix")) - - if let window = resolveWindow(window) { - return await withCheckedContinuation { continuation in - alert.beginSheetModal(for: window) { response in - continuation.resume(returning: response == .alertSecondButtonReturn) - } - } - } - return alert.runModal() == .alertSecondButtonReturn - } } diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 1062abfa0..a5d495374 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -326,6 +326,12 @@ struct ColumnLayoutState: Equatable { columnWidths = other.columnWidths columnOrder = other.columnOrder } + + func mergingWidths(_ liveWidths: [String: CGFloat]) -> ColumnLayoutState { + var result = self + result.columnWidths.merge(liveWidths) { _, live in live } + return result + } } struct TabExecutionState: Equatable { diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index f9b470055..2db0ec692 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -54,8 +54,8 @@ struct FilterValueTextField: NSViewRepresentable { let nsText = fieldText as NSString guard nsText.length > 0 else { return false } let clamped = min(max(cursor, 0), nsText.length) - guard clamped > 0, - let scalar = Unicode.Scalar(nsText.character(at: clamped - 1)) else { return false } + guard clamped > 0 else { return false } + guard let scalar = Unicode.Scalar(nsText.character(at: clamped - 1)) else { return true } return !CharacterSet.whitespaces.contains(scalar) } @@ -266,6 +266,9 @@ struct FilterValueTextField: NSViewRepresentable { textView: NSTextView, doCommandBy commandSelector: Selector ) -> Bool { + if commandSelector != #selector(NSResponder.cancelOperation(_:)) { + escapeDismissedPopup = false + } if commandSelector == #selector(NSResponder.insertNewline(_:)) { if suggestionPopover != nil { acceptCurrentSelection(submitting: submitsOnAccept) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index a95066409..e8cd1f793 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -421,9 +421,26 @@ struct MainEditorContentView: View { // MARK: - Results Section + @ViewBuilder + private func executionErrorBanner(tab: QueryTab) -> some View { + if let error = tab.execution.errorMessage, + tab.display.activeResultSet?.errorMessage == nil, + tab.display.resultSets.count <= 1 { + InlineErrorBanner( + message: error, + onFixWithAI: AppSettingsManager.shared.ai.enabled + ? { coordinator.fixErrorWithAI(error: error) } + : nil, + onDismiss: { tab.execution.errorMessage = nil } + ) + Divider() + } + } + @ViewBuilder private func resultsSection(tab: QueryTab) -> some View { VStack(spacing: 0) { + executionErrorBanner(tab: tab) switch tab.display.resultsViewMode { case .structure: if let tableName = tab.tableContext.tableName { @@ -452,13 +469,10 @@ struct MainEditorContentView: View { Divider() } - if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { + if let error = tab.display.activeResultSet?.errorMessage { InlineErrorBanner( message: error, - onDismiss: { - tab.display.activeResultSet?.errorMessage = nil - tab.execution.errorMessage = nil - } + onDismiss: { tab.display.activeResultSet?.errorMessage = nil } ) Divider() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a020422b..107291b63 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -179,6 +179,7 @@ final class MainContentCoordinator { @ObservationIgnored internal var queryGeneration: Int = 0 @ObservationIgnored internal var currentQueryTask: Task? + @ObservationIgnored internal var lastQueryErrorSQL: String? @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? @@ -566,6 +567,12 @@ final class MainContentCoordinator { rightPanelState?.activeTab = .aiChat } + func fixErrorWithAI(error: String) { + guard let query = lastQueryErrorSQL else { return } + showAIChatPanel() + aiViewModel?.handleFixError(query: query, error: error) + } + /// Set up the plugin driver for query building dispatch on the query builder and change manager. private func setupPluginDriver() { guard let driver = services.databaseManager.driver(for: connectionId) else { return } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 1b5230b13..aa0c8901b 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -98,6 +98,25 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return binding } + func resolvedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? { + let saved = savedColumnLayout(binding: binding) + let liveWidths = currentColumnWidths() + guard !liveWidths.isEmpty else { return saved } + return (saved ?? ColumnLayoutState()).mergingWidths(liveWidths) + } + + private func currentColumnWidths() -> [String: CGFloat] { + guard let tableView else { return [:] } + var widths: [String: CGFloat] = [:] + for column in tableView.tableColumns + where column.identifier != ColumnIdentitySchema.rowNumberIdentifier { + guard let dataIndex = dataColumnIndex(from: column.identifier), + let name = identitySchema.columnName(for: dataIndex) else { continue } + widths[name] = column.width + } + return widths + } + func captureColumnLayout() -> ColumnLayoutState? { guard let tableView else { return nil } let tableRows = tableRowsProvider() diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 5ac88e547..d9101713c 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -266,7 +266,7 @@ struct DataGridView: NSViewRepresentable { if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true - let savedLayout = coordinator.savedColumnLayout(binding: columnLayout) + let savedLayout = coordinator.resolvedColumnLayout(binding: columnLayout) reconcileColumnPool( tableView: tableView, coordinator: coordinator, diff --git a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift index e13a663f3..9bb56063a 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Selection.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Selection.swift @@ -9,19 +9,9 @@ import SwiftUI extension TableViewCoordinator { func tableViewColumnDidResize(_ notification: Notification) { guard !isRebuildingColumns else { return } - cacheCurrentColumnLayout() scheduleLayoutPersist() } - private func cacheCurrentColumnLayout() { - guard tabType == .table, - let connectionId, - let tableName, - !tableName.isEmpty, - let layout = captureColumnLayout() else { return } - layoutPersister.cacheLayout(layout, for: tableName, connectionId: connectionId) - } - func tableViewColumnDidMove(_ notification: Notification) { guard !isRebuildingColumns else { return } invalidateColumnIndexCache() diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index 316b36be3..a07a8e87e 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -10,6 +10,7 @@ import SwiftUI struct InlineErrorBanner: View { let message: String + var onFixWithAI: (() -> Void)? var onDismiss: (() -> Void)? var body: some View { @@ -23,6 +24,10 @@ struct InlineErrorBanner: View { .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 96) + if let onFixWithAI { + Button(String(localized: "Fix with AI")) { onFixWithAI() } + .controlSize(.small) + } Button { ClipboardService.shared.writeText(message) } label: { diff --git a/TableProTests/Models/Query/ColumnLayoutStateTests.swift b/TableProTests/Models/Query/ColumnLayoutStateTests.swift index f0673a02c..4c483ca09 100644 --- a/TableProTests/Models/Query/ColumnLayoutStateTests.swift +++ b/TableProTests/Models/Query/ColumnLayoutStateTests.swift @@ -53,4 +53,28 @@ struct ColumnLayoutStateTests { #expect(layout.hiddenColumns == ["secret"]) } + + @Test("Merging live widths overrides saved widths and adds new ones") + func mergingWidthsOverridesAndAdds() { + var saved = ColumnLayoutState() + saved.columnWidths = ["id": 60, "name": 200] + saved.columnOrder = ["id", "name"] + saved.hiddenColumns = ["secret"] + + let merged = saved.mergingWidths(["name": 320, "email": 240]) + + #expect(merged.columnWidths == ["id": 60, "name": 320, "email": 240]) + #expect(merged.columnOrder == ["id", "name"]) + #expect(merged.hiddenColumns == ["secret"]) + } + + @Test("Merging an empty live width map leaves the layout unchanged") + func mergingEmptyWidthsIsNoOp() { + var saved = ColumnLayoutState() + saved.columnWidths = ["id": 60] + + let merged = saved.mergingWidths([:]) + + #expect(merged.columnWidths == ["id": 60]) + } } diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index d5a98ea73..e3b9c99ba 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -263,44 +263,6 @@ struct FileColumnLayoutPersisterTests { #expect(restored?.columnWidths == ["id": 60]) } - @Test("cacheLayout makes the layout readable without writing the file") - func cacheLayoutIsInMemoryOnly() { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) - defer { cleanup(directory) } - - let persister = FileColumnLayoutPersister(storageDirectory: directory) - let connectionId = UUID() - var layout = ColumnLayoutState() - layout.columnWidths = ["id": 140] - persister.cacheLayout(layout, for: "users", connectionId: connectionId) - - #expect(persister.load(for: "users", connectionId: connectionId)?.columnWidths == ["id": 140]) - - let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") - #expect(!FileManager.default.fileExists(atPath: fileURL.path)) - } - - @Test("A cached layout does not survive a fresh persister instance until saved") - func cacheLayoutIsNotPersistedToDisk() { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("TableProTests-\(UUID().uuidString)", isDirectory: true) - defer { cleanup(directory) } - - let connectionId = UUID() - var layout = ColumnLayoutState() - layout.columnWidths = ["id": 140] - - do { - let persister = FileColumnLayoutPersister(storageDirectory: directory) - persister.cacheLayout(layout, for: "users", connectionId: connectionId) - } - - let restored = FileColumnLayoutPersister(storageDirectory: directory) - .load(for: "users", connectionId: connectionId) - #expect(restored == nil) - } - @Test("Reading an empty JSON object returns nil for any table lookup") func emptyEntriesFileReturnsNil() throws { let directory = FileManager.default.temporaryDirectory diff --git a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift index 7cb5956d9..e967d2249 100644 --- a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift +++ b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift @@ -159,6 +159,12 @@ struct FilterValueTextFieldTests { #expect(!FilterValueTextField.shouldOfferTokenCompletion(fieldText: "name ", cursor: 99)) } + @Test("A trailing non-BMP character counts as non-whitespace and still offers completion") + func testTokenCompletion_trailingAstralCharacter() { + let text = "name😀" + #expect(FilterValueTextField.shouldOfferTokenCompletion(fieldText: text, cursor: (text as NSString).length)) + } + @Test("Escape dismisses the popup when one is visible") func testEscapeOutcome_dismissesVisiblePopup() { #expect(FilterValueTextField.escapeOutcome(popupVisible: true, recentlyDismissedPopup: false) == .dismissPopup) From 111e2bf4c30d086f8748814b732e70f61e33bb09 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 11:52:17 +0700 Subject: [PATCH 3/5] fix(editor): clear the query error through the tab manager, not a let binding Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- TablePro/Views/Main/Child/MainEditorContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index e8cd1f793..7fc9bdc0d 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -431,7 +431,7 @@ struct MainEditorContentView: View { onFixWithAI: AppSettingsManager.shared.ai.enabled ? { coordinator.fixErrorWithAI(error: error) } : nil, - onDismiss: { tab.execution.errorMessage = nil } + onDismiss: { tabManager.mutate(tabId: tab.id) { $0.execution.errorMessage = nil } } ) Divider() } From 24a3e2e9fc0608df8ae559cf2b529b55ac5526af Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 16:29:18 +0700 Subject: [PATCH 4/5] fix: harden query-error banner, AI-fix query source, and column-width capture Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- .../QueryExecutionCoordinator+Helpers.swift | 1 - .../Main/Child/MainEditorContentView.swift | 23 ++++++++----------- .../Views/Main/MainContentCoordinator.swift | 4 +--- .../Views/Results/DataGridCoordinator.swift | 5 ++-- TablePro/Views/Results/DataGridView.swift | 3 ++- 5 files changed, 14 insertions(+), 22 deletions(-) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 9c6403a14..1c26dba44 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -483,7 +483,6 @@ extension QueryExecutionCoordinator { connection conn: DatabaseConnection ) { parent.currentQueryTask = nil - parent.lastQueryErrorSQL = sql parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.errorMessage = error.localizedDescription tab.execution.isExecuting = false diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 7fc9bdc0d..73d0e603c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -423,15 +423,18 @@ struct MainEditorContentView: View { @ViewBuilder private func executionErrorBanner(tab: QueryTab) -> some View { - if let error = tab.execution.errorMessage, - tab.display.activeResultSet?.errorMessage == nil, - tab.display.resultSets.count <= 1 { + if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { InlineErrorBanner( message: error, - onFixWithAI: AppSettingsManager.shared.ai.enabled - ? { coordinator.fixErrorWithAI(error: error) } + onFixWithAI: AppSettingsManager.shared.ai.enabled && tab.tabType == .query + ? { coordinator.fixErrorWithAI(query: tab.content.query, error: error) } : nil, - onDismiss: { tabManager.mutate(tabId: tab.id) { $0.execution.errorMessage = nil } } + onDismiss: { + tabManager.mutate(tabId: tab.id) { + $0.display.activeResultSet?.errorMessage = nil + $0.execution.errorMessage = nil + } + } ) Divider() } @@ -469,14 +472,6 @@ struct MainEditorContentView: View { Divider() } - if let error = tab.display.activeResultSet?.errorMessage { - InlineErrorBanner( - message: error, - onDismiss: { tab.display.activeResultSet?.errorMessage = nil } - ) - Divider() - } - let resolvedRows = resolvedTableRows(for: tab) if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty, rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 107291b63..7e239bec7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -179,7 +179,6 @@ final class MainContentCoordinator { @ObservationIgnored internal var queryGeneration: Int = 0 @ObservationIgnored internal var currentQueryTask: Task? - @ObservationIgnored internal var lastQueryErrorSQL: String? @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? @@ -567,8 +566,7 @@ final class MainContentCoordinator { rightPanelState?.activeTab = .aiChat } - func fixErrorWithAI(error: String) { - guard let query = lastQueryErrorSQL else { return } + func fixErrorWithAI(query: String, error: String) { showAIChatPanel() aiViewModel?.handleFixError(query: query, error: error) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index aa0c8901b..f49e76897 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -98,14 +98,13 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return binding } - func resolvedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? { + func resolvedColumnLayout(binding: ColumnLayoutState, liveWidths: [String: CGFloat]) -> ColumnLayoutState? { let saved = savedColumnLayout(binding: binding) - let liveWidths = currentColumnWidths() guard !liveWidths.isEmpty else { return saved } return (saved ?? ColumnLayoutState()).mergingWidths(liveWidths) } - private func currentColumnWidths() -> [String: CGFloat] { + func currentColumnWidths() -> [String: CGFloat] { guard let tableView else { return [:] } var widths: [String: CGFloat] = [:] for column in tableView.tableColumns diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index d9101713c..ba34a44b8 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -234,6 +234,7 @@ struct DataGridView: NSViewRepresentable { let oldColumnCount = coordinator.cachedColumnCount let structureChanged = oldRowCount != rowDisplayCount || oldColumnCount != columnCount + let liveColumnWidths = coordinator.currentColumnWidths() let schemaChanged = coordinator.rebuildColumnMetadataCache(from: latestRows) let needsFullReload = structureChanged || schemaChanged || contentChanged if contentChanged { @@ -266,7 +267,7 @@ struct DataGridView: NSViewRepresentable { if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true - let savedLayout = coordinator.resolvedColumnLayout(binding: columnLayout) + let savedLayout = coordinator.resolvedColumnLayout(binding: columnLayout, liveWidths: liveColumnWidths) reconcileColumnPool( tableView: tableView, coordinator: coordinator, From 93e772a6887b368ec356ed9722c9d09c052a44fb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 27 Jun 2026 18:12:33 +0700 Subject: [PATCH 5/5] test: merge ColumnLayoutState geometry tests into the existing suite, drop the duplicate file Claude-Session: https://claude.ai/code/session_0198faM6VCrViRU4XwRoS1DC --- .../Models/ColumnLayoutStateTests.swift | 74 ++++++++++++++++- .../Models/Query/ColumnLayoutStateTests.swift | 80 ------------------- 2 files changed, 73 insertions(+), 81 deletions(-) delete mode 100644 TableProTests/Models/Query/ColumnLayoutStateTests.swift diff --git a/TableProTests/Models/ColumnLayoutStateTests.swift b/TableProTests/Models/ColumnLayoutStateTests.swift index dc272711e..4665ddb68 100644 --- a/TableProTests/Models/ColumnLayoutStateTests.swift +++ b/TableProTests/Models/ColumnLayoutStateTests.swift @@ -6,8 +6,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("ColumnLayoutState") @@ -103,4 +103,76 @@ struct ColumnLayoutStateTests { #expect(state.hiddenColumns.contains("created_at")) #expect(!state.hiddenColumns.contains("name")) } + + // MARK: - applyGeometry + + @Test("Applying geometry updates widths and order") + func applyGeometryUpdatesWidthsAndOrder() { + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 60] + layout.columnOrder = ["id"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120, "name": 200] + incoming.columnOrder = ["name", "id"] + + layout.applyGeometry(from: incoming) + + #expect(layout.columnWidths == ["id": 120, "name": 200]) + #expect(layout.columnOrder == ["name", "id"]) + } + + @Test("Applying geometry preserves the existing hidden-column set") + func applyGeometryPreservesHiddenColumns() { + var layout = ColumnLayoutState() + layout.hiddenColumns = ["secret", "internal_id"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120] + incoming.columnOrder = ["id"] + + layout.applyGeometry(from: incoming) + + #expect(layout.hiddenColumns == ["secret", "internal_id"]) + } + + @Test("Applying geometry ignores the incoming hidden-column set") + func applyGeometryIgnoresIncomingHiddenColumns() { + var layout = ColumnLayoutState() + layout.hiddenColumns = ["secret"] + + var incoming = ColumnLayoutState() + incoming.columnWidths = ["id": 120] + incoming.hiddenColumns = [] + + layout.applyGeometry(from: incoming) + + #expect(layout.hiddenColumns == ["secret"]) + } + + // MARK: - mergingWidths + + @Test("Merging live widths overrides saved widths and adds new ones") + func mergingWidthsOverridesAndAdds() { + var saved = ColumnLayoutState() + saved.columnWidths = ["id": 60, "name": 200] + saved.columnOrder = ["id", "name"] + saved.hiddenColumns = ["secret"] + + let merged = saved.mergingWidths(["name": 320, "email": 240]) + + #expect(merged.columnWidths == ["id": 60, "name": 320, "email": 240]) + #expect(merged.columnOrder == ["id", "name"]) + #expect(merged.hiddenColumns == ["secret"]) + } + + @Test("Merging an empty live width map leaves the layout unchanged") + func mergingEmptyWidthsIsNoOp() { + var saved = ColumnLayoutState() + saved.columnWidths = ["id": 60] + + let merged = saved.mergingWidths([:]) + + #expect(merged.columnWidths == ["id": 60]) + } } diff --git a/TableProTests/Models/Query/ColumnLayoutStateTests.swift b/TableProTests/Models/Query/ColumnLayoutStateTests.swift deleted file mode 100644 index 4c483ca09..000000000 --- a/TableProTests/Models/Query/ColumnLayoutStateTests.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// ColumnLayoutStateTests.swift -// TableProTests -// - -import Foundation -import Testing - -@testable import TablePro - -@Suite("ColumnLayoutState.applyGeometry") -struct ColumnLayoutStateTests { - @Test("Applying geometry updates widths and order") - func updatesWidthsAndOrder() { - var layout = ColumnLayoutState() - layout.columnWidths = ["id": 60] - layout.columnOrder = ["id"] - - var incoming = ColumnLayoutState() - incoming.columnWidths = ["id": 120, "name": 200] - incoming.columnOrder = ["name", "id"] - - layout.applyGeometry(from: incoming) - - #expect(layout.columnWidths == ["id": 120, "name": 200]) - #expect(layout.columnOrder == ["name", "id"]) - } - - @Test("Applying geometry preserves the existing hidden-column set") - func preservesHiddenColumns() { - var layout = ColumnLayoutState() - layout.hiddenColumns = ["secret", "internal_id"] - - var incoming = ColumnLayoutState() - incoming.columnWidths = ["id": 120] - incoming.columnOrder = ["id"] - - layout.applyGeometry(from: incoming) - - #expect(layout.hiddenColumns == ["secret", "internal_id"]) - } - - @Test("Applying geometry ignores the incoming hidden-column set") - func ignoresIncomingHiddenColumns() { - var layout = ColumnLayoutState() - layout.hiddenColumns = ["secret"] - - var incoming = ColumnLayoutState() - incoming.columnWidths = ["id": 120] - incoming.hiddenColumns = [] - - layout.applyGeometry(from: incoming) - - #expect(layout.hiddenColumns == ["secret"]) - } - - @Test("Merging live widths overrides saved widths and adds new ones") - func mergingWidthsOverridesAndAdds() { - var saved = ColumnLayoutState() - saved.columnWidths = ["id": 60, "name": 200] - saved.columnOrder = ["id", "name"] - saved.hiddenColumns = ["secret"] - - let merged = saved.mergingWidths(["name": 320, "email": 240]) - - #expect(merged.columnWidths == ["id": 60, "name": 320, "email": 240]) - #expect(merged.columnOrder == ["id", "name"]) - #expect(merged.hiddenColumns == ["secret"]) - } - - @Test("Merging an empty live width map leaves the layout unchanged") - func mergingEmptyWidthsIsNoOp() { - var saved = ColumnLayoutState() - saved.columnWidths = ["id": 60] - - let merged = saved.mergingWidths([:]) - - #expect(merged.columnWidths == ["id": 60]) - } -}