diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7717d29..afbd3d5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- 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. - SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769) ## [0.53.0] - 2026-06-25 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index db2af3208..a81895d51 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,24 +42,31 @@ 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 } + // 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 { - // 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..1c26dba44 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -499,29 +499,6 @@ extension QueryExecutionCoordinator { wasSuccessful: false, errorMessage: error.localizedDescription ) - - 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 - ) - } - } } func restoreSchemaAndRunQuery(_ schema: String) async { 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 e92eb34dc..a5d495374 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -321,6 +321,17 @@ 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 + } + + 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 ef1be7b97..2db0ec692 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 else { return false } + guard let scalar = Unicode.Scalar(nsText.character(at: clamped - 1)) else { return true } + 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) } @@ -243,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) @@ -257,11 +283,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 +339,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 +426,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..73d0e603c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -421,9 +421,29 @@ struct MainEditorContentView: View { // MARK: - Results Section + @ViewBuilder + private func executionErrorBanner(tab: QueryTab) -> some View { + if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage { + InlineErrorBanner( + message: 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.display.activeResultSet?.errorMessage = nil + $0.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,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 @@ -679,7 +691,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/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 3a020422b..7e239bec7 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -566,6 +566,11 @@ final class MainContentCoordinator { rightPanelState?.activeTab = .aiChat } + func fixErrorWithAI(query: String, error: String) { + 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..f49e76897 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -98,6 +98,24 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return binding } + func resolvedColumnLayout(binding: ColumnLayoutState, liveWidths: [String: CGFloat]) -> ColumnLayoutState? { + let saved = savedColumnLayout(binding: binding) + guard !liveWidths.isEmpty else { return saved } + return (saved ?? ColumnLayoutState()).mergingWidths(liveWidths) + } + + 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..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.savedColumnLayout(binding: columnLayout) + let savedLayout = coordinator.resolvedColumnLayout(binding: columnLayout, liveWidths: liveColumnWidths) reconcileColumnPool( tableView: tableView, coordinator: coordinator, diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index bddfec039..a07a8e87e 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -10,17 +10,24 @@ import SwiftUI struct InlineErrorBanner: View { let message: String + var onFixWithAI: (() -> Void)? 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) + if let onFixWithAI { + Button(String(localized: "Fix with AI")) { onFixWithAI() } + .controlSize(.small) + } 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/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/Views/Filter/FilterValueTextFieldTests.swift b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift index debffc3e8..e967d2249 100644 --- a/TableProTests/Views/Filter/FilterValueTextFieldTests.swift +++ b/TableProTests/Views/Filter/FilterValueTextFieldTests.swift @@ -133,4 +133,51 @@ 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("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) + #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) + } }