diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff5f92b3..788742383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - AI Chat: GitHub Copilot tool registration was failing with "Expected string" schema validation errors. Optional fields now register with `type: "string"` instead of `type: ["string", "null"]` and are excluded from `required`, which Copilot's LSP validator accepts. - MySQL/MariaDB: `BIT(N)` columns now display as decimal numbers (`0`, `1`, `255`) instead of raw bytes that showed up as control characters like `^A` in the data grid. (#1272) - Structure tab: Refresh and ⌘R now show external schema changes immediately. Previously a column renamed from another session stayed stale on the Columns and DDL sub-tabs until the user switched tabs, and the ClickHouse Parts sub-tab ignored Refresh entirely. (#1281) +- Query editor: Enter and ⌘+Enter now work after picking a table from the autocomplete dropdown. The completion window was leaving its key event monitor installed after acceptance, so subsequent Enter presses kept re-applying the same suggestion. Double-clicking a suggestion no longer closes the editor window. (#1278) - Query editor autocomplete now reflects external column renames after Refresh. Previously the schema cache kept suggesting the old column name until the connection was reopened. - ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267) - AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash. diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index 7634957e4..fa739a23a 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -22,6 +22,12 @@ final class SuggestionViewModel: ObservableObject { weak var delegate: CodeSuggestionDelegate? + /// Invoked after a successful apply so the owning controller can dismiss the + /// suggestion window through its own ``close()`` override (which performs the + /// monitor and state cleanup). Bypassing this and calling `NSWindow.close()` + /// directly leaves the local key monitor installed. + var onApply: (() -> Void)? + private var cursorPosition: CursorPosition? private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] @@ -165,7 +171,7 @@ final class SuggestionViewModel: ObservableObject { delegate?.completionWindowDidSelect(item: item) } - func applySelectedItem(item: CodeSuggestionEntry, window: NSWindow?) { + func applySelectedItem(item: CodeSuggestionEntry) { guard let activeTextView else { return } @@ -174,7 +180,7 @@ final class SuggestionViewModel: ObservableObject { textView: activeTextView, cursorPosition: activeTextView.cursorPositions.first ) - window?.close() + onApply?() } func willClose() { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift index 301e7e914..a6d276b0c 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift @@ -54,10 +54,7 @@ struct SuggestionContentView: View { .onTapGesture(count: 2) { model.selectedIndex = index if let selectedItem = model.selectedItem { - model.applySelectedItem( - item: selectedItem, - window: model.activeTextView?.view.window - ) + model.applySelectedItem(item: selectedItem) } } .listRowInsets(EdgeInsets()) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index 54bef5957..cd65a6bcb 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -11,7 +11,7 @@ import Combine import SwiftUI public final class SuggestionController: NSWindowController { - static var shared: SuggestionController = SuggestionController() + static var shared = SuggestionController() // MARK: - Properties @@ -20,7 +20,7 @@ public final class SuggestionController: NSWindowController { window?.isVisible ?? false || popover?.isShown ?? false } - var model: SuggestionViewModel = SuggestionViewModel() + var model = SuggestionViewModel() // MARK: - Private Properties @@ -51,6 +51,15 @@ public final class SuggestionController: NSWindowController { let hostingView = NSHostingView(rootView: contentView) window.contentView = hostingView + model.onApply = { [weak self] in self?.close() } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWindowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: window + ) + // Resize window when items change model.$items .receive(on: DispatchQueue.main) @@ -160,8 +169,24 @@ public final class SuggestionController: NSWindowController { window.orderFront(nil) } - /// Close the window - public override func close() { + /// Close the window. Cleanup is performed by ``handleWindowWillClose(_:)`` + /// which fires off `NSWindow.willCloseNotification`. Routing through the + /// notification means cleanup is idempotent and runs even when callers + /// invoke `window.close()` on the underlying `NSWindow` directly. + override public func close() { + if popover != nil { + popover?.close() + popover = nil + } + super.close() + } + + @objc private func handleWindowWillClose(_ notification: Notification) { + guard (notification.object as AnyObject?) === window else { return } + performCleanup() + } + + private func performCleanup() { model.willClose() removeEventMonitors() @@ -172,13 +197,6 @@ public final class SuggestionController: NSWindowController { firstResponderKVO?.invalidate() firstResponderKVO = nil - - if popover != nil { - popover?.close() - popover = nil - } - - super.close() } // MARK: - Cursors Updated @@ -233,7 +251,7 @@ public final class SuggestionController: NSWindowController { return nil case 36, 48: // Return, Tab if let item = self.model.selectedItem { - self.model.applySelectedItem(item: item, window: self.window) + self.model.applySelectedItem(item: item) } return nil default: diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift new file mode 100644 index 000000000..a25177843 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift @@ -0,0 +1,116 @@ +import AppKit +@testable import CodeEditSourceEditor +import SwiftUI +import XCTest + +final class SuggestionApplyTests: XCTestCase { + @MainActor + func test_apply_invokesOnApplyCallback() throws { + let model = SuggestionViewModel() + let textViewController = Mock.textViewController(theme: Mock.theme()) + let delegate = StubSuggestionDelegate() + let item = StubSuggestionEntry(label: "users") + + model.activeTextView = textViewController + model.delegate = delegate + model.items = [item] + model.selectedIndex = 0 + + var onApplyCount = 0 + model.onApply = { onApplyCount += 1 } + + model.applySelectedItem(item: item) + + XCTAssertEqual(onApplyCount, 1) + XCTAssertEqual(delegate.applyCallCount, 1) + } + + @MainActor + func test_apply_skipsCallbackWhenActiveTextViewIsNil() throws { + let model = SuggestionViewModel() + let delegate = StubSuggestionDelegate() + let item = StubSuggestionEntry(label: "users") + + model.delegate = delegate + model.items = [item] + + var onApplyCount = 0 + model.onApply = { onApplyCount += 1 } + + model.applySelectedItem(item: item) + + XCTAssertEqual(onApplyCount, 0) + XCTAssertEqual(delegate.applyCallCount, 0) + } + + @MainActor + func test_nsWindowClose_clearsModelState() throws { + let controller = SuggestionController() + let textViewController = Mock.textViewController(theme: Mock.theme()) + + controller.model.activeTextView = textViewController + controller.model.delegate = StubSuggestionDelegate() + controller.model.items = [StubSuggestionEntry(label: "users")] + controller.model.selectedIndex = 0 + + controller.window?.close() + + XCTAssertNil(controller.model.activeTextView) + XCTAssertTrue(controller.model.items.isEmpty) + } + + @MainActor + func test_controllerClose_clearsModelState() throws { + let controller = SuggestionController() + let textViewController = Mock.textViewController(theme: Mock.theme()) + + controller.model.activeTextView = textViewController + controller.model.delegate = StubSuggestionDelegate() + controller.model.items = [StubSuggestionEntry(label: "users")] + controller.model.selectedIndex = 0 + + controller.close() + + XCTAssertNil(controller.model.activeTextView) + XCTAssertTrue(controller.model.items.isEmpty) + } +} + +private final class StubSuggestionDelegate: CodeSuggestionDelegate { + var applyCallCount = 0 + + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition, + isManualTrigger: Bool + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + nil + } + + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + nil + } + + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) { + applyCallCount += 1 + } +} + +private struct StubSuggestionEntry: 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 } +} diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index 44674c698..e2fb27bc1 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -165,9 +165,6 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { guard offset >= prefixStart, offset <= docLength else { return nil } let prefixLength = offset - prefixStart - // Guard against stale replacementRange producing an unreasonably - // large prefix read. Normal prefixes are <200 chars even for - // qualified identifiers (schema.table.column). guard prefixLength > 0, prefixLength <= 500 else { return nil } let prefixRange = NSRange(location: prefixStart, length: prefixLength) @@ -191,20 +188,16 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { suppressNextCompletion = true - // Extend replacement range from original start to current cursor position, - // since the user may have typed more characters since completions were triggered. let originalStart = context.replacementRange.location let currentEnd = cursorPosition?.range.location ?? (originalStart + context.replacementRange.length) let replaceRange = NSRange(location: originalStart, length: currentEnd - originalStart) let insertText = entry.item.insertText - // Replace text in the text view textView.textView.replaceCharacters( in: [replaceRange], with: insertText ) - // Move cursor: for function completions ending with "()", place cursor between parens let insertLength = (insertText as NSString).length let newPosition: Int if insertText.hasSuffix("()") {