Skip to content

Commit c71ab2d

Browse files
authored
fix(editor): make Format SQL undoable and sort query results server-side without overwriting the editor (#1645) (#1658)
* fix(editor): make Format SQL undoable and sort query results server-side without overwriting the editor (#1645) * fix(coordinator): confirm before discarding unsaved edits when sorting a query result
1 parent 0e5a9fd commit c71ab2d

14 files changed

Lines changed: 132 additions & 398 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121

2222
### Fixed
2323

24+
- 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)
25+
- 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)
2426
- 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.
2527
- 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)
2628
- 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)

TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ extension QueryExecutionCoordinator {
146146

147147
if isTruncated {
148148
tab.pagination.hasMoreRows = true
149-
tab.pagination.baseQueryForMore = sql
150149
tab.pagination.isLoadingMore = false
151150
} else {
152151
tab.pagination.resetLoadMore()
153152
}
153+
tab.pagination.baseQueryForMore = sql
154154

155155
if tab.display.isResultsCollapsed {
156156
tab.display.isResultsCollapsed = false

TablePro/Core/Coordinators/RowEditingCoordinator.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ final class RowEditingCoordinator {
4343

4444
parent.selectionState.indices = [result.rowIndex]
4545
parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
46-
parent.querySortCache.removeValue(forKey: tabId)
4746
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta)
4847
parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0)
4948
}
@@ -80,7 +79,6 @@ final class RowEditingCoordinator {
8079
parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
8180

8281
if !deleteResult.physicallyRemovedIndices.isEmpty {
83-
parent.querySortCache.removeValue(forKey: tabId)
8482
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta)
8583
} else {
8684
parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo()
@@ -114,7 +112,6 @@ final class RowEditingCoordinator {
114112

115113
parent.selectionState.indices = [result.rowIndex]
116114
parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
117-
parent.querySortCache.removeValue(forKey: tabId)
118115
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta)
119116
parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0)
120117
}
@@ -138,7 +135,6 @@ final class RowEditingCoordinator {
138135
}
139136

140137
parent.selectionState.indices = undoResult.adjustedSelection
141-
parent.querySortCache.removeValue(forKey: tabId)
142138
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(undoResult.delta)
143139
}
144140

@@ -159,7 +155,6 @@ final class RowEditingCoordinator {
159155
}
160156

161157
parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true }
162-
parent.querySortCache.removeValue(forKey: tabId)
163158
parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo()
164159
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(application.delta)
165160
}
@@ -231,7 +226,6 @@ final class RowEditingCoordinator {
231226
tab.selectedRowIndices = newIndices
232227
tab.hasUserInteraction = true
233228
}
234-
parent.querySortCache.removeValue(forKey: tabId)
235229
parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(pasteResult.delta)
236230
}
237231

TablePro/Models/Query/QueryTabState.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ struct PaginationState: Equatable {
110110
var isLoadingMore: Bool = false
111111
var baseQueryForMore: String?
112112
var baseQueryParameterValues: [String?]?
113+
var sortExecutionOverride: String? // Derived ORDER BY query run for a grid sort; never written back to the editor
113114

114115
/// Default page size constant (used when no explicit value is provided)
115116
/// Note: For new tabs, callers should pass AppSettingsManager.shared.dataGrid.defaultPageSize
@@ -217,6 +218,7 @@ struct PaginationState: Equatable {
217218
isLoadingMore = false
218219
baseQueryForMore = nil
219220
baseQueryParameterValues = nil
221+
sortExecutionOverride = nil
220222
}
221223

222224
/// Update page size (limit)

TablePro/Views/Editor/EditorEventRouter.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ internal final class EditorEventRouter {
104104
coordinator.findPrevious()
105105
}
106106

107+
internal func performFormatSQLForKeyWindow() {
108+
guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return }
109+
coordinator.performFormatSQL()
110+
}
111+
107112
/// Called by the SwiftUI "Clear Selection" menu when its Esc key equivalent fires.
108113
/// Routes the keystroke to the active editor's Vim engine if it is in a non-normal
109114
/// mode. Returns true when Vim consumed the escape — caller should suppress its

TablePro/Views/Editor/QueryEditorView.swift

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ import TableProPluginKit
1212

