Skip to content
Open
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 @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Opening a new query tab (Cmd+T, or opening a saved query or .sql file in its own window) now puts the keyboard focus in the SQL editor instead of the sidebar filter field, so you can start typing right away. (#1765)
- Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529)
- Connecting to Oracle no longer crashes the app while reading certain server values during the handshake; a bad packet now fails the connection with an error instead. (#1746)
- Following a foreign key into a table in another schema now opens the correct table for SQL Server and Oracle; the referenced schema was missing, so navigation fell back to the default schema. (#1754)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ enum SessionStateFactory {
initialQuery: payload.initialQuery,
title: payload.tabTitle,
databaseName: payload.databaseName ?? activeDatabaseName,
sourceFileURL: payload.sourceFileURL
sourceFileURL: payload.sourceFileURL,
claimFocus: true
)
}
case .createTable:
Expand All @@ -154,7 +155,8 @@ enum SessionStateFactory {
tabMgr.addTab(
initialQuery: payload.initialQuery,
title: title,
databaseName: payload.databaseName ?? activeDatabaseName
databaseName: payload.databaseName ?? activeDatabaseName,
claimFocus: true
)
case .restoreOrDefault:
break
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class QueryTabManager {

var tabStructureVersion: Int = 0

@ObservationIgnored var pendingFocusTabId: UUID?

@ObservationIgnored private var _tabIndexMap: [UUID: Int] = [:]
@ObservationIgnored private var _tabIndexMapDirty = true

Expand Down Expand Up @@ -103,7 +105,7 @@ final class QueryTabManager {

// MARK: - Tab Management

func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil) {
func addTab(initialQuery: String? = nil, title: String? = nil, databaseName: String = "", sourceFileURL: URL? = nil, claimFocus: Bool = false) {
if let sourceFileURL,
let existingIndex = tabs.firstIndex(where: { $0.content.sourceFileURL == sourceFileURL }) {
if let query = initialQuery {
Expand Down Expand Up @@ -136,6 +138,9 @@ final class QueryTabManager {
}
tabs.append(newTab)
selectedTabId = newTab.id
if claimFocus {
pendingFocusTabId = newTab.id
}
}

func addTableTab(
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Editor/QueryEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct QueryEditorView: View {
var connectionId: UUID?
var connectionAIPolicy: AIConnectionPolicy?
var tabID: UUID?
var claimFocusOnAppear: Bool = false
var onCloseTab: (() -> Void)?
var onExecuteQuery: (() -> Void)?
var onExplain: ((ClickHouseExplainVariant?) -> Void)?
Expand Down Expand Up @@ -59,6 +60,7 @@ struct QueryEditorView: View {
connectionId: connectionId,
connectionAIPolicy: connectionAIPolicy,
tabID: tabID,
claimFocusOnAppear: claimFocusOnAppear,
vimMode: $vimMode,
onCloseTab: onCloseTab,
onExecuteQuery: onExecuteQuery,
Expand Down
21 changes: 15 additions & 6 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,17 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
@ObservationIgnored private var isUppercasing = false
@ObservationIgnored private var wasEditorFocused = false
@ObservationIgnored private var didDestroy = false
@ObservationIgnored private var focusClaimPending = false

/// Test-only accessor for destroy state
var isDestroyed: Bool { didDestroy }

var pendingFocusClaim: Bool { focusClaimPending }

func scheduleEditorFocusClaim() {
focusClaimPending = true
}

/// Vim mode for UI observation
private(set) var vimMode: VimMode = .normal
@ObservationIgnored private var vimEngine: VimEngine?
Expand Down Expand Up @@ -110,12 +117,13 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
if let textView = controller.textView {
EditorEventRouter.shared.register(self, textView: textView)

// Auto-focus: make the editor first responder, then ensure a
// cursor exists. Order matters — setCursorPositions calls
// updateSelectionViews which guards on isFirstResponder.
if !self.isDestroyed, let window = textView.window,
window.firstResponder == nil || window.firstResponder === window {
window.makeFirstResponder(textView)
if !self.isDestroyed, let window = textView.window {
if self.focusClaimPending {
self.focusClaimPending = false
window.makeFirstResponder(textView)
} else if window.firstResponder == nil || window.firstResponder === window {
window.makeFirstResponder(textView)
}
}
if controller.cursorPositions.isEmpty {
controller.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 0))])
Expand Down Expand Up @@ -176,6 +184,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {

func destroy() {
didDestroy = true
focusClaimPending = false

uninstallVimKeyInterceptor()

Expand Down
6 changes: 6 additions & 0 deletions TablePro/Views/Editor/SQLEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct SQLEditorView: View {
var connectionId: UUID?
var connectionAIPolicy: AIConnectionPolicy?
var tabID: UUID?
var claimFocusOnAppear: Bool = false
@Binding var vimMode: VimMode
var onCloseTab: (() -> Void)?
var onExecuteQuery: (() -> Void)?
Expand Down Expand Up @@ -59,6 +60,11 @@ struct SQLEditorView: View {
completionDelegate: completionAdapter
)
.accessibilityLabel(String(localized: "SQL query editor"))
.onAppear {
if claimFocusOnAppear {
coordinator.scheduleEditorFocusClaim()
}
}
.onChange(of: editorState.cursorPositions) { _, newValue in
guard let positions = newValue else { return }
// Skip cursor propagation when the editor doesn't have focus
Expand Down
9 changes: 8 additions & 1 deletion TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ struct MainEditorContentView: View {
@ViewBuilder
private func queryTabContent(tab: QueryTab) -> some View {
@Bindable var bindableCoordinator = coordinator
let claimFocus = coordinator.tabManager.pendingFocusTabId == tab.id
QuerySplitView(
isBottomCollapsed: tab.display.isResultsCollapsed,
autosaveName: "QuerySplit-\(connectionId)-\(tab.id)",
Expand All @@ -291,6 +292,7 @@ struct MainEditorContentView: View {
connectionId: coordinator.connection.id,
connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy,
tabID: tab.id,
claimFocusOnAppear: claimFocus,
onCloseTab: {
NSApp.keyWindow?.close()
},
Expand Down Expand Up @@ -327,7 +329,12 @@ struct MainEditorContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
)
.onAppear { coordinator.applyRestoredCursor(for: tab.id) }
.onAppear {
coordinator.applyRestoredCursor(for: tab.id)
if coordinator.tabManager.pendingFocusTabId == tab.id {
coordinator.tabManager.pendingFocusTabId = nil
}
}
}

private func reloadFileForTab(tabId: UUID, url: URL) {
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ final class MainContentCommandActions {
if let coordinator, coordinator.tabManager.tabs.isEmpty {
coordinator.tabManager.addTab(
initialQuery: resolvedQuery,
databaseName: coordinator.activeDatabaseName
databaseName: coordinator.activeDatabaseName,
claimFocus: true
)
return
}
Expand Down
64 changes: 64 additions & 0 deletions TableProTests/Models/Query/QueryTabManagerFocusTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// QueryTabManagerFocusTests.swift
// TableProTests
//
// Locks the contract for pendingFocusTabId, the one-shot signal that tells
// the editor view layer a newly created query tab should claim keyboard focus.
// Only addTab(claimFocus:) sets it; tab restoration and table tabs never do.
//

import Foundation
@testable import TablePro
import TableProPluginKit
import Testing

@Suite("QueryTabManager editor focus claim")
@MainActor
struct QueryTabManagerFocusTests {
@Test("addTab with claimFocus sets pendingFocusTabId to the new tab")
func addTabWithClaimFocusSetsPendingId() {
let manager = QueryTabManager()
manager.addTab(claimFocus: true)
#expect(manager.pendingFocusTabId == manager.tabs.last?.id)
}

@Test("addTab without claimFocus leaves pendingFocusTabId nil")
func addTabWithoutClaimFocusLeavesNil() {
let manager = QueryTabManager()
manager.addTab()
#expect(manager.pendingFocusTabId == nil)
}

@Test("second addTab with claimFocus updates pendingFocusTabId to the latest tab")
func secondAddTabWithClaimFocusUpdatesToLatest() {
let manager = QueryTabManager()
manager.addTab(claimFocus: true)
manager.addTab(claimFocus: true)
#expect(manager.pendingFocusTabId == manager.tabs.last?.id)
}

@Test("opening a new file-backed tab with claimFocus sets pendingFocusTabId")
func newFileTabWithClaimFocusSetsPendingId() {
let manager = QueryTabManager()
let url = URL(fileURLWithPath: "/tmp/tablepro-focus-new.sql")
manager.addTab(initialQuery: "SELECT 1", sourceFileURL: url, claimFocus: true)
#expect(manager.pendingFocusTabId == manager.tabs.last?.id)
}

@Test("reopening an existing file tab does not set pendingFocusTabId")
func fileURLDeduplicationDoesNotSetFocusId() {
let manager = QueryTabManager()
let url = URL(fileURLWithPath: "/tmp/tablepro-focus-test.sql")
manager.addTab(sourceFileURL: url, claimFocus: true)
manager.pendingFocusTabId = nil
manager.addTab(sourceFileURL: url, claimFocus: true)
#expect(manager.pendingFocusTabId == nil)
}

@Test("addTableTab does not set pendingFocusTabId")
func addTableTabDoesNotSetFocusId() throws {
let manager = QueryTabManager()
try manager.addTableTab(tableName: "users")
#expect(manager.pendingFocusTabId == nil)
}
}
23 changes: 22 additions & 1 deletion TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//

import Foundation
import TableProPluginKit
@testable import TablePro
import TableProPluginKit
import Testing

@MainActor
Expand Down Expand Up @@ -41,4 +41,25 @@ struct SQLEditorCoordinatorTests {
coordinator.destroy()
#expect(coordinator.vimMode == .normal)
}

@Test("pendingFocusClaim starts false on a fresh coordinator")
func freshCoordinatorHasNoPendingClaim() {
let coordinator = SQLEditorCoordinator()
#expect(coordinator.pendingFocusClaim == false)
}

@Test("scheduleEditorFocusClaim() sets pendingFocusClaim to true")
func scheduleEditorFocusClaimSetsLatch() {
let coordinator = SQLEditorCoordinator()
coordinator.scheduleEditorFocusClaim()
#expect(coordinator.pendingFocusClaim == true)
}

@Test("scheduleEditorFocusClaim() is idempotent")
func scheduleEditorFocusClaimIsIdempotent() {
let coordinator = SQLEditorCoordinator()
coordinator.scheduleEditorFocusClaim()
coordinator.scheduleEditorFocusClaim()
#expect(coordinator.pendingFocusClaim == true)
}
}
Loading