diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcb8881e..968c356cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index f397de422..154746990 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -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: @@ -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 diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index ff2ca8008..edde7a549 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -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 @@ -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 { @@ -136,6 +138,9 @@ final class QueryTabManager { } tabs.append(newTab) selectedTabId = newTab.id + if claimFocus { + pendingFocusTabId = newTab.id + } } func addTableTab( diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index a38076781..088787532 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -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)? @@ -59,6 +60,7 @@ struct QueryEditorView: View { connectionId: connectionId, connectionAIPolicy: connectionAIPolicy, tabID: tabID, + claimFocusOnAppear: claimFocusOnAppear, vimMode: $vimMode, onCloseTab: onCloseTab, onExecuteQuery: onExecuteQuery, diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 9e8be214f..e48062ad1 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -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? @@ -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))]) @@ -176,6 +184,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { func destroy() { didDestroy = true + focusClaimPending = false uninstallVimKeyInterceptor() diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 528dda2be..e32e21d31 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -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)? @@ -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 diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 99f9089c9..d6119cf52 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -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)", @@ -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() }, @@ -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) { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 1e390a22d..d3969bf39 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -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 } diff --git a/TableProTests/Models/Query/QueryTabManagerFocusTests.swift b/TableProTests/Models/Query/QueryTabManagerFocusTests.swift new file mode 100644 index 000000000..981041e7b --- /dev/null +++ b/TableProTests/Models/Query/QueryTabManagerFocusTests.swift @@ -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) + } +} diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift index ef5bd4f12..4e3472861 100644 --- a/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorTests.swift @@ -6,8 +6,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @MainActor @@ -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) + } }