1313
/// SQL query editor view with execute button
1414
struct QueryEditorView: View {
15-
private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorView")
16-
17-
1815
@Binding var queryText: String
1916
@Binding var cursorPositions: [CursorPosition]
2017
@Binding var parameters: [QueryParameter]
@@ -67,8 +64,7 @@ struct QueryEditorView: View {
6764
onExecuteQuery: onExecuteQuery,
6865
onAIExplain: onAIExplain,
6966
onAIOptimize: onAIOptimize,
70-
onSaveAsFavorite: onSaveAsFavorite,
71-
onFormatSQL: formatQuery
67+
onSaveAsFavorite: onSaveAsFavorite
7268
)
7369
.frame(minHeight: 100)
7470
.clipped()
@@ -202,32 +198,7 @@ struct QueryEditorView: View {
202198
}
203199

204200
private func formatQuery() {
205-
// Get current database type
206-
let dbType = databaseType ?? .mysql
207-
208-
// Create formatter service
209-
let formatter = SQLFormatterService()
210-
let options = SQLFormatterOptions.default
211-
212-
let cursorOffset = cursorPositions.first?.range.location ?? 0
213-
214-
do {
215-
// Format SQL with cursor preservation
216-
let result = try formatter.format(
217-
queryText,
218-
dialect: dbType,
219-
cursorOffset: cursorOffset,
220-
options: options
221-
)
222-
223-
// Update text and cursor position
224-
queryText = result.formattedSQL
225-
if let newCursor = result.cursorOffset {
226-
cursorPositions = [CursorPosition(range: NSRange(location: newCursor, length: 0))]
227-
}
228-
} catch {
229-
Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)")
230-
}
201+
EditorEventRouter.shared.performFormatSQLForKeyWindow()
231202
}
232203
}
233204

TablePro/Views/Editor/SQLEditorCoordinator.swift

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
6060
@ObservationIgnored var onAIExplain: ((String) -> Void)?
6161
@ObservationIgnored var onAIOptimize: ((String) -> Void)?
6262
@ObservationIgnored var onSaveAsFavorite: ((String) -> Void)?
63-
@ObservationIgnored var onFormatSQL: (() -> Void)?
6463
@ObservationIgnored var databaseType: DatabaseType?
6564
@ObservationIgnored var tabID: UUID?
6665
@ObservationIgnored var connectionId: UUID?
@@ -197,7 +196,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
197196
onAIExplain = nil
198197
onAIOptimize = nil
199198
onSaveAsFavorite = nil
200-
onFormatSQL = nil
201199
schemaProvider = nil
202200
contextMenu = nil
203201
vimEngine = nil
@@ -246,10 +244,35 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
246244
menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) }
247245
menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) }
248246
menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) }
249-
menu.onFormatSQL = { [weak self] in self?.onFormatSQL?() }
247+
menu.onFormatSQL = { [weak self] in self?.performFormatSQL() }
250248
contextMenu = menu
251249
}
252250

