From 631a76c8761010ac3a892dc1d2d61ecc86fd2491 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Jul 2026 09:15:48 +0700 Subject: [PATCH 1/3] fix(toolbar): open the quick switcher panel on macOS 15 --- CHANGELOG.md | 1 + .../QuickSwitcher/QuickSwitcherPanel.swift | 52 ++++++++++------- .../QuickSwitcherPanelControllerTests.swift | 58 ++++++++++++++----- 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 586a2a9ad..e1a221186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ 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) - 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) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 87e55a86d..677af08c0 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -7,12 +7,13 @@ import AppKit import SwiftUI internal final class QuickSwitcherPanel: NSPanel { - var onCancel: (() -> Void)? - - init(contentView: NSView) { + init(hostingController: NSHostingController) { + hostingController.sizingOptions = [] + let proposal = NSScreen.main?.visibleFrame.size ?? NSSize(width: 1_280, height: 800) + 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 ) @@ -23,15 +24,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 var canBecomeMain: Bool { true } + + override func resignKey() { + super.resignKey() + close() + } override func cancelOperation(_ sender: Any?) { - onCancel?() + close() } } @@ -52,12 +60,15 @@ 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 @@ -72,16 +83,12 @@ 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 = nil + anchor = nil } func windowDidResize(_ notification: Notification) { @@ -90,6 +97,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 diff --git a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index d650d1478..47caf4602 100644 --- a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -36,29 +36,59 @@ 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("closing the panel window clears the presented state") + func windowWillCloseClearsPresentedState() { + let controller = QuickSwitcherPanelController() + controller.present(Text(verbatim: "content"), over: nil) + controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + #expect(controller.isPresented == false) + } + + @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.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + controller.dismiss() #expect(controller.isPresented == false) } - @Test("panel cannot become main but can become key") + @Test("panel can become key and main") func panelKeyAndMainBehavior() { - let panel = QuickSwitcherPanel(contentView: NSView()) + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) #expect(panel.canBecomeKey) - #expect(panel.canBecomeMain == false) - panel.orderOut(nil) + #expect(panel.canBecomeMain) + panel.close() + } + + @Test("resigning key closes the panel") + func resignKeyClosesPanel() { + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) + panel.resignKey() + #expect(panel.isVisible == false) } - @Test("escape on the panel invokes onCancel") - func escapeInvokesOnCancel() { - let panel = QuickSwitcherPanel(contentView: NSView()) - var cancelled = false - panel.onCancel = { cancelled = true } + @Test("escape closes the panel") + func escapeClosesPanel() { + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) 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: EmptyView())) + #expect(panel.styleMask.contains(.nonactivatingPanel)) + #expect(panel.styleMask.contains(.borderless)) + panel.close() } } From 4ba7f2e6512523b9edd672b5872c1a4f3b20b7af Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Jul 2026 10:25:43 +0700 Subject: [PATCH 2/3] fix(toolbar): keep the quick switcher panel non-main and test real dismissal paths --- .../QuickSwitcher/QuickSwitcherPanel.swift | 8 ++++-- .../QuickSwitcherPanelControllerTests.swift | 28 ++++++++----------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 677af08c0..47404870b 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -6,10 +6,12 @@ import AppKit import SwiftUI +private let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1_280, height: 800) + internal final class QuickSwitcherPanel: NSPanel { init(hostingController: NSHostingController) { hostingController.sizingOptions = [] - let proposal = NSScreen.main?.visibleFrame.size ?? NSSize(width: 1_280, height: 800) + let proposal = NSScreen.main?.visibleFrame.size ?? fallbackScreenFrame.size let contentSize = hostingController.sizeThatFits(in: proposal) super.init( contentRect: NSRect(origin: .zero, size: contentSize), @@ -31,7 +33,7 @@ internal final class QuickSwitcherPanel: NSPanel { } override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } + override var canBecomeMain: Bool { false } override func resignKey() { super.resignKey() @@ -73,7 +75,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { 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 diff --git a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index 47caf4602..e9c40de85 100644 --- a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -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) @@ -45,48 +45,44 @@ struct QuickSwitcherPanelControllerTests { panel.close() } - @Test("closing the panel window clears the presented state") - func windowWillCloseClearsPresentedState() { - let controller = QuickSwitcherPanelController() - controller.present(Text(verbatim: "content"), over: nil) - controller.windowWillClose(Notification(name: NSWindow.willCloseNotification)) - #expect(controller.isPresented == false) - } - @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.windowWillClose(Notification(name: NSWindow.willCloseNotification)) + controller.dismiss() controller.dismiss() #expect(controller.isPresented == false) } - @Test("panel can become key and main") + @Test("panel can become key but not main") func panelKeyAndMainBehavior() { - let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) #expect(panel.canBecomeKey) - #expect(panel.canBecomeMain) + #expect(panel.canBecomeMain == false) panel.close() } @Test("resigning key closes the panel") func resignKeyClosesPanel() { - let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) + 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: EmptyView())) + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) + panel.makeKeyAndOrderFront(nil) + #expect(panel.isVisible) panel.cancelOperation(nil) #expect(panel.isVisible == false) } @Test("panel uses a nonactivating borderless style mask") func panelStyleMask() { - let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: EmptyView())) + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) #expect(panel.styleMask.contains(.nonactivatingPanel)) #expect(panel.styleMask.contains(.borderless)) panel.close() From 78e312bdc0d730bfd884b4243ca133e78804579f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 3 Jul 2026 10:25:57 +0700 Subject: [PATCH 3/3] fix(toolbar): stop leaking the quick switcher key monitor on close --- CHANGELOG.md | 1 + TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a221186..adb44f0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ 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) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 47404870b..7f2a72548 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -89,6 +89,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { } func windowWillClose(_ notification: Notification) { + panel?.contentViewController = nil panel = nil anchor = nil }