From e5953f369a45a316c6deb147cfac9e6d0133ade7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 02:15:03 +0700 Subject: [PATCH 1/2] fix(editor): make Format SQL undoable and sort query results server-side without overwriting the editor (#1645) --- CHANGELOG.md | 2 + .../QueryExecutionCoordinator+Helpers.swift | 2 +- .../Coordinators/RowEditingCoordinator.swift | 6 - TablePro/Models/Query/QueryTabState.swift | 2 + TablePro/Views/Editor/EditorEventRouter.swift | 5 + TablePro/Views/Editor/QueryEditorView.swift | 33 +--- .../Views/Editor/SQLEditorCoordinator.swift | 29 ++- TablePro/Views/Editor/SQLEditorView.swift | 2 - .../Main/Child/MainEditorContentView.swift | 64 +------ .../Main/MainContentCommandActions.swift | 18 +- .../Views/Main/MainContentCoordinator.swift | 167 ++---------------- .../Models/PaginationStateTests.swift | 14 ++ .../MainContentCoordinatorSortTests.swift | 84 ++++++--- .../Main/SortCacheInvalidationTests.swift | 97 ---------- 14 files changed, 127 insertions(+), 398 deletions(-) delete mode 100644 TableProTests/Views/Main/SortCacheInvalidationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 48de8594a..ed3e62007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) +- Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645) - iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads. - Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633) - Refreshing a table now reloads its data even when the previous load is still running; before, the refresh was silently dropped and the grid kept stale rows. (#1637) diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 11f1638a8..a1e9067f1 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -146,11 +146,11 @@ extension QueryExecutionCoordinator { if isTruncated { tab.pagination.hasMoreRows = true - tab.pagination.baseQueryForMore = sql tab.pagination.isLoadingMore = false } else { tab.pagination.resetLoadMore() } + tab.pagination.baseQueryForMore = sql if tab.display.isResultsCollapsed { tab.display.isResultsCollapsed = false diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 8ab37d1bc..485f8e7d8 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -43,7 +43,6 @@ final class RowEditingCoordinator { parent.selectionState.indices = [result.rowIndex] parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } @@ -80,7 +79,6 @@ final class RowEditingCoordinator { parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } if !deleteResult.physicallyRemovedIndices.isEmpty { - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) } else { parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() @@ -114,7 +112,6 @@ final class RowEditingCoordinator { parent.selectionState.indices = [result.rowIndex] parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } @@ -138,7 +135,6 @@ final class RowEditingCoordinator { } parent.selectionState.indices = undoResult.adjustedSelection - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(undoResult.delta) } @@ -159,7 +155,6 @@ final class RowEditingCoordinator { } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(application.delta) } @@ -231,7 +226,6 @@ final class RowEditingCoordinator { tab.selectedRowIndices = newIndices tab.hasUserInteraction = true } - parent.querySortCache.removeValue(forKey: tabId) parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta) } diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 48dba093d..0e51d6dc9 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -110,6 +110,7 @@ struct PaginationState: Equatable { var isLoadingMore: Bool = false var baseQueryForMore: String? var baseQueryParameterValues: [String?]? + var sortExecutionOverride: String? // Derived ORDER BY query run for a grid sort; never written back to the editor /// Default page size constant (used when no explicit value is provided) /// Note: For new tabs, callers should pass AppSettingsManager.shared.dataGrid.defaultPageSize @@ -217,6 +218,7 @@ struct PaginationState: Equatable { isLoadingMore = false baseQueryForMore = nil baseQueryParameterValues = nil + sortExecutionOverride = nil } /// Update page size (limit) diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index 88b2b57ee..dfc83fb9d 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -104,6 +104,11 @@ internal final class EditorEventRouter { coordinator.findPrevious() } + internal func performFormatSQLForKeyWindow() { + guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return } + coordinator.performFormatSQL() + } + /// Called by the SwiftUI "Clear Selection" menu when its Esc key equivalent fires. /// Routes the keystroke to the active editor's Vim engine if it is in a non-normal /// mode. Returns true when Vim consumed the escape — caller should suppress its diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 5da9a9d9f..e6436beea 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -12,9 +12,6 @@ import TableProPluginKit /// SQL query editor view with execute button struct QueryEditorView: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView") - - @Binding var queryText: String @Binding var cursorPositions: [CursorPosition] @Binding var parameters: [QueryParameter] @@ -67,8 +64,7 @@ struct QueryEditorView: View { onExecuteQuery: onExecuteQuery, onAIExplain: onAIExplain, onAIOptimize: onAIOptimize, - onSaveAsFavorite: onSaveAsFavorite, - onFormatSQL: formatQuery + onSaveAsFavorite: onSaveAsFavorite ) .frame(minHeight: 100) .clipped() @@ -202,32 +198,7 @@ struct QueryEditorView: View { } private func formatQuery() { - // Get current database type - let dbType = databaseType ?? .mysql - - // Create formatter service - let formatter = SQLFormatterService() - let options = SQLFormatterOptions.default - - let cursorOffset = cursorPositions.first?.range.location ?? 0 - - do { - // Format SQL with cursor preservation - let result = try formatter.format( - queryText, - dialect: dbType, - cursorOffset: cursorOffset, - options: options - ) - - // Update text and cursor position - queryText = result.formattedSQL - if let newCursor = result.cursorOffset { - cursorPositions = [CursorPosition(range: NSRange(location: newCursor, length: 0))] - } - } catch { - Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)") - } + EditorEventRouter.shared.performFormatSQLForKeyWindow() } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 4f3ddf643..88b85653e 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -56,7 +56,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored var onAIExplain: ((String) -> Void)? @ObservationIgnored var onAIOptimize: ((String) -> Void)? @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? - @ObservationIgnored var onFormatSQL: (() -> Void)? @ObservationIgnored var databaseType: DatabaseType? @ObservationIgnored var tabID: UUID? @ObservationIgnored var connectionId: UUID? @@ -189,7 +188,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { onAIExplain = nil onAIOptimize = nil onSaveAsFavorite = nil - onFormatSQL = nil schemaProvider = nil contextMenu = nil vimEngine = nil @@ -238,10 +236,35 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) } menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) } menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } - menu.onFormatSQL = { [weak self] in self?.onFormatSQL?() } + menu.onFormatSQL = { [weak self] in self?.performFormatSQL() } contextMenu = menu } + func performFormatSQL() { + guard let textView = controller?.textView else { return } + let dialect = databaseType ?? .mysql + let cursorLocation = textView.selectedRange().location + let cursorOffset = cursorLocation == NSNotFound ? 0 : cursorLocation + let formatter = SQLFormatterService() + + do { + let result = try formatter.format( + textView.string, + dialect: dialect, + cursorOffset: cursorOffset, + options: .default + ) + let fullRange = NSRange(location: 0, length: (textView.string as NSString).length) + textView.replaceCharacters(in: fullRange, with: result.formattedSQL) + if let newOffset = result.cursorOffset { + let clamped = min(newOffset, (result.formattedSQL as NSString).length) + controller?.setCursorPositions([CursorPosition(range: NSRange(location: clamped, length: 0))]) + } + } catch { + Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)") + } + } + /// Called by EditorEventRouter when a right-click is detected in this editor's text view. func showContextMenu(for event: NSEvent, in textView: TextView) { if contextMenu == nil, let controller { diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 09399b2d3..2e4543d80 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -29,7 +29,6 @@ struct SQLEditorView: View { var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? - var onFormatSQL: (() -> Void)? @State private var editorState = SourceEditorState() @State private var completionAdapter: SQLCompletionAdapter? @@ -46,7 +45,6 @@ struct SQLEditorView: View { coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize coordinator.onSaveAsFavorite = onSaveAsFavorite - coordinator.onFormatSQL = onFormatSQL coordinator.schemaProvider = schemaProvider coordinator.connectionAIPolicy = connectionAIPolicy coordinator.databaseType = databaseType diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 0c83c2a21..dc3da9e15 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -134,7 +134,7 @@ struct MainEditorContentView: View { } .onChange(of: tabManager.tabStructureVersion) { _, _ in let openTabIds = Set(tabManager.tabIds) - coordinator.cleanupSortCache(openTabIds: openTabIds) + coordinator.cleanupTabCaches(openTabIds: openTabIds) erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } @@ -573,7 +573,7 @@ struct MainEditorContentView: View { showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers, hiddenColumns: tab.columnLayout.hiddenColumns ), - sortedIDs: sortedIDsForTab(tab), + sortedIDs: nil, displayFormats: displayFormats(for: tab), delegate: dataTabDelegate, selectedRowIndices: Binding( @@ -656,66 +656,6 @@ struct MainEditorContentView: View { return result } - /// Returns the display order as a permutation of `RowID`, or nil when no sort applies. - /// For table tabs, sorting is handled server-side via SQL ORDER BY. - private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? { - if tab.tabType == .table { - return nil - } - - guard tab.sortState.isSorting else { - return nil - } - - let resolvedRows = resolvedTableRows(for: tab) - guard !resolvedRows.rows.isEmpty else { - return nil - } - let colTypes = resolvedRows.columnTypes - - if let cached = coordinator.querySortCache[tab.id], - cached.columnIndex == (tab.sortState.columnIndex ?? -1), - cached.direction == tab.sortState.direction, - cached.schemaVersion == tab.schemaVersion - { - return cached.sortedIDs - } - - if resolvedRows.rows.count > 1_000 { - return nil - } - - let sortColumns = tab.sortState.columns - let storageRows = resolvedRows.rows - let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in - let row1 = storageRows[idx1].values - let row2 = storageRows[idx2].values - for sortCol in sortColumns { - let val1 = sortCol.columnIndex < row1.count - ? row1[sortCol.columnIndex].sortKey : "" - let val2 = sortCol.columnIndex < row2.count - ? row2[sortCol.columnIndex].sortKey : "" - let colType = sortCol.columnIndex < colTypes.count - ? colTypes[sortCol.columnIndex] : nil - let result = RowSortComparator.compare(val1, val2, columnType: colType) - if result == .orderedSame { continue } - return sortCol.direction == .ascending - ? result == .orderedAscending - : result == .orderedDescending - } - return false - } - let sortedIDs = sortedIndices.map { storageRows[$0].id } - - coordinator.querySortCache[tab.id] = QuerySortCacheEntry( - sortedIDs: sortedIDs, - columnIndex: tab.sortState.columnIndex ?? -1, - direction: tab.sortState.direction, - schemaVersion: tab.schemaVersion - ) - - return sortedIDs - } private func sortStateBinding(for tab: QueryTab) -> Binding { Binding( diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 065165c3e..906bedec3 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -758,23 +758,7 @@ final class MainContentCommandActions { } func formatQuery() { - guard let coordinator, - let (tab, tabIndex) = coordinator.tabManager.selectedTabAndIndex else { return } - let dbType = connection.type - let formatter = SQLFormatterService() - let options = SQLFormatterOptions.default - - do { - let result = try formatter.format( - tab.content.query, - dialect: dbType, - cursorOffset: 0, - options: options - ) - coordinator.tabManager.mutate(at: tabIndex) { $0.content.query = result.formattedSQL } - } catch { - Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)") - } + EditorEventRouter.shared.performFormatSQLForKeyWindow() } // MARK: - UI Operations (Group A — Called Directly) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index d18e00e7b..37edb1caf 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -22,16 +22,6 @@ enum DiscardAction { case filter } -/// Cache entry for async-sorted query tab rows. Stores a permutation of `RowID` so the -/// sort survives mutations: inserted rows append to the end of the sorted view, and -/// removed rows are dropped from the permutation without re-sorting. -struct QuerySortCacheEntry { - let sortedIDs: [RowID] - let columnIndex: Int - let direction: SortDirection - let schemaVersion: Int -} - struct DisplayFormatsCacheEntry { let schemaVersion: Int let smartDetectionEnabled: Bool @@ -173,9 +163,6 @@ final class MainContentCoordinator { var exportPreselectedTableNames: Set? var needsLazyLoad = false - /// Cache for async-sorted query tab rows (large datasets sorted on background thread) - @ObservationIgnored var querySortCache: [UUID: QuerySortCacheEntry] = [:] - @ObservationIgnored var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] @ObservationIgnored let schemaColumns = SchemaColumnStore() @@ -190,7 +177,6 @@ final class MainContentCoordinator { @ObservationIgnored internal var tableLoadTasks: [UUID: (token: UUID, task: Task)] = [:] @ObservationIgnored internal var redisDatabaseSwitchTask: Task? @ObservationIgnored private var changeManagerUpdateTask: Task? - @ObservationIgnored private var activeSortTasks: [UUID: Task] = [:] @ObservationIgnored private var terminationObserver: NSObjectProtocol? @ObservationIgnored private var postConnectCancellable: AnyCancellable? @ObservationIgnored private var externalFileModCancellable: AnyCancellable? @@ -346,18 +332,11 @@ final class MainContentCoordinator { } } - /// Remove sort cache entries for tabs that no longer exist - func cleanupSortCache(openTabIds: Set) { - if querySortCache.keys.contains(where: { !openTabIds.contains($0) }) { - querySortCache = querySortCache.filter { openTabIds.contains($0.key) } - } + /// Remove cache entries for tabs that no longer exist + func cleanupTabCaches(openTabIds: Set) { if displayFormatsCache.keys.contains(where: { !openTabIds.contains($0) }) { displayFormatsCache = displayFormatsCache.filter { openTabIds.contains($0.key) } } - for (tabId, task) in activeSortTasks where !openTabIds.contains(tabId) { - task.cancel() - activeSortTasks.removeValue(forKey: tabId) - } } // MARK: - Initialization @@ -680,13 +659,10 @@ final class MainContentCoordinator { changeManagerUpdateTask = nil redisDatabaseSwitchTask?.cancel() redisDatabaseSwitchTask = nil - for task in activeSortTasks.values { task.cancel() } - activeSortTasks.removeAll() dataTabDelegate?.tableViewCoordinator?.releaseData() tabSessionRegistry.removeAll() - querySortCache.removeAll() displayFormatsCache.removeAll() schemaColumns.removeAll() columnScopeRequeryTask?.cancel() @@ -820,8 +796,9 @@ final class MainContentCoordinator { let fullQuery = tab.content.query let sql: String - if tab.tabType == .table { - sql = fullQuery + if let sortOverride = tab.pagination.sortExecutionOverride { + tabManager.mutate(at: index) { $0.pagination.sortExecutionOverride = nil } + sql = sortOverride } else if let firstCursor = cursorPositions.first, firstCursor.range.length > 0 { // Execute selected text only @@ -1285,90 +1262,23 @@ final class MainContentCoordinator { let tableRows = tabSessionRegistry.tableRows(for: tab.id) if tab.tabType == .query { - if !newState.columns.isEmpty && tab.pagination.hasMoreRows { - let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query - let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) - let orderClause = newState.columns.compactMap { sortCol -> String? in - guard sortCol.columnIndex >= 0, sortCol.columnIndex < tableRows.columns.count else { return nil } - let columnName = tableRows.columns[sortCol.columnIndex] - let direction = sortCol.direction == .ascending ? "ASC" : "DESC" - return "\(queryBuilder.quoteIdentifier(columnName)) \(direction)" - }.joined(separator: ", ") - let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)" - tabManager.mutate(at: tabIndex) { tab in - tab.sortState = newState - tab.hasUserInteraction = true - tab.pagination.resetLoadMore() - tab.content.query = orderQuery - } - runQuery() - return - } - - if newState.columns.isEmpty { - tabManager.mutate(at: tabIndex) { tab in - tab.sortState = newState - tab.hasUserInteraction = true - } - querySortCache.removeValue(forKey: tab.id) - dataTabDelegate?.dataGridDidReplaceAllRows() - return - } - + let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query + let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) + let orderClause = newState.columns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < tableRows.columns.count else { return nil } + let columnName = tableRows.columns[sortCol.columnIndex] + let direction = sortCol.direction == .ascending ? "ASC" : "DESC" + return "\(queryBuilder.quoteIdentifier(columnName)) \(direction)" + }.joined(separator: ", ") + let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)" tabManager.mutate(at: tabIndex) { tab in tab.sortState = newState tab.hasUserInteraction = true tab.pagination.reset() + tab.pagination.resetLoadMore() + tab.pagination.sortExecutionOverride = orderQuery } - let tabId = tab.id - let schemaVersion = tab.schemaVersion - let sortColumns = newState.columns - let colTypes = tableRows.columnTypes - let storageRows = tableRows.rows - let snapshotRows: [(id: RowID, values: [PluginCellValue])] = storageRows.map { ($0.id, Array($0.values)) } - - if storageRows.count > 1_000 { - activeSortTasks[tabId]?.cancel() - activeSortTasks.removeValue(forKey: tabId) - tabManager.mutate(at: tabIndex) { $0.execution.isExecuting = true } - toolbarState.setExecuting(true) - querySortCache.removeValue(forKey: tabId) - - let sortStartTime = Date() - let task = Task.detached { [weak self] in - let sortedIDs = Self.multiColumnSortedIDs( - rows: snapshotRows, - sortColumns: sortColumns, - columnTypes: colTypes - ) - let sortDuration = Date().timeIntervalSince(sortStartTime) - - await MainActor.run { [weak self] in - guard let self else { return } - guard let idx = self.tabManager.tabs.firstIndex(where: { $0.id == tabId }), - self.tabManager.tabs[idx].sortState == newState else { - return - } - self.querySortCache[tabId] = QuerySortCacheEntry( - sortedIDs: sortedIDs, - columnIndex: sortColumns.first?.columnIndex ?? 0, - direction: sortColumns.first?.direction ?? .ascending, - schemaVersion: schemaVersion - ) - self.tabManager.mutate(at: idx) { tab in - tab.execution.isExecuting = false - tab.execution.executionTime = sortDuration - } - self.toolbarState.setExecuting(false) - self.toolbarState.lastQueryDuration = sortDuration - self.activeSortTasks.removeValue(forKey: tabId) - self.dataTabDelegate?.dataGridDidReplaceAllRows() - } - } - activeSortTasks[tabId] = task - } else { - dataTabDelegate?.dataGridDidReplaceAllRows() - } + runQuery() return } @@ -1397,47 +1307,4 @@ final class MainContentCoordinator { self.runQuery() } } - - /// Multi-column sort returning a permutation of `RowID` (nonisolated for background thread). - nonisolated private static func multiColumnSortedIDs( - rows: [(id: RowID, values: [PluginCellValue])], - sortColumns: [SortColumn], - columnTypes: [ColumnType] = [] - ) -> [RowID] { - if sortColumns.count == 1 { - let col = sortColumns[0] - let colIndex = col.columnIndex - let ascending = col.direction == .ascending - let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil - var indices = Array(0.. (MainContentCoordinator, QueryTabManager, UUID) { - let tabManager = QueryTabManager() - let coordinator = MainContentCoordinator( - connection: TestFixtures.makeConnection(), - tabManager: tabManager, - changeManager: DataChangeManager(), - toolbarState: ConnectionToolbarState() - ) - try tabManager.addTableTab(tableName: "users") - let tabIndex = tabManager.selectedTabIndex ?? 0 - tabManager.tabs[tabIndex].tableContext.isEditable = true - let tabId = tabManager.tabs[tabIndex].id - return (coordinator, tabManager, tabId) - } - - private func seedCache(_ coordinator: MainContentCoordinator, for tabId: UUID) { - coordinator.querySortCache[tabId] = QuerySortCacheEntry( - sortedIDs: [.existing(0), .existing(1), .existing(2)], - columnIndex: 1, - direction: .ascending, - schemaVersion: 0 - ) - } - - private func seedRows(_ coordinator: MainContentCoordinator, for tabId: UUID, count: Int) { - let columns = ["id", "name"] - let rows = (0.. Date: Fri, 12 Jun 2026 02:21:58 +0700 Subject: [PATCH 2/2] fix(coordinator): confirm before discarding unsaved edits when sorting a query result --- .../Main/Child/MainEditorContentView.swift | 1 - .../Views/Main/MainContentCoordinator.swift | 38 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index dc3da9e15..f5c484589 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -656,7 +656,6 @@ struct MainEditorContentView: View { return result } - private func sortStateBinding(for tab: QueryTab) -> Binding { Binding( get: { tab.sortState }, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 37edb1caf..7fa25aaa1 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1256,29 +1256,35 @@ final class MainContentCoordinator { // MARK: - Sorting func handleSortStateChanged(_ newState: SortState) { - guard let (tab, tabIndex) = tabManager.selectedTabAndIndex else { return } + guard let (tab, _) = tabManager.selectedTabAndIndex else { return } guard newState != tab.sortState else { return } let tableRows = tabSessionRegistry.tableRows(for: tab.id) if tab.tabType == .query { + let tabId = tab.id + let capturedSort = newState let baseQuery = tab.pagination.baseQueryForMore ?? tab.content.query - let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) - let orderClause = newState.columns.compactMap { sortCol -> String? in - guard sortCol.columnIndex >= 0, sortCol.columnIndex < tableRows.columns.count else { return nil } - let columnName = tableRows.columns[sortCol.columnIndex] - let direction = sortCol.direction == .ascending ? "ASC" : "DESC" - return "\(queryBuilder.quoteIdentifier(columnName)) \(direction)" - }.joined(separator: ", ") - let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)" - tabManager.mutate(at: tabIndex) { tab in - tab.sortState = newState - tab.hasUserInteraction = true - tab.pagination.reset() - tab.pagination.resetLoadMore() - tab.pagination.sortExecutionOverride = orderQuery + let capturedColumns = tableRows.columns + confirmDiscardChangesIfNeeded(action: .sort) { [weak self] confirmed in + guard let self, confirmed else { return } + let strippedQuery = Self.stripTrailingOrderBy(from: baseQuery) + let orderClause = capturedSort.columns.compactMap { sortCol -> String? in + guard sortCol.columnIndex >= 0, sortCol.columnIndex < capturedColumns.count else { return nil } + let columnName = capturedColumns[sortCol.columnIndex] + let direction = sortCol.direction == .ascending ? "ASC" : "DESC" + return "\(self.queryBuilder.quoteIdentifier(columnName)) \(direction)" + }.joined(separator: ", ") + let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)" + guard self.tabManager.mutate(tabId: tabId, { tab in + tab.sortState = capturedSort + tab.hasUserInteraction = true + tab.pagination.reset() + tab.pagination.resetLoadMore() + tab.pagination.sortExecutionOverride = orderQuery + }) else { return } + self.runQuery() } - runQuery() return }