251+
func performFormatSQL() {
252+
guard let textView = controller?.textView else { return }
253+
let dialect = databaseType ?? .mysql
254+
let cursorLocation = textView.selectedRange().location
255+
let cursorOffset = cursorLocation == NSNotFound ? 0 : cursorLocation
256+
let formatter = SQLFormatterService()
257+
258+
do {
259+
let result = try formatter.format(
260+
textView.string,
261+
dialect: dialect,
262+
cursorOffset: cursorOffset,
263+
options: .default
264+
)
265+
let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
266+
textView.replaceCharacters(in: fullRange, with: result.formattedSQL)
267+
if let newOffset = result.cursorOffset {
268+
let clamped = min(newOffset, (result.formattedSQL as NSString).length)
269+
controller?.setCursorPositions([CursorPosition(range: NSRange(location: clamped, length: 0))])
270+
}
271+
} catch {
272+
Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)")
273+
}
274+
}
275+
253276
/// Called by EditorEventRouter when a right-click is detected in this editor's text view.
254277
func showContextMenu(for event: NSEvent, in textView: TextView) {
255278
if contextMenu == nil, let controller {

TablePro/Views/Editor/SQLEditorView.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ struct SQLEditorView: View {
2929
var onAIExplain: ((String) -> Void)?
3030
var onAIOptimize: ((String) -> Void)?
3131
var onSaveAsFavorite: ((String) -> Void)?
32-
var onFormatSQL: (() -> Void)?
3332

3433
@State private var editorState = SourceEditorState()
3534
@State private var completionAdapter: SQLCompletionAdapter?
@@ -46,7 +45,6 @@ struct SQLEditorView: View {
4645
coordinator.onAIExplain = onAIExplain
4746
coordinator.onAIOptimize = onAIOptimize
4847
coordinator.onSaveAsFavorite = onSaveAsFavorite
49-
coordinator.onFormatSQL = onFormatSQL
5048
coordinator.schemaProvider = schemaProvider
5149
coordinator.connectionAIPolicy = connectionAIPolicy
5250
coordinator.databaseType = databaseType

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ struct MainEditorContentView: View {
134134
}
135135
.onChange(of: tabManager.tabStructureVersion) { _, _ in
136136
let openTabIds = Set(tabManager.tabIds)
137-
coordinator.cleanupSortCache(openTabIds: openTabIds)
137+
coordinator.cleanupTabCaches(openTabIds: openTabIds)
138138
erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) }
139139
serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) }
140140
}
@@ -573,7 +573,7 @@ struct MainEditorContentView: View {
573573
showRowNumbers: AppSettingsManager.shared.dataGrid.showRowNumbers,
574574
hiddenColumns: tab.columnLayout.hiddenColumns
575575
),
576-
sortedIDs: sortedIDsForTab(tab),
576+
sortedIDs: nil,
577577
displayFormats: displayFormats(for: tab),
578578
delegate: dataTabDelegate,
579579
selectedRowIndices: Binding(
@@ -656,67 +656,6 @@ struct MainEditorContentView: View {
656656
return result
657657
}
658658

659-
/// Returns the display order as a permutation of `RowID`, or nil when no sort applies.
660-
/// For table tabs, sorting is handled server-side via SQL ORDER BY.
661-
private func sortedIDsForTab(_ tab: QueryTab) -> [RowID]? {
662-
if tab.tabType == .table {
663-
return nil
664-
}
665-
666-
guard tab.sortState.isSorting else {
667-
return nil
668-
}
669-
670-
let resolvedRows = resolvedTableRows(for: tab)
671-
guard !resolvedRows.rows.isEmpty else {
672-
return nil
673-
}
674-
let colTypes = resolvedRows.columnTypes
675-
676-
if let cached = coordinator.querySortCache[tab.id],
677-
cached.columnIndex == (tab.sortState.columnIndex ?? -1),
678-
cached.direction == tab.sortState.direction,
679-
cached.schemaVersion == tab.schemaVersion
680-
{
681-
return cached.sortedIDs
682-
}
683-
684-
if resolvedRows.rows.count > 1_000 {
685-
return nil
686-
}
687-
688-
let sortColumns = tab.sortState.columns
689-
let storageRows = resolvedRows.rows
690-
let sortedIndices = Array(storageRows.indices).sorted { idx1, idx2 in
691-
let row1 = storageRows[idx1].values
692-
let row2 = storageRows[idx2].values
693-
for sortCol in sortColumns {
694-
let val1 = sortCol.columnIndex < row1.count
695-
? row1[sortCol.columnIndex].sortKey : ""
696-
let val2 = sortCol.columnIndex < row2.count
697-
? row2[sortCol.columnIndex].sortKey : ""
698-
let colType = sortCol.columnIndex < colTypes.count
699-
? colTypes[sortCol.columnIndex] : nil
700-
let result = RowSortComparator.compare(val1, val2, columnType: colType)
701-
if result == .orderedSame { continue }
702-
return sortCol.direction == .ascending
703-
? result == .orderedAscending
704-
: result == .orderedDescending
705-
}
706-
return false
707-
}
708-
let sortedIDs = sortedIndices.map { storageRows[$0].id }
709-
710-
coordinator.querySortCache[tab.id] = QuerySortCacheEntry(
711-
sortedIDs: sortedIDs,
712-
columnIndex: tab.sortState.columnIndex ?? -1,
713-
direction: tab.sortState.direction,
714-
schemaVersion: tab.schemaVersion
715-
)
716-
717-
return sortedIDs
718-
}
719-
720659
private func sortStateBinding(for tab: QueryTab) -> Binding<SortState> {
721660
Binding(
722661
get: { tab.sortState },

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -758,23 +758,7 @@ final class MainContentCommandActions {
758758
}
759759

760760
func formatQuery() {
761-
guard let coordinator,
762-
let (tab, tabIndex) = coordinator.tabManager.selectedTabAndIndex else { return }
763-
let dbType = connection.type
764-
let formatter = SQLFormatterService()
765-
let options = SQLFormatterOptions.default
766-
767-
do {
768-
let result = try formatter.format(
769-
tab.content.query,
770-
dialect: dbType,
771-
cursorOffset: 0,
772-
options: options
773-
)
774-
coordinator.tabManager.mutate(at: tabIndex) { $0.content.query = result.formattedSQL }
775-
} catch {
776-
Self.logger.error("SQL Formatting error: \(error.localizedDescription, privacy: .public)")
777-
}
761+
EditorEventRouter.shared.performFormatSQLForKeyWindow()
778762
}
779763

780764
// MARK: - UI Operations (Group A — Called Directly)

0 commit comments

Comments
 (0)