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 @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]

Expand Down Expand Up @@ -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
}
Expand All @@ -174,7 +180,7 @@ final class SuggestionViewModel: ObservableObject {
textView: activeTextView,
cursorPosition: activeTextView.cursorPositions.first
)
window?.close()
onApply?()
}

func willClose() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Combine
import SwiftUI

public final class SuggestionController: NSWindowController {
static var shared: SuggestionController = SuggestionController()
static var shared = SuggestionController()

// MARK: - Properties

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
7 changes: 0 additions & 7 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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("()") {
Expand Down
Loading