Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The Columns and Add Row buttons and clicks in the SQL editor no longer stop working after the autocomplete window opens. The autocomplete now stays inside the editor and closes when you click outside it.
- Resizing a data grid column by dragging its edge no longer sorts the column by accident.
- Hidden columns stay hidden after you resize or reorder columns in the data grid; they no longer reappear on their own.
- Data grid column widths no longer snap back to their previous size when the grid reloads, sorts, or paginates right after a resize.
- Query and filter errors now show in a selectable, copyable banner above the results, with a Fix with AI action when AI is enabled, instead of a small alert window.
- The filter bar's autocomplete no longer pops up on empty space; it appears only while you are typing a word.
- Pressing Escape to close the filter autocomplete no longer also closes the filter bar.
- The SQL editor no longer leaks an Escape key handler each time you close a tab with the Find bar open.
- SSH tunnels no longer pin a CPU core after the connection drops. A dropped tunnel is now detected and torn down instead of spinning in its relay loop. (#1769)

## [0.53.0] - 2026-06-25
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable> = []

// MARK: - Initialization
Expand Down Expand Up @@ -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
)
}
}
}
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -265,5 +283,9 @@ public final class SuggestionController: NSWindowController {
NSEvent.removeMonitor(monitor)
localEventMonitor = nil
}
if let monitor = mouseEventMonitor {
NSEvent.removeMonitor(monitor)
mouseEventMonitor = nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ final class FindPanelHostingView: NSHostingView<FindPanelView> {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ final class JumpToDefinitionModel {
if let textView {
BezelNotification.show(symbolName: "questionmark", over: textView)
}
cancelHover()
return
}
if links.count == 1 {
Expand Down
23 changes: 0 additions & 23 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,29 +499,6 @@ extension QueryExecutionCoordinator {
wasSuccessful: false,
errorMessage: error.localizedDescription
)

let errorMessage = error.localizedDescription
let queryCopy = sql
Task { [weak self, parent] in
guard let self else { return }
if AppSettingsManager.shared.ai.enabled {
let wantsAIFix = await AlertHelper.showQueryErrorWithAIOption(
title: String(localized: "Query Execution Failed"),
message: errorMessage,
window: parent.contentWindow
)
if wantsAIFix {
parent.showAIChatPanel()
parent.aiViewModel?.handleFixError(query: queryCopy, error: errorMessage)
}
} else {
AlertHelper.showErrorSheet(
title: String(localized: "Query Execution Failed"),
message: errorMessage,
window: parent.contentWindow
)
}
}
}

func restoreSchemaAndRunQuery(_ schema: String) async {
Expand Down
24 changes: 0 additions & 24 deletions TablePro/Core/Utilities/UI/AlertHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
11 changes: 11 additions & 0 deletions TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,17 @@ struct ColumnLayoutState: Equatable {
var columnWidths: [String: CGFloat] = [:]
var columnOrder: [String]?
var hiddenColumns: Set<String> = []

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 {
Expand Down
47 changes: 43 additions & 4 deletions TablePro/Views/Filter/FilterValueTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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 Treat newlines as whitespace for filter completions

Raw SQL filter fields are multiline (allowsMultiLine: true), but this check only uses CharacterSet.whitespaces, which does not contain \n. When the cursor is at the start of a new/blank line, the function returns true and still asks the SQL-token provider for completions with an empty token, so the popup can appear on empty space despite this fix; use .whitespacesAndNewlines (or include .newlines) before offering token completion.

Useful? React with 👍 / 👎.

}

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 }
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 23 additions & 9 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,29 @@ struct MainEditorContentView: View {

// MARK: - Results Section

@ViewBuilder
private func executionErrorBanner(tab: QueryTab) -> some View {
if let error = tab.display.activeResultSet?.errorMessage ?? tab.execution.errorMessage {
InlineErrorBanner(
message: error,
onFixWithAI: AppSettingsManager.shared.ai.enabled && tab.tabType == .query

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 Fix with AI for table filter errors

When a table-tab filter/raw-filter query fails, the tab rendering this banner has tab.tabType == .table, so this condition makes onFixWithAI nil even when AI is enabled. The old execution-error flow offered the AI fix action for these failures, and this change says filter errors should keep that opt-in action; with a malformed filter the banner now only allows copy/dismiss. Allow table/filter execution errors to supply the action too, using the generated failed SQL/filter context.

Useful? React with 👍 / 👎.

? { coordinator.fixErrorWithAI(query: tab.content.query, error: error) }

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 Use the failed statement for Fix with AI

Fresh evidence in this revision is that the new banner callback passes the whole editor buffer, while runQuery() can execute only the selected text or the statement at the cursor. In a tab containing multiple statements, Fix with AI now receives unrelated SQL instead of the exact statement that failed, regressing the previous error handler's queryCopy = sql behavior; store the executed SQL with the error state and pass that here.

Useful? React with 👍 / 👎.

: nil,
onDismiss: {
tabManager.mutate(tabId: tab.id) {
$0.display.activeResultSet?.errorMessage = nil
$0.execution.errorMessage = nil
}
}
)
Divider()
}
}

@ViewBuilder
private func resultsSection(tab: QueryTab) -> some View {
VStack(spacing: 0) {
executionErrorBanner(tab: tab)
switch tab.display.resultsViewMode {
case .structure:
if let tableName = tab.tableContext.tableName {
Expand Down Expand Up @@ -452,14 +472,6 @@ struct MainEditorContentView: View {
Divider()
}

if let error = tab.display.activeResultSet?.errorMessage {
InlineErrorBanner(
message: error,
onDismiss: { tab.display.activeResultSet?.errorMessage = nil }
)
Divider()
}

let resolvedRows = resolvedTableRows(for: tab)
if let rs = tab.display.activeResultSet, rs.resultColumns.isEmpty,
rs.errorMessage == nil, tab.execution.lastExecutedAt != nil, !tab.execution.isExecuting
Expand Down Expand Up @@ -679,7 +691,9 @@ struct MainEditorContentView: View {
set: { newValue in
coordinator.isUpdatingColumnLayout = true
if let index = tabManager.selectedTabIndex {
tabManager.mutate(at: index) { $0.columnLayout = newValue }
tabManager.mutate(at: index) { tab in
tab.columnLayout.applyGeometry(from: newValue)
}
}
Task { @MainActor in
coordinator.isUpdatingColumnLayout = false
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,11 @@ final class MainContentCoordinator {
rightPanelState?.activeTab = .aiChat
}

func fixErrorWithAI(query: String, error: String) {
showAIChatPanel()
aiViewModel?.handleFixError(query: query, error: error)
}

/// Set up the plugin driver for query building dispatch on the query builder and change manager.
private func setupPluginDriver() {
guard let driver = services.databaseManager.driver(for: connectionId) else { return }
Expand Down
Loading
Loading