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

### Fixed

- Opening the connection or database switcher now puts the cursor in its search field even while a filter input is being edited; the filter text is kept. (#1575)
- TablePro no longer shows its icon for .sql, .sqlite, and .duckdb files in Finder when it is not the default app for those types. (#1594)
- The JSON results view shows row data right away instead of staying blank until you switch between Tree and Text, and it updates when the row selection changes. A spinner shows while large results are being formatted. (#1576)
- Double-clicking or pressing Enter on a JSON cell now edits its value inline, like other cells; on a blob cell it opens the hex editor. The chevron still opens the tree or hex editor. Neither cell responded to double-click before. (#1588)
Expand Down
55 changes: 52 additions & 3 deletions TablePro/Views/Filter/FilterValueTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ enum FilterCompletionSource {
case sqlTokens(RawSQLFilterCompletionProvider)
}

struct FilterFocusState: Equatable {
private(set) var claimedId: UUID?

mutating func claimFocus(requestedId: UUID?, identity: UUID) -> Bool {
guard requestedId == identity, claimedId != identity else { return false }
claimedId = identity
return true
}

mutating func markFocused(_ identity: UUID) {
claimedId = identity
}

mutating func releaseFocus() {
claimedId = nil
}
}

struct FilterValueTextField: NSViewRepresentable {
@Binding var text: String
@Binding var focusedId: UUID?
Expand Down Expand Up @@ -132,6 +150,8 @@ struct FilterValueTextField: NSViewRepresentable {
private let suggestionState = SuggestionState()
private var suggestionPopover: NSPopover?
private var keyMonitor: Any?
private var focusState = FilterFocusState()
private var windowKeyObserver: NSObjectProtocol?
private var latestReplacementRange: NSRange?
private var completionGeneration = 0
private static let completionDebounce: UInt64 = 50_000_000
Expand All @@ -158,30 +178,54 @@ struct FilterValueTextField: NSViewRepresentable {
}

func focusIfRequested() {
guard focusedId.wrappedValue == identity,
let textField,
let window = textField.window else { return }
guard let textField,
let window = textField.window,
focusState.claimFocus(requestedId: focusedId.wrappedValue, identity: identity) else { return }
if window.firstResponder !== textField.currentEditor() {
window.makeFirstResponder(textField)
}
}

func handleBecameFirstResponder() {
focusState.markFocused(identity)
if focusedId.wrappedValue != identity {
focusedId.wrappedValue = identity
}
}

func handleResignedFirstResponder() {
focusState.releaseFocus()
if focusedId.wrappedValue == identity {
focusedId.wrappedValue = nil
}
}

func startObservingWindowKeyStatus(for window: NSWindow) {
stopObservingWindowKeyStatus()
windowKeyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.handleResignedFirstResponder()
}
}
}

func stopObservingWindowKeyStatus() {
guard let token = windowKeyObserver else { return }
NotificationCenter.default.removeObserver(token)
windowKeyObserver = nil
}

deinit {
if let token = keyMonitor {
NSEvent.removeMonitor(token)
}
if let token = windowKeyObserver {
NotificationCenter.default.removeObserver(token)
}
}

func controlTextDidChange(_ notification: Notification) {
Expand Down Expand Up @@ -417,6 +461,11 @@ struct FilterValueTextField: NSViewRepresentable {

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let window {
owner?.startObservingWindowKeyStatus(for: window)
} else {
owner?.stopObservingWindowKeyStatus()
}
owner?.focusIfRequested()
}

Expand Down
2 changes: 2 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ final class MainContentCommandActions {
let type = coordinator.connection.type
guard PluginManager.shared.supportsDatabaseSwitching(for: type) else { return }
guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return }
coordinator.contentWindow?.makeFirstResponder(nil)
coordinator.isDatabaseSwitcherShown = true
}

Expand All @@ -860,6 +861,7 @@ final class MainContentCommandActions {
}

func openConnectionSwitcher() {
coordinator?.contentWindow?.makeFirstResponder(nil)
coordinator?.isConnectionSwitcherShown = true
}

Expand Down
17 changes: 12 additions & 5 deletions TablePro/Views/Sidebar/NativeSearchField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,21 @@ import AppKit
import SwiftUI

private final class IntrinsicHeightSearchField: NSSearchField {
var focusOnAppear = false

override var intrinsicContentSize: NSSize {
let cellHeight = cell?.cellSize.height ?? super.intrinsicContentSize.height
return NSSize(width: NSView.noIntrinsicMetric, height: cellHeight)
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard focusOnAppear, acceptsFirstResponder, let window else { return }
window.makeFirstResponder(self)
if !window.isKeyWindow {
window.makeKey()
}
}
}

struct NativeSearchField: NSViewRepresentable {
Expand All @@ -39,11 +50,7 @@ struct NativeSearchField: NSViewRepresentable {
field.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true
}
context.coordinator.lastFocusTrigger = focusTrigger
if focusOnAppear {
DispatchQueue.main.async {
field.window?.makeFirstResponder(field)
}
}
field.focusOnAppear = focusOnAppear
return field
}

Expand Down
73 changes: 73 additions & 0 deletions TableProTests/Views/Filter/FilterFocusStateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// FilterFocusStateTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("Filter Focus State")
struct FilterFocusStateTests {
@Test("Claims focus when requested id matches identity")
func testClaimFocus_newRequestClaims() {
var state = FilterFocusState()
let identity = UUID()
let claimed = state.claimFocus(requestedId: identity, identity: identity)
#expect(claimed)
#expect(state.claimedId == identity)
}

@Test("Does not re-claim focus it already owns")
func testClaimFocus_repeatedRequestDoesNotReclaim() {
var state = FilterFocusState()
let identity = UUID()
_ = state.claimFocus(requestedId: identity, identity: identity)
let reclaimed = state.claimFocus(requestedId: identity, identity: identity)
#expect(!reclaimed)
}

@Test("Does not claim focus requested for another identity")
func testClaimFocus_otherIdentityDoesNotClaim() {
var state = FilterFocusState()
let claimed = state.claimFocus(requestedId: UUID(), identity: UUID())
#expect(!claimed)
#expect(state.claimedId == nil)
}

@Test("Does not claim focus when nothing is requested")
func testClaimFocus_nilRequestDoesNotClaim() {
var state = FilterFocusState()
let claimed = state.claimFocus(requestedId: nil, identity: UUID())
#expect(!claimed)
}

@Test("Releasing focus allows a later re-claim")
func testReleaseFocus_allowsReclaim() {
var state = FilterFocusState()
let identity = UUID()
_ = state.claimFocus(requestedId: identity, identity: identity)
state.releaseFocus()
#expect(state.claimedId == nil)
let reclaimed = state.claimFocus(requestedId: identity, identity: identity)
#expect(reclaimed)
}

@Test("Click-driven focus suppresses a redundant programmatic claim")
func testMarkFocused_suppressesRedundantClaim() {
var state = FilterFocusState()
let identity = UUID()
state.markFocused(identity)
let claimed = state.claimFocus(requestedId: identity, identity: identity)
#expect(!claimed)
}

@Test("Focus marked on another identity does not block this identity")
func testMarkFocused_otherIdentityDoesNotBlockClaim() {
var state = FilterFocusState()
let identity = UUID()
state.markFocused(UUID())
let claimed = state.claimFocus(requestedId: identity, identity: identity)
#expect(claimed)
}
}
Loading