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

### Fixed

- Fixed the SQL editor in a new tab losing keyboard focus after the first couple of characters, which stopped further typing until you clicked back into the editor. (#1885)
- Cancelling a SQL Server connection now stops waiting right away instead of staying stuck until the attempt finished on its own. A connection attempt also gives up after the login timeout with a clear message rather than hanging, and for Windows Authentication the message points at the usual Kerberos causes (KDC unreachable, missing SPN, or clock skew). (#1889)
- Fixed MySQL and MariaDB queries ending in ORDER BY that could show an empty result with no error. A failure part way through reading rows was treated as a normal end of results, so a real server error looked like a table with no rows. TablePro now shows the error the server reported. (#1884)
- Fixed the query result row cap returning a single row when it was set to unlimited. (#1884)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ final class SuggestionViewModel: ObservableObject {
self.delegate = nil
itemsRequestTask?.cancel()

guard let targetParentWindow = textView.view.window else {
guard textView.view.window != nil else {
Self.logger.warning("showCompletions: textView.view.window is nil")
return
}
Expand All @@ -121,22 +121,31 @@ final class SuggestionViewModel: ObservableObject {
try await MainActor.run {
try Task.checkCancellation()

guard let window = textView.view.window,
window.isKeyWindow,
let responder = window.firstResponder as? NSView,
responder.isDescendant(of: textView.view) else {
Self.logger.debug("showCompletions: editor lost focus while completions were loading")
return
Comment on lines +124 to +129

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 Reset stale completions when dropping unfocused presentations

When the async request finishes after focus has moved away, this new guard returns without clearing activeTextView/delegate or resetting the delegate's completion session. In the SQL editor, SQLCompletionAdapter.completionSuggestionsRequested has already cached its final session before returning; if the user clicks back into the same editor and keeps typing, cursorsUpdated sees the same active text view, gets filtered items from completionOnCursorMove, and returns without invoking the presentIfNot path, so the popup stays hidden until something else resets the session. Please route this drop path through the same cleanup as closing/dismissing the request.

Useful? React with 👍 / 👎.

}

guard let cursorPosition = textView.resolveCursorPosition(completionItems.windowPosition),
let cursorRect = textView.textView.layoutManager.rectForOffset(
cursorPosition.range.location
),
let cursorRect = textView.view.window?.convertToScreen(
textView.textView.convert(cursorRect, to: nil)
) else {
Self.logger.warning("showCompletions: cursor rect resolution failed")
return
}

let screenCursorRect = window.convertToScreen(
textView.textView.convert(cursorRect, to: nil)
)

self.items = completionItems.items
self.selectedIndex = 0
self.syntaxHighlightedCache = [:]
self.notifySelection()
showWindowOnParent(targetParentWindow, cursorRect)
showWindowOnParent(window, screenCursorRect)
}
} catch {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import AppKit
import SwiftUI

struct SuggestionContentView: View {
static let rowHeight: CGFloat = 26

@ObservedObject var model: SuggestionViewModel

var body: some View {
Expand Down Expand Up @@ -38,43 +40,20 @@ struct SuggestionContentView: View {
}
}

/// Rows live in a plain `ScrollView`/`LazyVStack`, never a `List`. `List` is backed by a
/// focusable `NSTableView`, and AppKit's attempt to focus it when the panel appears asked
/// the panel to become key, resigning the editor window's key status with no successor
/// (issue #1885). Selection, arrow keys, and taps are all model-driven, so the popup's
/// content must stay display-only chrome with no focus participation at all.
private var suggestionList: some View {
ScrollViewReader { proxy in
List {
ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in
CodeSuggestionLabelView(
suggestion: item,
labelColor: model.themeTextColor,
secondaryLabelColor: model.themeTextColor.withAlphaComponent(0.5),
font: model.activeTextView?.font ?? .systemFont(ofSize: 12),
isSelected: index == model.selectedIndex
)
.lineLimit(1)
.truncationMode(.tail)
.contentShape(Rectangle())
.onTapGesture(count: 1) {
model.selectedIndex = index
}
.onTapGesture(count: 2) {
model.selectedIndex = index
if let selectedItem = model.selectedItem {
model.applySelectedItem(item: selectedItem)
}
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in
suggestionRow(index: index, item: item)
}
.listRowInsets(EdgeInsets())
.listRowBackground(
RoundedRectangle(cornerRadius: 5)
.fill(index == model.selectedIndex
? Color(nsColor: .selectedContentBackgroundColor)
: Color.clear)
.padding(.horizontal, SuggestionController.WINDOW_PADDING)
)
.listRowSeparator(.hidden)
.id(index)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.padding(.vertical, SuggestionController.WINDOW_PADDING)
.frame(height: listMaxHeight)
.onChange(of: model.selectedIndex) { newIndex in
Expand All @@ -85,6 +64,40 @@ struct SuggestionContentView: View {
}
}

private func suggestionRow(index: Int, item: CodeSuggestionEntry) -> some View {
CodeSuggestionLabelView(
suggestion: item,
labelColor: model.themeTextColor,
secondaryLabelColor: model.themeTextColor.withAlphaComponent(0.5),
font: model.activeTextView?.font ?? .systemFont(ofSize: 12),
isSelected: index == model.selectedIndex
)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Self.rowHeight)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(index == model.selectedIndex
? Color(nsColor: .selectedContentBackgroundColor)
: Color.clear)
.padding(.horizontal, SuggestionController.WINDOW_PADDING)
)
.contentShape(Rectangle())
.onTapGesture(count: 1) {
model.selectedIndex = index
}
.onTapGesture(count: 2) {
model.selectedIndex = index
if let selectedItem = model.selectedItem {
model.applySelectedItem(item: selectedItem)
}
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(index == model.selectedIndex ? .isSelected : [])
.id(index)
}

private var contentWidth: CGFloat {
let font = model.activeTextView?.font ?? NSFont.systemFont(ofSize: 12)
let iconWidth = font.pointSize + 6
Expand All @@ -101,9 +114,8 @@ struct SuggestionContentView: View {
}

private var listMaxHeight: CGFloat {
let rowHeight: CGFloat = 26
let visibleRows = min(CGFloat(model.items.count), SuggestionController.MAX_VISIBLE_ROWS)
return rowHeight * visibleRows + SuggestionController.WINDOW_PADDING * 2
return Self.rowHeight * visibleRows + SuggestionController.WINDOW_PADDING * 2
}

private var noCompletionsView: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,15 @@ public final class SuggestionController: NSWindowController {
}
}

/// Opens the window as a child of another window.
/// Opens the window as a child of another window. The panel is ordered front without ever
/// being made key or main: `orderFront(_:)` is documented not to change either status, and
/// `NSWindowController.showWindow(_:)` is deliberately avoided because its default
/// implementation routes through `makeKeyAndOrderFront(_:)`.
public func showWindow(attachedTo parentWindow: NSWindow) {
guard let window = window else { return }
if let currentParent = window.parent, currentParent !== parentWindow {
currentParent.removeChildWindow(window)
}
parentWindow.addChildWindow(window, ordered: .above)

if let existingObserver = windowResignObserver {
Expand Down Expand Up @@ -179,7 +185,6 @@ public final class SuggestionController: NSWindowController {
}

setupEventMonitors()
super.showWindow(nil)
window.orderFront(nil)
}

Expand All @@ -204,6 +209,10 @@ public final class SuggestionController: NSWindowController {
model.willClose()
removeEventMonitors()

if let window, let parent = window.parent {
parent.removeChildWindow(window)
}

if let observer = windowResignObserver {
NotificationCenter.default.removeObserver(observer)
windowResignObserver = nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import AppKit
@testable import CodeEditSourceEditor
import SwiftUI
import XCTest

final class SuggestionPanelFocusTests: XCTestCase {
@MainActor
func test_panel_neverAcceptsKeyOrMainStatus() throws {
let controller = SuggestionController()
let window = try XCTUnwrap(controller.window)

XCTAssertFalse(window.canBecomeKey)
XCTAssertFalse(window.canBecomeMain)
XCTAssertTrue(window.styleMask.contains(.nonactivatingPanel))
XCTAssertEqual((window as? NSPanel)?.becomesKeyOnlyIfNeeded, true)
XCTAssertEqual(window.tabbingMode, .disallowed)
}

@MainActor
func test_contentViewHierarchy_containsNoFocusableTableView() throws {
let controller = SuggestionController()
let window = try XCTUnwrap(controller.window)

controller.model.items = [PanelStubEntry(label: "SELECT"), PanelStubEntry(label: "SET")]
window.setContentSize(NSSize(width: 300, height: 200))
window.orderFrontRegardless()
defer { window.close() }
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2))

let contentView = try XCTUnwrap(window.contentView)
XCTAssertNil(firstFocusableTableView(in: contentView))
}

@MainActor
func test_showWindowAttachedTo_preservesParentFirstResponder() throws {
let (parentWindow, textViewController) = Mock.windowedTextViewController(theme: Mock.theme())
let controller = SuggestionController()
defer {
controller.close()
parentWindow.close()
}

parentWindow.orderFrontRegardless()
let textView = try XCTUnwrap(textViewController.textView)
XCTAssertTrue(parentWindow.makeFirstResponder(textView))

controller.model.items = [PanelStubEntry(label: "SELECT")]
controller.showWindow(attachedTo: parentWindow)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2))

XCTAssertTrue(parentWindow.firstResponder === textView)
}

@MainActor
func test_showWindowAttachedTo_reattachesFromPreviousParent() throws {
let (firstParent, _) = Mock.windowedTextViewController(theme: Mock.theme())
let (secondParent, _) = Mock.windowedTextViewController(theme: Mock.theme())
let controller = SuggestionController()
let window = try XCTUnwrap(controller.window)
defer {
controller.close()
firstParent.close()
secondParent.close()
}

controller.showWindow(attachedTo: firstParent)
XCTAssertTrue(window.parent === firstParent)

controller.showWindow(attachedTo: secondParent)
XCTAssertTrue(window.parent === secondParent)
XCTAssertFalse(firstParent.childWindows?.contains(window) ?? false)
}

@MainActor
func test_close_detachesPanelFromParent() throws {
let (parentWindow, _) = Mock.windowedTextViewController(theme: Mock.theme())
let controller = SuggestionController()
let window = try XCTUnwrap(controller.window)
defer { parentWindow.close() }

controller.showWindow(attachedTo: parentWindow)
XCTAssertTrue(window.parent === parentWindow)

controller.close()

XCTAssertNil(window.parent)
XCTAssertFalse(parentWindow.childWindows?.contains(window) ?? false)
}

@MainActor
private func firstFocusableTableView(in view: NSView) -> NSTableView? {
if let tableView = view as? NSTableView {
return tableView
}
for subview in view.subviews {
if let found = firstFocusableTableView(in: subview) {
return found
}
}
return nil
}
}

private struct PanelStubEntry: CodeSuggestionEntry {
var label: String
var detail: String? { nil }
var documentation: String? { nil }
var pathComponents: [String]? { nil }
var targetPosition: CursorPosition? { nil }
var sourcePreview: String? { nil }
var image: Image { Image(systemName: "circle") }
var imageColor: Color { .gray }
var deprecated: Bool { false }
}
Loading
Loading