Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Pressing Escape to dismiss the SQL autocomplete popup no longer moves focus out of the editor, so you can keep typing. (#1845)

## [0.56.1] - 2026-07-09

### Removed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// TextViewController+Suggestions.swift
// CodeEditSourceEditor
//

import AppKit

public extension TextViewController {
/// Whether the completion popup is currently showing for this controller.
var isShowingCompletions: Bool {
SuggestionController.shared.isVisible && SuggestionController.shared.model.activeTextView === self
}

/// Dismisses the completion popup when it is showing for this controller.
/// Returns whether a popup was dismissed.
@discardableResult
func dismissCompletions() -> Bool {
guard isShowingCompletions else { return false }
SuggestionController.shared.close()
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -325,20 +325,19 @@ extension TextViewController {
}

private func handleShowCompletions(_ event: NSEvent) -> NSEvent? {
if let completionDelegate = self.completionDelegate,
let cursorPosition = cursorPositions.first {
if SuggestionController.shared.isVisible {
SuggestionController.shared.close()
return event
}
SuggestionController.shared.showCompletions(
textView: self,
delegate: completionDelegate,
cursorPosition: cursorPosition,
isManualTrigger: true
)
guard let completionDelegate = self.completionDelegate,
let cursorPosition = cursorPositions.first else {
return event
}
if dismissCompletions() {
return nil
}
return event
SuggestionController.shared.showCompletions(
textView: self,
delegate: completionDelegate,
cursorPosition: cursorPosition,
isManualTrigger: true
)
return nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import AppKit
@testable import CodeEditSourceEditor
import XCTest

final class TextViewControllerSuggestionsTests: XCTestCase {
@MainActor
func test_isShowingCompletions_falseWhenNothingShowing() {
let controller = Mock.textViewController(theme: Mock.theme())
defer { resetSharedSuggestions() }

XCTAssertFalse(controller.isShowingCompletions)
}

@MainActor
func test_dismissCompletions_returnsFalseWhenNothingShowing() {
let controller = Mock.textViewController(theme: Mock.theme())
defer { resetSharedSuggestions() }

XCTAssertFalse(controller.dismissCompletions())
}

@MainActor
func test_isShowingCompletions_trueOnlyForOwningController() {
let owner = Mock.textViewController(theme: Mock.theme())
let other = Mock.textViewController(theme: Mock.theme())
defer { resetSharedSuggestions() }

SuggestionController.shared.model.activeTextView = owner
SuggestionController.shared.window?.orderFrontRegardless()

XCTAssertTrue(owner.isShowingCompletions)
XCTAssertFalse(other.isShowingCompletions)
}

@MainActor
func test_dismissCompletions_closesPopupAndReturnsTrueWhenVisible() {
let owner = Mock.textViewController(theme: Mock.theme())
defer { resetSharedSuggestions() }

SuggestionController.shared.model.activeTextView = owner
SuggestionController.shared.window?.orderFrontRegardless()
XCTAssertTrue(owner.isShowingCompletions)

let dismissed = owner.dismissCompletions()

XCTAssertTrue(dismissed)
XCTAssertFalse(SuggestionController.shared.isVisible)
XCTAssertFalse(owner.isShowingCompletions)
}

@MainActor
private func resetSharedSuggestions() {
SuggestionController.shared.close()
SuggestionController.shared.model.activeTextView = nil
}
}
7 changes: 1 addition & 6 deletions TablePro/Core/Vim/VimKeyInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,6 @@ final class VimKeyInterceptor {
}

private func closeSuggestionPopup() {
guard let window = controller?.textView.window else { return }
for childWindow in window.childWindows ?? [] {
if childWindow.windowController is SuggestionController {
childWindow.windowController?.close()
}
}
controller?.dismissCompletions()
}
}
10 changes: 6 additions & 4 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ struct PasteboardCommands: Commands {
.optionalKeyboardShortcut(shortcut(for: .selectAll))

Button("Clear Selection") {
// Route the Esc key equivalent to Vim first when the active editor is
// in a non-normal mode — the menu shortcut otherwise preempts the
// local event monitor and Vim never sees the keystroke.
if !EditorEventRouter.shared.handleVimEscapeFromMenu() {
// The bare-Escape key equivalent preempts the editor's local event
// monitors. Route it to the focused editor first so it dismisses the
// autocomplete popup, exits Vim insert mode, and keeps the caret in the
// editor. Fall back to clearing the data-grid selection only when no
// editor handled it.
if !EditorEventRouter.shared.handleEscapeFromMenu() {
NSApp.sendAction(#selector(NSResponder.cancelOperation(_:)), to: nil, from: nil)
}
}
Expand Down
15 changes: 9 additions & 6 deletions TablePro/Views/Editor/EditorEventRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,17 @@ internal final class EditorEventRouter {
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
/// normal cancelOperation fallback in that case.
/// Called by the SwiftUI "Clear Selection" menu when its bare-Escape key equivalent
/// fires. A bare-key menu equivalent preempts every local event monitor in the key
/// window, so the focused editor's completion popup, Vim interceptor, and
/// first-responder handling never see the keystroke. Routes it to the key window's
/// editor so it can dismiss its completion popup, hand the escape to Vim, and restore
/// first responder. Returns whether the editor consumed the escape so the caller
/// skips its cancelOperation fallback (the data grid's Clear Selection).
@discardableResult
internal func handleVimEscapeFromMenu() -> Bool {
internal func handleEscapeFromMenu() -> Bool {
guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return false }
return coordinator.handleVimEscapeFromMenu()
return coordinator.handleEscapeFromMenu()
}

// MARK: - Lookup
Expand Down
40 changes: 35 additions & 5 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -458,15 +458,45 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
}
}

// MARK: - Vim External Escape Routing
// MARK: - Menu Escape Routing

/// Called by `EditorEventRouter.handleEscapeFromMenu()` when the "Clear Selection"
/// menu item's bare-Escape key equivalent fires. That key equivalent preempts the
/// editor's local event monitors, so the completion popup, Vim, and first-responder
/// handling that would normally run on Escape never do. Dismisses an open completion
/// popup, hands the keystroke to Vim when it is mid-command, and restores first
/// responder and the caret when this editor was the focused surface. Returns whether
/// the editor consumed the escape so the menu skips its cancelOperation fallback.
@discardableResult
func handleEscapeFromMenu() -> Bool {
let wasFocused = wasEditorFocused
controller?.dismissCompletions()
let vimHandled = handleVimEscapeFromMenu()

if wasFocused {
reclaimFirstResponder()
}

return wasFocused || vimHandled

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 Return consumed only for current editor focus

When the editor focus cache is stale, this returns true solely because wasEditorFocused was previously true, so the Clear Selection menu skips the cancelOperation fallback even if focus has already moved to the data grid. wasEditorFocused is updated later via the window update observer, so a quick click from the editor into a selected grid followed by Escape can be swallowed here and even reclaim editor focus instead of clearing the grid selection, contradicting the intended grid-focused behavior.

Useful? React with 👍 / 👎.

}

/// Called by the menu's "Clear Selection" (Esc) shortcut so a SwiftUI key
/// equivalent that preempts the local event monitor still flips Vim back to
/// normal mode instead of getting silently swallowed.
func handleVimEscapeFromMenu() -> Bool {
private func handleVimEscapeFromMenu() -> Bool {
vimKeyInterceptor?.handleEscapeFromExternalSource() ?? false
}

/// `TextView.resignFirstResponder()` calls `removeCursors()`, destroying the caret
/// subview, and `makeFirstResponder` alone does not recreate it (only a window-level
/// `didBecomeKeyNotification` does, which does not fire for an in-window first
/// responder change). Re-applying `cursorPositions` rebuilds the cursor views now
/// that the text view is first responder again.
private func reclaimFirstResponder() {
guard let controller, let textView = controller.textView, let window = textView.window,
window.firstResponder !== textView else { return }
guard window.makeFirstResponder(textView) else { return }
guard !controller.cursorPositions.isEmpty else { return }
controller.setCursorPositions(controller.cursorPositions)
}

// MARK: - First Responder Tracking

func checkFirstResponderChange() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// SQLEditorCoordinatorEscapeMenuTests.swift
// TableProTests
//
// Tests for SQLEditorCoordinator.handleEscapeFromMenu() routing.
//

import Foundation
@testable import TablePro
import Testing

@MainActor
@Suite("SQLEditorCoordinator menu escape")
struct SQLEditorCoordinatorEscapeMenuTests {
@Test("handleEscapeFromMenu returns false when no editor is focused")
func returnsFalseWhenNothingToHandle() {
let coordinator = SQLEditorCoordinator()
#expect(coordinator.handleEscapeFromMenu() == false)
}

@Test("handleEscapeFromMenu stays false after destroy so the grid keeps Escape")
func staysFalseAfterDestroy() {
let coordinator = SQLEditorCoordinator()
coordinator.destroy()
#expect(coordinator.handleEscapeFromMenu() == false)
}
}
2 changes: 1 addition & 1 deletion docs/features/autocomplete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Autocomplete suggests keywords, tables, columns, and functions based on where yo
/>
</Frame>

Suggestions appear after 2 characters, after `.`, or after keywords like SELECT/FROM/JOIN. 50ms debounce. Press `Escape` to dismiss.
Suggestions appear after 2 characters, after `.`, or after keywords like SELECT/FROM/JOIN. 50ms debounce. Press `Escape` to dismiss and keep typing.

## Completion Types

Expand Down
Loading