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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Quick Switcher opens again on macOS Sequoia. Since 0.51.0 the panel came up invisible on macOS 15, and the toolbar button and keyboard shortcut appeared to do nothing. (#1806)
- Quick Switcher keyboard navigation (Ctrl-J/K and arrow shortcuts) no longer goes dead after the switcher has been opened and closed repeatedly. (#1806)
- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796)
- The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799)
- Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800)
Expand Down
55 changes: 35 additions & 20 deletions TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import AppKit
import SwiftUI

internal final class QuickSwitcherPanel: NSPanel {
var onCancel: (() -> Void)?
private let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1_280, height: 800)

init(contentView: NSView) {
internal final class QuickSwitcherPanel: NSPanel {
init<Content: View>(hostingController: NSHostingController<Content>) {
hostingController.sizingOptions = []
let proposal = NSScreen.main?.visibleFrame.size ?? fallbackScreenFrame.size
let contentSize = hostingController.sizeThatFits(in: proposal)
super.init(
contentRect: NSRect(origin: .zero, size: contentView.fittingSize),
styleMask: [.borderless, .fullSizeContentView],
contentRect: NSRect(origin: .zero, size: contentSize),
styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered,
defer: false
)
Expand All @@ -23,15 +26,22 @@ internal final class QuickSwitcherPanel: NSPanel {
backgroundColor = .clear
hasShadow = true
isMovableByWindowBackground = false
isReleasedWhenClosed = false
animationBehavior = .utilityWindow
self.contentView = contentView
contentViewController = hostingController
setContentSize(contentSize)
}

override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }

override func resignKey() {
super.resignKey()
close()
}

override func cancelOperation(_ sender: Any?) {
onCancel?()
close()
}
}

Expand All @@ -52,17 +62,20 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate {
func present(_ content: some View, over parentWindow: NSWindow?) {
dismiss()

let hostingView = NSHostingView(rootView: content)
hostingView.sizingOptions = .preferredContentSize
let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { [weak self] size in
self?.contentSizeDidChange(size)
}
let hostingController = NSHostingController(rootView: sizeReportingContent)

let panel = QuickSwitcherPanel(contentView: hostingView)
let panel = QuickSwitcherPanel(hostingController: hostingController)
panel.delegate = self
panel.onCancel = { [weak self] in self?.dismiss() }
self.panel = panel

let reference = parentWindow?.frame
?? NSScreen.main?.visibleFrame
?? NSRect(x: 0, y: 0, width: 1_280, height: 800)
?? fallbackScreenFrame
anchor = Anchor(
centerX: reference.midX,
top: reference.maxY - reference.height * Self.topOffsetRatio
Expand All @@ -72,16 +85,13 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate {
}

func dismiss() {
guard let panel else { return }
panel.delegate = nil
panel.onCancel = nil
self.panel = nil
anchor = nil
panel.orderOut(nil)
panel?.close()
}

func windowDidResignKey(_ notification: Notification) {
dismiss()
func windowWillClose(_ notification: Notification) {
panel?.contentViewController = nil
panel = nil
anchor = nil
}

func windowDidResize(_ notification: Notification) {
Expand All @@ -90,6 +100,11 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate {
panel.invalidateShadow()
}

private func contentSizeDidChange(_ size: CGSize) {
guard let panel, size.width > 0, size.height > 0, panel.frame.size != size else { return }
panel.setContentSize(size)
}

private func applyAnchor(to panel: QuickSwitcherPanel) {
guard let anchor else { return }
let size = panel.frame.size
Expand Down
54 changes: 40 additions & 14 deletions TableProTests/Views/QuickSwitcherPanelControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct QuickSwitcherPanelControllerTests {
controller.dismiss()
}

@Test("dismiss hides the panel")
@Test("dismiss closes the panel and clears the presented state")
func dismissHidesPanel() {
let controller = QuickSwitcherPanelController()
controller.present(Text(verbatim: "content"), over: nil)
Expand All @@ -36,29 +36,55 @@ struct QuickSwitcherPanelControllerTests {
#expect(controller.isPresented == false)
}

@Test("losing key status dismisses the panel")
func resignKeyDismissesPanel() {
@Test("panel resolves a non-zero frame from its content before showing")
func panelResolvesNonZeroFrame() {
let hostingController = NSHostingController(rootView: Text(verbatim: "content"))
let panel = QuickSwitcherPanel(hostingController: hostingController)
#expect(panel.frame.width > 0)
#expect(panel.frame.height > 0)
panel.close()
}

@Test("dismiss after the panel already closed is a safe no-op")
func dismissAfterAlreadyClosedIsNoOp() {
let controller = QuickSwitcherPanelController()
controller.present(Text(verbatim: "content"), over: nil)
controller.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification))
controller.dismiss()
controller.dismiss()
#expect(controller.isPresented == false)
}

@Test("panel cannot become main but can become key")
@Test("panel can become key but not main")
func panelKeyAndMainBehavior() {
let panel = QuickSwitcherPanel(contentView: NSView())
let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content")))
#expect(panel.canBecomeKey)
#expect(panel.canBecomeMain == false)
panel.orderOut(nil)
panel.close()
}

@Test("escape on the panel invokes onCancel")
func escapeInvokesOnCancel() {
let panel = QuickSwitcherPanel(contentView: NSView())
var cancelled = false
panel.onCancel = { cancelled = true }
@Test("resigning key closes the panel")
func resignKeyClosesPanel() {
let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content")))
panel.makeKeyAndOrderFront(nil)
#expect(panel.isVisible)
panel.resignKey()
#expect(panel.isVisible == false)
}

@Test("escape closes the panel")
func escapeClosesPanel() {
let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content")))
panel.makeKeyAndOrderFront(nil)
#expect(panel.isVisible)
panel.cancelOperation(nil)
#expect(cancelled)
panel.orderOut(nil)
#expect(panel.isVisible == false)
}

@Test("panel uses a nonactivating borderless style mask")
func panelStyleMask() {
let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content")))
#expect(panel.styleMask.contains(.nonactivatingPanel))
#expect(panel.styleMask.contains(.borderless))
panel.close()
}
}
Loading