Skip to content

fix(editor): make Format SQL undoable and sort query results server-side without overwriting the editor (#1645)#1658

Merged
datlechin merged 2 commits into
mainfrom
fix/editor-undo-and-sort-overwrite
Jun 11, 2026
Merged

fix(editor): make Format SQL undoable and sort query results server-side without overwriting the editor (#1645)#1658
datlechin merged 2 commits into
mainfrom
fix/editor-undo-and-sort-overwrite

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #1645 — two undo-related bugs in the SQL file editor.

Bugs

  1. Format Query couldn't be undone. Cmd+Z and Edit > Undo did nothing after formatting.
  2. Sorting a query result overwrote the editor and the file. Sorting the result grid regenerated the query with ORDER BY and wrote it back into the editor's content.query; for an opened .sql file that made it dirty, so Cmd+S persisted the generated query to disk, and the overwrite couldn't be undone.

Both shared one root cause: writing tab.content.query programmatically routed through the editor's setText -> setTextStorage -> CEUndoManager.clearStack(), which wipes the undo stack.

Bug 1: Format is now a single undoable edit

Format applies through the editor's undoable API instead of the SwiftUI text binding. SQLEditorCoordinator.performFormatSQL() formats the text view's contents and applies them with TextView.replaceCharacters(in:with:), which registers one CEUndoManager mutation — so one Cmd+Z restores the pre-format text, matching every editor users know. All entry points (toolbar button, app menu, context menu) route through EditorEventRouter (the existing find-panel pattern); the dead onFormatSQL plumbing is removed.

Bug 2: Sorting a query result runs server-side and never touches the editor

Query-result sorting is now server-side end-to-end:

  • Every grid-header sort re-runs the exact executed statement with ORDER BY against the database (via a new execution-only PaginationState.sortExecutionOverride), so the editor text and any backing .sql file are never rewritten. Clearing the sort re-runs the statement without ORDER BY.
  • The grid displays rows in the order the database returned them, so ordering matches DB collation and NULL handling instead of an in-memory approximation.
  • The executed query is captured for every result, so sorting works correctly even when the user ran a selection or one of several statements.

This removes the old client-side in-memory sort path for query results (querySortCache, QuerySortCacheEntry, multiColumnSortedIDs, activeSortTasks, sortedIDsForTab, and the cache clears in RowEditingCoordinator). The .table browse view is unchanged (its query is app-generated, not user text).

Tests

  • Sorting a paginated/file-backed query tab leaves content.query unchanged and the file not dirty; clearing sort preserves the editor query; PaginationState.resetLoadMore() clears the override.
  • Removed the obsolete client-side sort-cache tests.
  • Format undo is keyed off NSApp.keyWindow, so it isn't unit-testable headlessly; the formatter logic itself is already covered.

Notes

  • Known limitation (deliberately not hacked around): the sort appends ORDER BY to the executed statement after stripping a trailing one — the codebase's existing mechanism, shared with load-more. A statement ending in LIMIT would need sub-select wrapping to be fully correct; happy to add that if wanted.
  • RowSortComparator is now unused (it backed the removed in-memory sort); left in place as a self-contained tested utility, easy follow-up to remove.
  • No PluginKit/ABI change. No docs change (behavior fix).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5953f369a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1264 to +1266
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the executed statement for multi-statement sorting

When a query tab result comes from Run All Statements or a selection containing multiple statements, executeMultipleStatements does not populate tab.pagination.baseQueryForMore for the active result, so this new query-tab sorting path falls back to the full editor text. For an editor containing e.g. SELECT ...; SELECT ..., clicking a result-grid header builds one script with ORDER BY appended at the end instead of sorting the displayed statement, which commonly fails or targets the wrong statement; the removed client-side sort did not have this regression. The sort source needs to be stored per result set or otherwise use the exact statement that produced the active result.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit c71ab2d into main Jun 11, 2026
2 checks passed
@datlechin datlechin deleted the fix/editor-undo-and-sort-overwrite branch June 11, 2026 19:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91a02f1d04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return "\(queryBuilder.quoteIdentifier(columnName)) \(direction)"
return "\(self.queryBuilder.quoteIdentifier(columnName)) \(direction)"
}.joined(separator: ", ")
let orderQuery = orderClause.isEmpty ? strippedQuery : "\(strippedQuery) ORDER BY \(orderClause)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert ORDER BY before LIMIT/OFFSET for query sorts

When a query-tab result comes from SQL that already has LIMIT or OFFSET, appending the new clause here produces invalid clause order such as SELECT ... LIMIT 100 ORDER BY "name" ASC. The table-tab builder already inserts ORDER BY before LIMIT/OFFSET, but this new server-side query-tab path bypasses that handling, so clicking a grid header fails for common limited queries.

Useful? React with 👍 / 👎.

if tab.tabType == .table {
sql = fullQuery
if let sortOverride = tab.pagination.sortExecutionOverride {
tabManager.mutate(at: index) { $0.pagination.sortExecutionOverride = nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the sort override until parameterized execution

If the sorted query contains parameters and the parameter panel is currently hidden, runQuery() clears sortExecutionOverride before the parameter-panel visibility check later returns without dispatching the query. In that scenario, sorting a parameterized result only opens the panel, then the next run uses tab.content.query without the generated ORDER BY, while the tab's sort state still indicates the grid should be sorted.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File editor issues

1 participant