diff --git a/CHANGELOG.md b/CHANGELOG.md index 98134c534..4d2fac32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Switching schemas on an Oracle connection no longer hangs on an infinite loading spinner. Oracle now switches by schema like BigQuery, the sidebar lists every schema with its tables loading on expand, Oracle queries respect the query timeout setting and reconnect automatically after a timeout, and a schema load that fails shows an error with a Retry button instead of spinning forever. Works with an already-installed Oracle plugin; updating the plugin adds the query timeout enforcement. (#1807) +- Resizing a data grid column no longer triggers a sort. Dragging a column edge only resizes it; clicking the header still sorts. (#1815) +- Hidden columns stay hidden. The columns you choose to show are remembered per table across sessions, and resizing a column no longer brings the hidden ones back. Column widths and order are remembered per table too, now kept separately for each connection, database, and schema so two tables with the same name no longer overwrite each other's layout. A Reset Columns button in the Columns popover puts widths, order, and visibility back to defaults. Existing saved column layouts (widths, order, and which columns are hidden) reset once as part of this change. (#1815) +- Query and filter errors now appear in a scrollable banner you can read, select, and copy, with a Fix with AI button, instead of a small dialog that cut the message off. (#1815) +- The filter autocomplete no longer pops up on empty input, and pressing Escape to close it no longer also closes the filter bar. (#1815) +- The editor autocomplete popup no longer draws over the status bar or the Columns and Add buttons, and clicking outside it now dismisses it. This fixes the bottom-right buttons and the editor mouse going dead after the popup had appeared. (#1815) ## [0.55.0] - 2026-07-04 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/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 9034dc172..12d64c274 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -488,12 +488,12 @@ extension QueryExecutionCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection, - trigger: TableLoadTrigger = .userInitiated + connection conn: DatabaseConnection ) { parent.currentQueryTask = nil parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.errorMessage = error.localizedDescription + tab.execution.errorQuery = sql tab.execution.isExecuting = false tab.execution.lastExecutedAt = Date() } @@ -508,31 +508,6 @@ extension QueryExecutionCoordinator { wasSuccessful: false, errorMessage: error.localizedDescription ) - - guard !trigger.suppressesFailureModal 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 - ) - } - } } func restoreSchemaAndRunQuery(_ schema: String, trigger: TableLoadTrigger = .userInitiated) async { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index b82c54d9b..781c43da4 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -432,9 +432,10 @@ extension QueryExecutionCoordinator { + error.localizedDescription let errorRS = ResultSet(label: "Error \(failedStmtIndex)") - errorRS.errorMessage = error.localizedDescription + errorRS.errorMessage = contextMsg resultSets.append(errorRS) + let failedStatement = failedSQL ?? statements[min(executedCount, totalCount - 1)] let capturedResultSets = resultSets await MainActor.run { [weak self] in guard let self else { return } @@ -443,6 +444,7 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.errorMessage = contextMsg + tab.execution.errorQuery = failedStatement tab.execution.isExecuting = false tab.execution.executionTime = cumulativeTime @@ -451,7 +453,7 @@ extension QueryExecutionCoordinator { tab.display.activeResultSetId = capturedResultSets.last?.id } - let rawSQL = failedSQL ?? statements[min(executedCount, totalCount - 1)] + let rawSQL = failedStatement let recordSQL = rawSQL.hasSuffix(";") ? rawSQL : rawSQL + ";" QueryHistoryManager.shared.recordQuery( query: recordSQL, @@ -462,12 +464,6 @@ extension QueryExecutionCoordinator { wasSuccessful: false, errorMessage: error.localizedDescription ) - - AlertHelper.showErrorSheet( - title: String(localized: "Query Execution Failed"), - message: contextMsg, - window: parent.contentWindow - ) } } } diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index af63e3460..773af50ef 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -8,9 +8,16 @@ import os @MainActor final class FileColumnLayoutPersister: ColumnLayoutPersisting { + static let shared: FileColumnLayoutPersister = { + let persister = FileColumnLayoutPersister() + persister.performScopeMigration() + return persister + }() + private static let logger = Logger(subsystem: "com.TablePro", category: "ColumnLayoutPersister") - private static let legacyKeyPrefix = "com.TablePro.columns.layout." - private static let migrationCompleteKey = "com.TablePro.columnLayoutMigrationComplete" + private static let legacyUserDefaultsPrefix = "com.TablePro.columns.layout." + private static let legacyVisibilityPrefix = "com.TablePro.columns.hiddenColumns." + private static let scopeMigrationKey = "com.TablePro.columnLayoutSchemaScopeMigrationComplete" private struct PersistedColumnLayout: Codable { var columnWidths: [String: CGFloat] @@ -34,11 +41,9 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { } catch { Self.logger.error("Failed to create storage directory: \(error.localizedDescription)") } - - Self.performMigrationIfNeeded(storageDirectory: self.storageDirectory) } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) { guard !layout.columnWidths.isEmpty else { return } let persisted = PersistedColumnLayout( @@ -46,15 +51,15 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { columnOrder: layout.columnOrder ) - var entries = loadEntries(for: connectionId) - entries[tableName] = persisted - cache[connectionId] = entries - writeEntries(entries, for: connectionId) + var entries = loadEntries(for: key.connectionId) + entries[key.storageKey] = persisted + cache[key.connectionId] = entries + writeEntries(entries, for: key.connectionId) } - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { - let entries = loadEntries(for: connectionId) - guard let persisted = entries[tableName] else { return nil } + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { + let entries = loadEntries(for: key.connectionId) + guard let persisted = entries[key.storageKey] else { return nil } var state = ColumnLayoutState() state.columnWidths = persisted.columnWidths @@ -62,16 +67,16 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { return state } - func clear(for tableName: String, connectionId: UUID) { - var entries = loadEntries(for: connectionId) - guard entries.removeValue(forKey: tableName) != nil else { return } + func clear(for key: ColumnLayoutTableKey) { + var entries = loadEntries(for: key.connectionId) + guard entries.removeValue(forKey: key.storageKey) != nil else { return } if entries.isEmpty { - cache[connectionId] = [:] - removeFile(for: connectionId) + cache[key.connectionId] = [:] + removeFile(for: key.connectionId) } else { - cache[connectionId] = entries - writeEntries(entries, for: connectionId) + cache[key.connectionId] = entries + writeEntries(entries, for: key.connectionId) } } @@ -136,53 +141,26 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { .appendingPathComponent("ColumnLayout", isDirectory: true) } - private static func performMigrationIfNeeded(storageDirectory: URL) { + private func performScopeMigration() { let defaults = UserDefaults.standard - guard !defaults.bool(forKey: migrationCompleteKey) else { return } - - let allKeys = defaults.dictionaryRepresentation().keys - let legacyKeys = allKeys.filter { $0.hasPrefix(legacyKeyPrefix) } - - var grouped: [UUID: [String: PersistedColumnLayout]] = [:] - let decoder = JSONDecoder() - - for key in legacyKeys { - let suffix = String(key.dropFirst(legacyKeyPrefix.count)) - guard let dotIndex = suffix.firstIndex(of: ".") else { continue } - - let uuidString = String(suffix[.. ColumnLayoutState? - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) - func clear(for tableName: String, connectionId: UUID) + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) + func clear(for key: ColumnLayoutTableKey) } diff --git a/TablePro/Core/Storage/ColumnVisibilityPersistence.swift b/TablePro/Core/Storage/ColumnVisibilityPersistence.swift index 6499626a8..03a33c4d4 100644 --- a/TablePro/Core/Storage/ColumnVisibilityPersistence.swift +++ b/TablePro/Core/Storage/ColumnVisibilityPersistence.swift @@ -6,27 +6,30 @@ import Foundation enum ColumnVisibilityPersistence { - static func key(tableName: String, connectionId: UUID) -> String { - "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).\(tableName)" + private static let keyPrefix = "com.TablePro.columns.hiddenColumns." + + static func key(for tableKey: ColumnLayoutTableKey) -> String { + keyPrefix + tableKey.storageKey } static func loadHiddenColumns( - for tableName: String, - connectionId: UUID, + for tableKey: ColumnLayoutTableKey, defaults: UserDefaults = .standard ) -> Set { - let storageKey = key(tableName: tableName, connectionId: connectionId) - guard let array = defaults.stringArray(forKey: storageKey) else { return [] } + guard let array = defaults.stringArray(forKey: key(for: tableKey)) else { return [] } return Set(array) } static func saveHiddenColumns( _ hiddenColumns: Set, - for tableName: String, - connectionId: UUID, + for tableKey: ColumnLayoutTableKey, defaults: UserDefaults = .standard ) { - let storageKey = key(tableName: tableName, connectionId: connectionId) - defaults.set(Array(hiddenColumns), forKey: storageKey) + let storageKey = key(for: tableKey) + if hiddenColumns.isEmpty { + defaults.removeObject(forKey: storageKey) + } else { + defaults.set(Array(hiddenColumns), forKey: storageKey) + } } } diff --git a/TablePro/Core/Storage/CompositeStorageKey.swift b/TablePro/Core/Storage/CompositeStorageKey.swift new file mode 100644 index 000000000..6c85be160 --- /dev/null +++ b/TablePro/Core/Storage/CompositeStorageKey.swift @@ -0,0 +1,19 @@ +// +// CompositeStorageKey.swift +// TablePro +// + +import Foundation + +enum CompositeStorageKey { + static func make( + connectionId: UUID, + databaseName: String, + schemaName: String?, + tableName: String + ) -> String { + [connectionId.uuidString, databaseName, schemaName ?? "", tableName] + .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } + .joined(separator: ".") + } +} diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 03b242440..f6d85321d 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -387,9 +387,12 @@ final class FilterSettingsStorage { databaseName: String, schemaName: String? ) -> String { - [connectionId.uuidString, databaseName, schemaName ?? "", tableName] - .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } - .joined(separator: ".") + CompositeStorageKey.make( + connectionId: connectionId, + databaseName: databaseName, + schemaName: schemaName, + tableName: tableName + ) } private static func resolvedFilterStateDirectory() -> URL { 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..b2b15b4d7 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 { @@ -328,6 +339,7 @@ struct TabExecutionState: Equatable { var executionTime: TimeInterval? var statusMessage: String? var errorMessage: String? + var errorQuery: String? var rowsAffected: Int = 0 var lastExecutedAt: Date? diff --git a/TablePro/Models/Query/TableLoadTrigger.swift b/TablePro/Models/Query/TableLoadTrigger.swift index 737230a35..240a04661 100644 --- a/TablePro/Models/Query/TableLoadTrigger.swift +++ b/TablePro/Models/Query/TableLoadTrigger.swift @@ -8,8 +8,4 @@ import Foundation internal enum TableLoadTrigger { case userInitiated case restore - - var suppressesFailureModal: Bool { - self == .restore - } } diff --git a/TablePro/Models/UI/DataGridConfiguration.swift b/TablePro/Models/UI/DataGridConfiguration.swift index 6e3336ce0..b506cf9ca 100644 --- a/TablePro/Models/UI/DataGridConfiguration.swift +++ b/TablePro/Models/UI/DataGridConfiguration.swift @@ -14,6 +14,8 @@ struct DataGridConfiguration: Equatable { var connectionId: UUID? var databaseType: DatabaseType? var tableName: String? + var databaseName: String? + var schemaName: String? var primaryKeyColumns: [String] = [] var tabType: TabType? var showRowNumbers: Bool = true 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 24b9d500e..f1363c235 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -474,9 +474,30 @@ 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.execution.errorQuery ?? tab.content.query, error: error) } + : nil, + onDismiss: { + tabManager.mutate(tabId: tab.id) { + $0.display.activeResultSet?.errorMessage = nil + $0.execution.errorMessage = nil + $0.execution.errorQuery = 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 { @@ -505,14 +526,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 @@ -627,6 +640,8 @@ struct MainEditorContentView: View { connectionId: connection.id, databaseType: connection.type, tableName: tab.tableContext.tableName, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, primaryKeyColumns: changeManager.primaryKeyColumns, tabType: tab.tabType, showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, @@ -727,13 +742,12 @@ struct MainEditorContentView: View { } private func columnLayoutBinding(for tab: QueryTab) -> Binding { - Binding( + let tabId = tab.id + return Binding( get: { tab.columnLayout }, set: { newValue in coordinator.isUpdatingColumnLayout = true - if let index = tabManager.selectedTabIndex { - tabManager.mutate(at: index) { $0.columnLayout = newValue } - } + coordinator.applyColumnGeometry(from: newValue, toTabId: tabId) Task { @MainActor in coordinator.isUpdatingColumnLayout = false } @@ -764,7 +778,8 @@ struct MainEditorContentView: View { all: coordinator.columnsForVisibilityPicker(for: tab, resultColumns: resolvedRows.columns), onToggle: { coordinator.toggleColumnVisibility($0) }, onShowAll: { coordinator.showAllColumns() }, - onHideAll: { coordinator.hideAllColumns($0) } + onHideAll: { coordinator.hideAllColumns($0) }, + onReset: { coordinator.resetColumns() } ), structureState: StatusBarStructureState( footer: coordinator.structureFooterState, diff --git a/TablePro/Views/Main/Child/MainStatusBarView.swift b/TablePro/Views/Main/Child/MainStatusBarView.swift index b6e17f14c..678e9cbf2 100644 --- a/TablePro/Views/Main/Child/MainStatusBarView.swift +++ b/TablePro/Views/Main/Child/MainStatusBarView.swift @@ -23,6 +23,7 @@ struct StatusBarColumnState { let onToggle: (String) -> Void let onShowAll: () -> Void let onHideAll: ([String]) -> Void + let onReset: () -> Void } struct StatusBarStructureState { @@ -187,7 +188,8 @@ struct MainStatusBarView: View { hiddenColumns: columnState.hidden, onToggleColumn: columnState.onToggle, onShowAll: columnState.onShowAll, - onHideAll: columnState.onHideAll + onHideAll: columnState.onHideAll, + onReset: columnState.onReset ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 9315d4098..e379c9c41 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -53,14 +53,45 @@ extension MainContentCoordinator { mutateSelectedTabHiddenColumns { $0 = pruned } } - func restoreLastHiddenColumnsForTable(_ tableName: String) { - let restored = ColumnVisibilityPersistence.loadHiddenColumns( - for: tableName, - connectionId: connectionId - ) + func restoreLastHiddenColumnsForTable() { + guard let tab = tabManager.selectedTab, let key = columnLayoutTableKey(for: tab) else { return } + let restored = ColumnVisibilityPersistence.loadHiddenColumns(for: key) mutateSelectedTabHiddenColumns(persist: false) { $0 = restored } } + func applyColumnGeometry(from geometry: ColumnLayoutState, toTabId tabId: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabManager.mutate(at: index) { $0.columnLayout.applyGeometry(from: geometry) } + tabSessionRegistry.session(for: tabId)?.columnLayout.applyGeometry(from: geometry) + } + + func clearColumnLayoutForSelectedTable() { + guard let tab = tabManager.selectedTab, let key = columnLayoutTableKey(for: tab) else { return } + FileColumnLayoutPersister.shared.clear(for: key) + } + + func resetColumns() { + guard let index = tabManager.selectedTabIndex else { return } + let tab = tabManager.tabs[index] + if let key = columnLayoutTableKey(for: tab) { + FileColumnLayoutPersister.shared.clear(for: key) + ColumnVisibilityPersistence.saveHiddenColumns([], for: key) + } + tabManager.mutate(at: index) { $0.columnLayout = ColumnLayoutState() } + tabSessionRegistry.session(for: tab.id)?.columnLayout = ColumnLayoutState() + requeryWithColumnScope(debounced: false) + } + + private func columnLayoutTableKey(for tab: QueryTab) -> ColumnLayoutTableKey? { + guard let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return nil } + return ColumnLayoutTableKey( + connectionId: connectionId, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + tableName: tableName + ) + } + func rebuildSelectedTableQueryForHiddenColumnsIfNeeded() async { guard let tab = tabManager.selectedTab, !tab.columnLayout.hiddenColumns.isEmpty, @@ -70,14 +101,8 @@ extension MainContentCoordinator { } private func persistTabHiddenColumns(_ tab: QueryTab) { - guard tab.tabType == .table, - let tableName = tab.tableContext.tableName, - !tableName.isEmpty else { return } - ColumnVisibilityPersistence.saveHiddenColumns( - tab.columnLayout.hiddenColumns, - for: tableName, - connectionId: connectionId - ) + guard tab.tabType == .table, let key = columnLayoutTableKey(for: tab) else { return } + ColumnVisibilityPersistence.saveHiddenColumns(tab.columnLayout.hiddenColumns, for: key) } private func mutateSelectedTabHiddenColumns(persist: Bool = true, _ mutate: (inout Set) -> Void) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index b9c5a2751..6f05e0f5e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -128,7 +128,7 @@ extension MainContentCoordinator { tabManager.mutate(at: tabIndex) { $0.pagination.reset() } toolbarState.isTableTab = true } - restoreLastHiddenColumnsForTable(tableName) + restoreLastHiddenColumnsForTable() restoreFiltersForTable(tableName) if let dbIndex = Int(currentDatabase) { selectRedisDatabaseAndQuery(dbIndex) @@ -249,7 +249,7 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - restoreLastHiddenColumnsForTable(tableName) + restoreLastHiddenColumnsForTable() restoreFiltersForTable(tableName) if isInPlace, let dbIndex = Int(currentDatabase) { selectRedisDatabaseAndQuery(dbIndex) @@ -291,7 +291,7 @@ extension MainContentCoordinator { } toolbarState.isTableTab = true } - restoreLastHiddenColumnsForTable(tableName) + restoreLastHiddenColumnsForTable() restoreFiltersForTable(tableName) if let tabId = tabManager.selectedTab?.id { cancelTableLoad(for: tabId) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index f3011a3b8..ef9f9d86b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -8,6 +8,11 @@ import os import TableProPluginKit extension MainContentCoordinator { + func fixErrorWithAI(query: String, error: String) { + showAIChatPanel() + aiViewModel?.handleFixError(query: query, error: error) + } + func switchDatabaseBeforeExecution(to database: String, connectionId: UUID) async { do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: false) @@ -105,15 +110,13 @@ extension MainContentCoordinator { _ error: Error, sql: String, tabId: UUID, - connection conn: DatabaseConnection, - trigger: TableLoadTrigger = .userInitiated + connection conn: DatabaseConnection ) { queryExecutionCoordinator.handleQueryExecutionError( error, sql: sql, tabId: tabId, - connection: conn, - trigger: trigger + connection: conn ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index feffa5538..cc7db39c7 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -44,7 +44,7 @@ extension MainContentView { selectedTab.tabType == .table, let tableName = selectedTab.tableContext.tableName { - coordinator.restoreLastHiddenColumnsForTable(tableName) + coordinator.restoreLastHiddenColumnsForTable() if selectedTab.filterState.appliedFilters.isEmpty { coordinator.restoreFiltersForTable(tableName) } else if let tabIndex = tabManager.selectedTabIndex { @@ -182,7 +182,7 @@ extension MainContentView { if selected.tabType == .table, let tableName = selected.tableContext.tableName, !selected.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - coordinator.restoreLastHiddenColumnsForTable(tableName) + coordinator.restoreLastHiddenColumnsForTable() coordinator.restoreFiltersForTable(tableName) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index c3bef1c33..ac1a5bc65 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1292,7 +1292,7 @@ final class MainContentCoordinator { pendingLoadTrigger = trigger return } - handleQueryExecutionError(error, sql: plan.executedSQL, tabId: tabId, connection: conn, trigger: trigger) + handleQueryExecutionError(error, sql: plan.executedSQL, tabId: tabId, connection: conn) } } } diff --git a/TablePro/Views/Results/ColumnVisibilityPopover.swift b/TablePro/Views/Results/ColumnVisibilityPopover.swift index 323654da1..f837bedfa 100644 --- a/TablePro/Views/Results/ColumnVisibilityPopover.swift +++ b/TablePro/Views/Results/ColumnVisibilityPopover.swift @@ -11,6 +11,7 @@ struct ColumnVisibilityPopover: View { let onToggleColumn: (String) -> Void let onShowAll: () -> Void let onHideAll: ([String]) -> Void + let onReset: () -> Void @State private var searchText = "" @@ -33,10 +34,26 @@ struct ColumnVisibilityPopover: View { } columnList + + Divider() + + footer } .frame(width: 260) } + private var footer: some View { + HStack { + Spacer() + Button("Reset Columns") { onReset() } + .buttonStyle(.link) + .controlSize(.small) + .help(String(localized: "Reset column widths, order, and visibility to defaults")) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + private var headerTitle: String { guard !hiddenColumns.isEmpty else { return String(localized: "Columns") diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 1b5230b13..62bccb60c 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -34,6 +34,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var connectionId: UUID? var databaseType: DatabaseType? var tableName: String? + var databaseName: String? + var schemaName: String? var primaryKeyColumns: [String] = [] var primaryKeyColumn: String? { primaryKeyColumns.first } var tabType: TabType? @@ -78,6 +80,16 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData .compactMap { dataColumnIndex(from: $0.identifier) } } + var columnLayoutKey: ColumnLayoutTableKey? { + guard let connectionId, let tableName, !tableName.isEmpty else { return nil } + return ColumnLayoutTableKey( + connectionId: connectionId, + databaseName: databaseName ?? "", + schemaName: schemaName, + tableName: tableName + ) + } + func savedColumnLayout(binding: ColumnLayoutState) -> ColumnLayoutState? { guard tabType == .table else { guard !binding.columnWidths.isEmpty else { return nil } @@ -86,10 +98,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return layout } - if let connectionId, - let tableName, - !tableName.isEmpty, - let stored = layoutPersister.load(for: tableName, connectionId: connectionId) { + if let columnLayoutKey, let stored = layoutPersister.load(for: columnLayoutKey) { return stored } if binding.columnWidths.isEmpty && binding.columnOrder == nil { @@ -98,6 +107,32 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData return binding } + func resolvedColumnLayout(binding: ColumnLayoutState, liveWidths: [String: CGFloat]) -> ColumnLayoutState? { + let saved = savedColumnLayout(binding: binding) + guard let saved, !liveWidths.isEmpty else { return saved } + return saved.mergingWidths(liveWidths) + } + + static func liveWidthsForSameTable( + previous: ColumnLayoutTableKey?, + current: ColumnLayoutTableKey?, + liveWidths: [String: CGFloat] + ) -> [String: CGFloat] { + previous == current ? 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() @@ -125,8 +160,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard let layout = captureColumnLayout() else { return } onColumnLayoutDidChange?(layout) - if tabType == .table, let connectionId, let tableName, !tableName.isEmpty { - layoutPersister.save(layout, for: tableName, connectionId: connectionId) + if tabType == .table, let columnLayoutKey { + layoutPersister.save(layout, for: columnLayoutKey) } } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e29267c74..86838c65f 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -128,6 +128,8 @@ struct DataGridView: NSViewRepresentable { context.coordinator.connectionId = configuration.connectionId context.coordinator.databaseType = configuration.databaseType context.coordinator.tableName = configuration.tableName + context.coordinator.databaseName = configuration.databaseName + context.coordinator.schemaName = configuration.schemaName context.coordinator.primaryKeyColumns = configuration.primaryKeyColumns context.coordinator.tabType = configuration.tabType @@ -235,6 +237,8 @@ struct DataGridView: NSViewRepresentable { let oldColumnCount = coordinator.cachedColumnCount let structureChanged = oldRowCount != rowDisplayCount || oldColumnCount != columnCount + let previousColumnKey = coordinator.columnLayoutKey + let liveColumnWidths = latestRows.columns.isEmpty ? [:] : coordinator.currentColumnWidths() let schemaChanged = coordinator.rebuildColumnMetadataCache(from: latestRows) let needsFullReload = structureChanged || schemaChanged || contentChanged if contentChanged { @@ -258,6 +262,8 @@ struct DataGridView: NSViewRepresentable { coordinator.connectionId = configuration.connectionId coordinator.databaseType = configuration.databaseType coordinator.tableName = configuration.tableName + coordinator.databaseName = configuration.databaseName + coordinator.schemaName = configuration.schemaName coordinator.primaryKeyColumns = configuration.primaryKeyColumns coordinator.tabType = configuration.tabType @@ -267,7 +273,12 @@ struct DataGridView: NSViewRepresentable { if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true - let savedLayout = coordinator.savedColumnLayout(binding: columnLayout) + let sameTableLiveWidths = TableViewCoordinator.liveWidthsForSameTable( + previous: previousColumnKey, + current: coordinator.columnLayoutKey, + liveWidths: liveColumnWidths + ) + let savedLayout = coordinator.resolvedColumnLayout(binding: columnLayout, liveWidths: sameTableLiveWidths) reconcileColumnPool( tableView: tableView, coordinator: coordinator, @@ -435,7 +446,7 @@ struct DataGridView: NSViewRepresentable { isEditable: isEditable, selectedRowIndices: $selectedRowIndices, delegate: delegate, - layoutPersister: layoutPersister ?? FileColumnLayoutPersister() + layoutPersister: layoutPersister ?? FileColumnLayoutPersister.shared ) let columnLayoutBinding = $columnLayout coordinator.onColumnLayoutDidChange = { layout in 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/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index bfeb04008..39d31f913 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -52,7 +52,6 @@ struct TableStructureView: View { @State var selectedRows: Set = [] @State var sortState = SortState() @State var structureColumnLayouts: [StructureTab: ColumnLayoutState] = [:] - @State var columnLayoutPersister: any ColumnLayoutPersisting = FileColumnLayoutPersister() @State var actionHandler = StructureViewActionHandler() @State var gridDelegate: StructureGridDelegate @State private var footerOwnerId = UUID() @@ -380,7 +379,7 @@ struct TableStructureView: View { await loadColumns() loadSchemaForEditing() isReloadingAfterSave = false - columnLayoutPersister.clear(for: tableName, connectionId: connection.id) + coordinator?.clearColumnLayoutForSelectedTable() AppCommands.shared.refreshData.send(connection.id) } catch { AlertHelper.showErrorSheet( @@ -423,7 +422,6 @@ struct TableStructureView: View { databaseType: connection.type ), delegate: gridDelegate, - layoutPersister: columnLayoutPersister, selectedRowIndices: $selectedRows, sortState: $sortState, columnLayout: columnLayoutBinding(for: selectedTab) diff --git a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift index 24b28867b..c366b6c3e 100644 --- a/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift +++ b/TableProTests/Core/Storage/ColumnVisibilityPersistenceTests.swift @@ -19,33 +19,34 @@ struct ColumnVisibilityPersistenceTests { return defaults } + private func makeKey( + table: String, + connectionId: UUID = UUID(), + database: String = "app", + schema: String? = "public" + ) -> ColumnLayoutTableKey { + ColumnLayoutTableKey( + connectionId: connectionId, + databaseName: database, + schemaName: schema, + tableName: table + ) + } + @Test("loadHiddenColumns returns an empty set when no value is stored") func loadReturnsEmptyByDefault() { let defaults = makeDefaults() - let result = ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: UUID(), - defaults: defaults - ) + let result = ColumnVisibilityPersistence.loadHiddenColumns(for: makeKey(table: "users"), defaults: defaults) #expect(result.isEmpty) } @Test("saveHiddenColumns then loadHiddenColumns round-trips the set") func roundTripsAcrossSaveAndLoad() { let defaults = makeDefaults() - let connectionId = UUID() - ColumnVisibilityPersistence.saveHiddenColumns( - ["email", "phone"], - for: "users", - connectionId: connectionId, - defaults: defaults - ) + let key = makeKey(table: "users") + ColumnVisibilityPersistence.saveHiddenColumns(["email", "phone"], for: key, defaults: defaults) - let result = ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: connectionId, - defaults: defaults - ) + let result = ColumnVisibilityPersistence.loadHiddenColumns(for: key, defaults: defaults) #expect(result == ["email", "phone"]) } @@ -53,98 +54,73 @@ struct ColumnVisibilityPersistenceTests { func tablesAreScopedSeparately() { let defaults = makeDefaults() let connectionId = UUID() - ColumnVisibilityPersistence.saveHiddenColumns( - ["a"], - for: "users", - connectionId: connectionId, - defaults: defaults - ) - ColumnVisibilityPersistence.saveHiddenColumns( - ["b"], - for: "orders", - connectionId: connectionId, - defaults: defaults - ) + let users = makeKey(table: "users", connectionId: connectionId) + let orders = makeKey(table: "orders", connectionId: connectionId) + ColumnVisibilityPersistence.saveHiddenColumns(["a"], for: users, defaults: defaults) + ColumnVisibilityPersistence.saveHiddenColumns(["b"], for: orders, defaults: defaults) - #expect( - ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: connectionId, - defaults: defaults - ) == ["a"] - ) - #expect( - ColumnVisibilityPersistence.loadHiddenColumns( - for: "orders", - connectionId: connectionId, - defaults: defaults - ) == ["b"] - ) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: users, defaults: defaults) == ["a"]) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: orders, defaults: defaults) == ["b"]) } @Test("Different connections store independent sets for the same table name") func connectionsAreScopedSeparately() { let defaults = makeDefaults() - let connectionA = UUID() - let connectionB = UUID() - ColumnVisibilityPersistence.saveHiddenColumns( - ["x"], - for: "users", - connectionId: connectionA, - defaults: defaults - ) - ColumnVisibilityPersistence.saveHiddenColumns( - ["y"], - for: "users", - connectionId: connectionB, - defaults: defaults - ) + let a = makeKey(table: "users", connectionId: UUID()) + let b = makeKey(table: "users", connectionId: UUID()) + ColumnVisibilityPersistence.saveHiddenColumns(["x"], for: a, defaults: defaults) + ColumnVisibilityPersistence.saveHiddenColumns(["y"], for: b, defaults: defaults) - #expect( - ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: connectionA, - defaults: defaults - ) == ["x"] - ) - #expect( - ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: connectionB, - defaults: defaults - ) == ["y"] - ) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: a, defaults: defaults) == ["x"]) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: b, defaults: defaults) == ["y"]) } - @Test("saveHiddenColumns with an empty set persists as an empty array") - func savingEmptySetClearsState() { + @Test("Same table name in different databases does not collide") + func databasesAreScopedSeparately() { let defaults = makeDefaults() let connectionId = UUID() - ColumnVisibilityPersistence.saveHiddenColumns( - ["leftover"], - for: "users", - connectionId: connectionId, - defaults: defaults - ) - ColumnVisibilityPersistence.saveHiddenColumns( - [], - for: "users", - connectionId: connectionId, - defaults: defaults - ) + let sales = makeKey(table: "users", connectionId: connectionId, database: "sales") + let hr = makeKey(table: "users", connectionId: connectionId, database: "hr") + ColumnVisibilityPersistence.saveHiddenColumns(["salary"], for: sales, defaults: defaults) + ColumnVisibilityPersistence.saveHiddenColumns(["ssn"], for: hr, defaults: defaults) - let result = ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: connectionId, - defaults: defaults - ) - #expect(result.isEmpty) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: sales, defaults: defaults) == ["salary"]) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: hr, defaults: defaults) == ["ssn"]) + } + + @Test("Same table name in different schemas does not collide") + func schemasAreScopedSeparately() { + let defaults = makeDefaults() + let connectionId = UUID() + let publicUsers = makeKey(table: "users", connectionId: connectionId, schema: "public") + let authUsers = makeKey(table: "users", connectionId: connectionId, schema: "auth") + ColumnVisibilityPersistence.saveHiddenColumns(["a"], for: publicUsers, defaults: defaults) + ColumnVisibilityPersistence.saveHiddenColumns(["b"], for: authUsers, defaults: defaults) + + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: publicUsers, defaults: defaults) == ["a"]) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: authUsers, defaults: defaults) == ["b"]) + } + + @Test("Saving an empty set clears stored state") + func savingEmptySetClearsState() { + let defaults = makeDefaults() + let key = makeKey(table: "users") + ColumnVisibilityPersistence.saveHiddenColumns(["leftover"], for: key, defaults: defaults) + ColumnVisibilityPersistence.saveHiddenColumns([], for: key, defaults: defaults) + + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: key, defaults: defaults).isEmpty) } - @Test("Storage key encodes connection id and table name") + @Test("Storage key carries the hidden-columns prefix and separates scopes") func keyFormat() { let connectionId = UUID() - let key = ColumnVisibilityPersistence.key(tableName: "users", connectionId: connectionId) - #expect(key == "com.TablePro.columns.hiddenColumns.\(connectionId.uuidString).users") + let publicKey = ColumnVisibilityPersistence.key( + for: makeKey(table: "users", connectionId: connectionId, database: "app", schema: "public") + ) + let authKey = ColumnVisibilityPersistence.key( + for: makeKey(table: "users", connectionId: connectionId, database: "app", schema: "auth") + ) + #expect(publicKey.hasPrefix("com.TablePro.columns.hiddenColumns.")) + #expect(publicKey != authKey) } } diff --git a/TableProTests/Core/Storage/CompositeStorageKeyTests.swift b/TableProTests/Core/Storage/CompositeStorageKeyTests.swift new file mode 100644 index 000000000..38b539075 --- /dev/null +++ b/TableProTests/Core/Storage/CompositeStorageKeyTests.swift @@ -0,0 +1,47 @@ +// +// CompositeStorageKeyTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("CompositeStorageKey") +struct CompositeStorageKeyTests { + @Test("Distinct database/schema/table scopes produce distinct keys") + func distinctScopesDiffer() { + let connectionId = UUID() + let publicUsers = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "app", schemaName: "public", tableName: "users" + ) + let authUsers = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "app", schemaName: "auth", tableName: "users" + ) + #expect(publicUsers != authUsers) + } + + @Test("Dots in component names do not collide because components are percent-encoded") + func dotsDoNotCollide() { + let connectionId = UUID() + let dbWithDot = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "a.b", schemaName: nil, tableName: "c" + ) + let schemaSplit = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "a", schemaName: "b", tableName: "c" + ) + #expect(dbWithDot != schemaSplit) + } + + @Test("Same scope produces a stable key") + func stableForSameScope() { + let connectionId = UUID() + let first = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "app", schemaName: "public", tableName: "users" + ) + let second = CompositeStorageKey.make( + connectionId: connectionId, databaseName: "app", schemaName: "public", tableName: "users" + ) + #expect(first == second) + } +} 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/TableLoadTriggerTests.swift b/TableProTests/Models/TableLoadTriggerTests.swift deleted file mode 100644 index 2ba9348de..000000000 --- a/TableProTests/Models/TableLoadTriggerTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// TableLoadTriggerTests.swift -// TableProTests -// - -import Testing - -@testable import TablePro - -@Suite("TableLoadTrigger") -struct TableLoadTriggerTests { - @Test("Restore trigger suppresses the failure modal") - func restoreSuppressesModal() { - #expect(TableLoadTrigger.restore.suppressesFailureModal == true) - } - - @Test("User-initiated trigger does not suppress the failure modal") - func userInitiatedShowsModal() { - #expect(TableLoadTrigger.userInitiated.suppressesFailureModal == false) - } -} diff --git a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift index e3b9c99ba..04303e065 100644 --- a/TableProTests/Storage/FileColumnLayoutPersisterTests.swift +++ b/TableProTests/Storage/FileColumnLayoutPersisterTests.swift @@ -23,6 +23,20 @@ struct FileColumnLayoutPersisterTests { try? FileManager.default.removeItem(at: directory) } + private func key( + _ table: String, + _ connectionId: UUID, + database: String = "app", + schema: String? = "public" + ) -> ColumnLayoutTableKey { + ColumnLayoutTableKey( + connectionId: connectionId, + databaseName: database, + schemaName: schema, + tableName: table + ) + } + @Test("Save then load returns the same widths and order") func roundTrip() { let (persister, dir) = makeIsolatedPersister() @@ -32,9 +46,9 @@ struct FileColumnLayoutPersisterTests { var layout = ColumnLayoutState() layout.columnWidths = ["id": 60, "name": 200, "email": 240] layout.columnOrder = ["id", "name", "email"] - persister.save(layout, for: "users", connectionId: connectionId) + persister.save(layout, for: key("users", connectionId)) - let loaded = persister.load(for: "users", connectionId: connectionId) + let loaded = persister.load(for: key("users", connectionId)) #expect(loaded?.columnWidths == layout.columnWidths) #expect(loaded?.columnOrder == layout.columnOrder) } @@ -44,7 +58,7 @@ struct FileColumnLayoutPersisterTests { let (persister, dir) = makeIsolatedPersister() defer { cleanup(dir) } - #expect(persister.load(for: "missing", connectionId: UUID()) == nil) + #expect(persister.load(for: key("missing", UUID())) == nil) } @Test("Save with empty widths is a no-op") @@ -53,8 +67,8 @@ struct FileColumnLayoutPersisterTests { defer { cleanup(dir) } let connectionId = UUID() - persister.save(ColumnLayoutState(), for: "users", connectionId: connectionId) - #expect(persister.load(for: "users", connectionId: connectionId) == nil) + persister.save(ColumnLayoutState(), for: key("users", connectionId)) + #expect(persister.load(for: key("users", connectionId)) == nil) } @Test("Multiple tables on the same connection coexist") @@ -68,11 +82,11 @@ struct FileColumnLayoutPersisterTests { var orders = ColumnLayoutState() orders.columnWidths = ["total": 120] - persister.save(users, for: "users", connectionId: connectionId) - persister.save(orders, for: "orders", connectionId: connectionId) + persister.save(users, for: key("users", connectionId)) + persister.save(orders, for: key("orders", connectionId)) - #expect(persister.load(for: "users", connectionId: connectionId)?.columnWidths == ["id": 60]) - #expect(persister.load(for: "orders", connectionId: connectionId)?.columnWidths == ["total": 120]) + #expect(persister.load(for: key("users", connectionId))?.columnWidths == ["id": 60]) + #expect(persister.load(for: key("orders", connectionId))?.columnWidths == ["total": 120]) } @Test("Clear removes only the targeted table") @@ -86,12 +100,12 @@ struct FileColumnLayoutPersisterTests { var b = ColumnLayoutState() b.columnWidths = ["y": 200] - persister.save(a, for: "a", connectionId: connectionId) - persister.save(b, for: "b", connectionId: connectionId) - persister.clear(for: "a", connectionId: connectionId) + persister.save(a, for: key("a", connectionId)) + persister.save(b, for: key("b", connectionId)) + persister.clear(for: key("a", connectionId)) - #expect(persister.load(for: "a", connectionId: connectionId) == nil) - #expect(persister.load(for: "b", connectionId: connectionId)?.columnWidths == ["y": 200]) + #expect(persister.load(for: key("a", connectionId)) == nil) + #expect(persister.load(for: key("b", connectionId))?.columnWidths == ["y": 200]) } @Test("Save survives a fresh persister instance pointed at the same directory") @@ -107,11 +121,11 @@ struct FileColumnLayoutPersisterTests { do { let persister = FileColumnLayoutPersister(storageDirectory: directory) - persister.save(layout, for: "users", connectionId: connectionId) + persister.save(layout, for: key("users", connectionId)) } let restored = FileColumnLayoutPersister(storageDirectory: directory) - .load(for: "users", connectionId: connectionId) + .load(for: key("users", connectionId)) #expect(restored?.columnWidths == layout.columnWidths) #expect(restored?.columnOrder == layout.columnOrder) } @@ -128,7 +142,7 @@ struct FileColumnLayoutPersisterTests { try Data("{not valid json".utf8).write(to: fileURL) let persister = FileColumnLayoutPersister(storageDirectory: directory) - #expect(persister.load(for: "users", connectionId: connectionId) == nil) + #expect(persister.load(for: key("users", connectionId)) == nil) } @Test("Saving over a corrupted file replaces it cleanly") @@ -145,10 +159,10 @@ struct FileColumnLayoutPersisterTests { let persister = FileColumnLayoutPersister(storageDirectory: directory) var layout = ColumnLayoutState() layout.columnWidths = ["id": 100] - persister.save(layout, for: "users", connectionId: connectionId) + persister.save(layout, for: key("users", connectionId)) let restored = FileColumnLayoutPersister(storageDirectory: directory) - .load(for: "users", connectionId: connectionId) + .load(for: key("users", connectionId)) #expect(restored?.columnWidths == ["id": 100]) } @@ -162,12 +176,12 @@ struct FileColumnLayoutPersisterTests { let connectionId = UUID() var layout = ColumnLayoutState() layout.columnWidths = ["id": 100] - persister.save(layout, for: "users", connectionId: connectionId) + persister.save(layout, for: key("users", connectionId)) let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") #expect(FileManager.default.fileExists(atPath: fileURL.path)) - persister.clear(for: "users", connectionId: connectionId) + persister.clear(for: key("users", connectionId)) #expect(!FileManager.default.fileExists(atPath: fileURL.path)) } @@ -183,14 +197,14 @@ struct FileColumnLayoutPersisterTests { users.columnWidths = ["id": 60] var orders = ColumnLayoutState() orders.columnWidths = ["total": 120] - persister.save(users, for: "users", connectionId: connectionId) - persister.save(orders, for: "orders", connectionId: connectionId) + persister.save(users, for: key("users", connectionId)) + persister.save(orders, for: key("orders", connectionId)) - persister.clear(for: "users", connectionId: connectionId) + persister.clear(for: key("users", connectionId)) let fresh = FileColumnLayoutPersister(storageDirectory: directory) - #expect(fresh.load(for: "users", connectionId: connectionId) == nil) - #expect(fresh.load(for: "orders", connectionId: connectionId)?.columnWidths == ["total": 120]) + #expect(fresh.load(for: key("users", connectionId)) == nil) + #expect(fresh.load(for: key("orders", connectionId))?.columnWidths == ["total": 120]) } @Test("Clearing a missing entry is a no-op and never creates a file") @@ -201,7 +215,7 @@ struct FileColumnLayoutPersisterTests { let persister = FileColumnLayoutPersister(storageDirectory: directory) let connectionId = UUID() - persister.clear(for: "missing", connectionId: connectionId) + persister.clear(for: key("missing", connectionId)) let fileURL = directory.appendingPathComponent("\(connectionId.uuidString).json") #expect(!FileManager.default.fileExists(atPath: fileURL.path)) @@ -219,11 +233,33 @@ struct FileColumnLayoutPersisterTests { var layoutB = ColumnLayoutState() layoutB.columnWidths = ["id": 200] - persister.save(layoutA, for: "users", connectionId: connectionA) - persister.save(layoutB, for: "users", connectionId: connectionB) + persister.save(layoutA, for: key("users", connectionA)) + persister.save(layoutB, for: key("users", connectionB)) + + #expect(persister.load(for: key("users", connectionA))?.columnWidths == ["id": 60]) + #expect(persister.load(for: key("users", connectionB))?.columnWidths == ["id": 200]) + } + + @Test("Same table name in different databases and schemas do not collide") + func sameTableNameAcrossDatabasesAndSchemasAreIsolated() { + let (persister, dir) = makeIsolatedPersister() + defer { cleanup(dir) } - #expect(persister.load(for: "users", connectionId: connectionA)?.columnWidths == ["id": 60]) - #expect(persister.load(for: "users", connectionId: connectionB)?.columnWidths == ["id": 200]) + let connectionId = UUID() + var sales = ColumnLayoutState() + sales.columnWidths = ["id": 60] + var hr = ColumnLayoutState() + hr.columnWidths = ["id": 200] + var authSchema = ColumnLayoutState() + authSchema.columnWidths = ["id": 320] + + persister.save(sales, for: key("users", connectionId, database: "sales", schema: "public")) + persister.save(hr, for: key("users", connectionId, database: "hr", schema: "public")) + persister.save(authSchema, for: key("users", connectionId, database: "sales", schema: "auth")) + + #expect(persister.load(for: key("users", connectionId, database: "sales", schema: "public"))?.columnWidths == ["id": 60]) + #expect(persister.load(for: key("users", connectionId, database: "hr", schema: "public"))?.columnWidths == ["id": 200]) + #expect(persister.load(for: key("users", connectionId, database: "sales", schema: "auth"))?.columnWidths == ["id": 320]) } @Test("Saving overwrites an existing entry instead of merging") @@ -235,14 +271,14 @@ struct FileColumnLayoutPersisterTests { var first = ColumnLayoutState() first.columnWidths = ["id": 60, "name": 200] first.columnOrder = ["id", "name"] - persister.save(first, for: "users", connectionId: connectionId) + persister.save(first, for: key("users", connectionId)) var second = ColumnLayoutState() second.columnWidths = ["email": 240] second.columnOrder = ["email"] - persister.save(second, for: "users", connectionId: connectionId) + persister.save(second, for: key("users", connectionId)) - let restored = persister.load(for: "users", connectionId: connectionId) + let restored = persister.load(for: key("users", connectionId)) #expect(restored?.columnWidths == ["email": 240]) #expect(restored?.columnOrder == ["email"]) } @@ -256,9 +292,9 @@ struct FileColumnLayoutPersisterTests { var layout = ColumnLayoutState() layout.columnWidths = ["id": 60] layout.columnOrder = nil - persister.save(layout, for: "users", connectionId: connectionId) + persister.save(layout, for: key("users", connectionId)) - let restored = persister.load(for: "users", connectionId: connectionId) + let restored = persister.load(for: key("users", connectionId)) #expect(restored?.columnOrder == nil) #expect(restored?.columnWidths == ["id": 60]) } @@ -275,6 +311,6 @@ struct FileColumnLayoutPersisterTests { try Data("{}".utf8).write(to: fileURL) let persister = FileColumnLayoutPersister(storageDirectory: directory) - #expect(persister.load(for: "anything", connectionId: connectionId) == nil) + #expect(persister.load(for: key("anything", connectionId)) == nil) } } 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) + } } diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index 246f90bc9..ca37c029c 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -58,19 +58,46 @@ struct CoordinatorColumnVisibilityTests { func hideColumnPersistsForReopen() { let (coordinator, tabManager) = makeCoordinator() _ = addTableTab(to: tabManager, tableName: "users") - let storageKey = ColumnVisibilityPersistence.key( - tableName: "users", - connectionId: coordinator.connectionId + guard let tab = tabManager.selectedTab else { + Issue.record("Expected selected tab") + return + } + let key = ColumnLayoutTableKey( + connectionId: coordinator.connectionId, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName, + tableName: "users" ) - defer { UserDefaults.standard.removeObject(forKey: storageKey) } + defer { UserDefaults.standard.removeObject(forKey: ColumnVisibilityPersistence.key(for: key)) } coordinator.hideColumn("email") - let persisted = ColumnVisibilityPersistence.loadHiddenColumns( - for: "users", - connectionId: coordinator.connectionId - ) - #expect(persisted == ["email"]) + #expect(ColumnVisibilityPersistence.loadHiddenColumns(for: key) == ["email"]) + } + + @Test("Applying column geometry after hiding columns keeps the hidden set and syncs the session") + func applyColumnGeometryPreservesHiddenColumns() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + coordinator.hideColumn("email") + + var geometry = ColumnLayoutState() + geometry.columnWidths = ["id": 80, "name": 220] + geometry.columnOrder = ["id", "name"] + coordinator.applyColumnGeometry(from: geometry, toTabId: tabId) + + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + let layout = tabManager.tabs[index].columnLayout + #expect(layout.hiddenColumns == ["email"]) + #expect(layout.columnWidths == ["id": 80, "name": 220]) + #expect(layout.columnOrder == ["id", "name"]) + + let session = coordinator.tabSessionRegistry.session(for: tabId) + #expect(session?.columnLayout.hiddenColumns == ["email"]) + #expect(session?.columnLayout.columnWidths == ["id": 80, "name": 220]) } @Test("showColumn removes from the active tab's hidden set") @@ -163,27 +190,29 @@ struct CoordinatorColumnVisibilityTests { ) let state = SessionStateFactory.create(connection: connection, payload: payload) let coordinator = state.coordinator - let storageKey = ColumnVisibilityPersistence.key( - tableName: "users", - connectionId: connection.id + guard let createdTab = state.tabManager.selectedTab else { + Issue.record("Expected payload-created table tab") + return + } + let key = ColumnLayoutTableKey( + connectionId: connection.id, + databaseName: createdTab.tableContext.databaseName, + schemaName: createdTab.tableContext.schemaName, + tableName: "users" ) defer { - UserDefaults.standard.removeObject(forKey: storageKey) + UserDefaults.standard.removeObject(forKey: ColumnVisibilityPersistence.key(for: key)) coordinator.teardown() } - ColumnVisibilityPersistence.saveHiddenColumns( - ["email"], - for: "users", - connectionId: connection.id - ) + ColumnVisibilityPersistence.saveHiddenColumns(["email"], for: key) coordinator.schemaColumns.store( (columns: ["id", "name", "email"], primaryKeys: ["id"]), for: coordinator.schemaColumnsKey("users", schema: nil) ) - coordinator.restoreLastHiddenColumnsForTable("users") + coordinator.restoreLastHiddenColumnsForTable() await coordinator.rebuildSelectedTableQueryForHiddenColumnsIfNeeded() guard let tab = state.tabManager.selectedTab else { diff --git a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift index 057c55045..0821a36e2 100644 --- a/TableProTests/Views/Main/MainStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/MainStatusBarLayoutTests.swift @@ -34,7 +34,8 @@ struct MainStatusBarLayoutTests { all: [], onToggle: { _ in }, onShowAll: {}, - onHideAll: { _ in } + onHideAll: { _ in }, + onReset: {} ), structureState: StatusBarStructureState( footer: StructureFooterState(), diff --git a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift index 55a51c7ee..bf9472ef9 100644 --- a/TableProTests/Views/Results/DataGridRowViewCopyTests.swift +++ b/TableProTests/Views/Results/DataGridRowViewCopyTests.swift @@ -21,9 +21,9 @@ private final class DataGridRowViewCopyClipboard: ClipboardProvider { @MainActor private final class DataGridRowViewCopyLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @MainActor diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift index 11f330cc0..11a8fbce2 100644 --- a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -17,9 +17,9 @@ import Testing @MainActor private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @MainActor diff --git a/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift b/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift index d37bf5e7b..c01e08f48 100644 --- a/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift +++ b/TableProTests/Views/Results/Extensions/ColumnIndexCacheTests.swift @@ -6,9 +6,9 @@ import Testing @MainActor private final class StubColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @Suite("TableViewCoordinator column index cache") diff --git a/TableProTests/Views/Results/Extensions/DataGridSelectionTests.swift b/TableProTests/Views/Results/Extensions/DataGridSelectionTests.swift index 2cc21aaf9..809a0881d 100644 --- a/TableProTests/Views/Results/Extensions/DataGridSelectionTests.swift +++ b/TableProTests/Views/Results/Extensions/DataGridSelectionTests.swift @@ -6,9 +6,9 @@ import Testing @MainActor private final class FakeColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @MainActor diff --git a/TableProTests/Views/Results/Extensions/FillColumnTests.swift b/TableProTests/Views/Results/Extensions/FillColumnTests.swift index e1f982407..35d0fcba7 100644 --- a/TableProTests/Views/Results/Extensions/FillColumnTests.swift +++ b/TableProTests/Views/Results/Extensions/FillColumnTests.swift @@ -17,9 +17,9 @@ import Testing @MainActor private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @Suite("Fill Column") diff --git a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift index 20346a515..de1e091d5 100644 --- a/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift +++ b/TableProTests/Views/Results/Extensions/InlineEditEligibilityTests.swift @@ -11,9 +11,9 @@ import Testing @MainActor private final class StubColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @Suite("Inline edit eligibility") diff --git a/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift b/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift index 273009ff5..56058db3d 100644 --- a/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift +++ b/TableProTests/Views/Results/KeyHandlingTableViewOverlayTests.swift @@ -13,9 +13,9 @@ import Testing @MainActor private final class StubColumnLayoutPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} - func clear(for tableName: String, connectionId: UUID) {} + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} } @Suite("KeyHandlingTableView overlay raise") diff --git a/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift b/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift index 3d9dc948f..2d3decbb4 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorLayoutTests.swift @@ -14,16 +14,16 @@ import Testing private final class FakeColumnLayoutPersister: ColumnLayoutPersisting { var stored: [String: ColumnLayoutState] = [:] - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { - stored[tableName] + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { + stored[key.tableName] } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) { - stored[tableName] = layout + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) { + stored[key.tableName] = layout } - func clear(for tableName: String, connectionId: UUID) { - stored.removeValue(forKey: tableName) + func clear(for key: ColumnLayoutTableKey) { + stored.removeValue(forKey: key.tableName) } } @@ -158,4 +158,50 @@ struct TableViewCoordinatorLayoutTests { let resolved = coordinator.savedColumnLayout(binding: binding) #expect(resolved?.columnWidths == ["fallback": 42]) } + + @Test("resolvedColumnLayout merges live widths on top of a saved layout") + func resolvedMergesLiveWidthsOntoSaved() { + let persister = FakeColumnLayoutPersister() + var saved = ColumnLayoutState() + saved.columnWidths = ["id": 60, "name": 100] + persister.stored["users"] = saved + let coordinator = makeCoordinator( + tabType: .table, + connectionId: UUID(), + tableName: "users", + persister: persister + ) + + let resolved = coordinator.resolvedColumnLayout( + binding: ColumnLayoutState(), + liveWidths: ["name": 250] + ) + #expect(resolved?.columnWidths == ["id": 60, "name": 250]) + } + + @Test("resolvedColumnLayout returns nil with no saved layout so widths recompute after a reset") + func resolvedReturnsNilWhenNothingSaved() { + let coordinator = makeCoordinator( + tabType: .table, + connectionId: UUID(), + tableName: "users", + persister: FakeColumnLayoutPersister() + ) + #expect( + coordinator.resolvedColumnLayout(binding: ColumnLayoutState(), liveWidths: ["name": 250]) == nil + ) + } + + @Test("Live widths are kept on a same-table reload but discarded on a table switch") + func liveWidthsGatedByTableIdentity() { + let connectionId = UUID() + let tableA = ColumnLayoutTableKey(connectionId: connectionId, databaseName: "db", schemaName: "public", tableName: "a") + let tableB = ColumnLayoutTableKey(connectionId: connectionId, databaseName: "db", schemaName: "public", tableName: "b") + let live: [String: CGFloat] = ["id": 120, "name": 240] + + #expect(TableViewCoordinator.liveWidthsForSameTable(previous: tableA, current: tableA, liveWidths: live) == live) + #expect(TableViewCoordinator.liveWidthsForSameTable(previous: tableA, current: tableB, liveWidths: live).isEmpty) + #expect(TableViewCoordinator.liveWidthsForSameTable(previous: nil, current: tableA, liveWidths: live).isEmpty) + #expect(TableViewCoordinator.liveWidthsForSameTable(previous: nil, current: nil, liveWidths: live) == live) + } } diff --git a/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift b/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift index c34fad040..d2a910e24 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorRowCountCacheTests.swift @@ -149,9 +149,9 @@ struct TableViewCoordinatorRowCountCacheTests { @MainActor private final class FakeRowCountPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} - func clear(for tableName: String, connectionId: UUID) {} + func clear(for key: ColumnLayoutTableKey) {} } diff --git a/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift index 9b17a404d..20aadcf9c 100644 --- a/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift +++ b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift @@ -190,9 +190,9 @@ struct TableViewCoordinatorValueFilterTests { @MainActor private final class FakeValueFilterPersister: ColumnLayoutPersisting { - func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } - func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} - func clear(for tableName: String, connectionId: UUID) {} + func clear(for key: ColumnLayoutTableKey) {} } diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 90f71eae9..44ab44a84 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -50,10 +50,12 @@ This filter runs on the rows already loaded, so it covers the current page only. ### Resize and Hide -Drag a column border to resize, or double-click it to fit the content. Widths are saved per table. +Drag a column border to resize, or double-click it to fit the content. Dragging the border only resizes; it never triggers a sort. Hide columns from the columns button in the status bar or the header menu. A hidden column isn't fetched, so hiding a large column makes the table load faster. The primary key is always fetched, so editing still works. +Column widths, order, and which columns are hidden are remembered per table and restored the next time you open it. Each table keeps its own layout per connection, database, and schema, so two tables with the same name in different databases don't share one. Click **Reset Columns** in the columns popover to put everything back to defaults. + ### Display Format UUIDs and Unix timestamps render in a readable form when the column type and name match (for example a `BINARY(16)` column named `uuid`, or a `BIGINT` named `created_at`). Right-click a header and choose **Display As** to set the format per column. Toggle the automatic behavior in **Settings > Editor > Smart value detection**. diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index d0e600dab..d5f0a5c88 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -33,7 +33,7 @@ created_at > NOW() - INTERVAL 7 DAY price * quantity > 1000 ``` -As you type, autocomplete suggests the table's columns and SQL keywords at every position in the expression, including after AND and OR. Use the arrow keys to pick a suggestion and Tab or Return to insert it. +As you type, autocomplete suggests the table's columns and SQL keywords at every position in the expression, including after AND and OR. Use the arrow keys to pick a suggestion and Tab or Return to insert it. Suggestions only appear once you start a word, not on an empty field or after a space. Press Escape to dismiss the suggestions; the filter bar stays open. To switch a row to column mode, select a column from the picker.