From fbec6e49fc3e6ddf2a8432ce2987191baf718ec9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 00:35:59 +0700 Subject: [PATCH 1/3] perf(quick-switcher): keep the Open Quickly panel between opens --- .../ViewModels/QuickSwitcherViewModel.swift | 39 +++++++++++++- ...MainContentCoordinator+QuickSwitcher.swift | 11 +++- .../QuickSwitcher/QuickSwitcherPanel.swift | 41 +++++++++++---- .../QuickSwitcherPanelView.swift | 51 +++++++++++++++---- .../QuickSwitcherViewModelTests.swift | 18 +++++++ .../QuickSwitcherPanelControllerTests.swift | 37 ++++++++++++++ 6 files changed, 171 insertions(+), 26 deletions(-) diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift index dc065991a3..0fbec7e3a0 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -121,6 +121,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { private var activeCrossConnectionQueryLoadId = UUID() private var loadedCrossConnectionVersion: CrossConnectionCatalogVersion? private var loadedCrossConnectionQueryVersion: CrossConnectionQueryVersion? + // The retained Open Quickly panel keeps showing this model until its next presentation. + private var isReleased = false @Published private(set) var groups: [Group] = [] @Published private(set) var isLoading = true @@ -252,6 +254,8 @@ internal final class QuickSwitcherViewModel: ObservableObject { openTables: Set = [], browseSchema: String? = nil ) async { + // A task that starts after the panel already closed would set up the observers again. + guard !isReleased, !Task.isCancelled else { return } isLoading = true let loadId = UUID() @@ -520,7 +524,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// task that drives this, and the refresh it just ran has already moved the version, so /// the panel refreshes that connection forever. func loadCrossConnectionItems() async { - guard scope.usesCrossConnectionCatalog else { return } + guard !isReleased, scope.usesCrossConnectionCatalog else { return } guard loadedCrossConnectionVersion != crossConnectionCatalogVersion else { return } let loadId = UUID() @@ -554,7 +558,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } func loadCrossConnectionQueryItems() async { - guard scope.usesCrossConnectionQueries else { return } + guard !isReleased, scope.usesCrossConnectionQueries else { return } let version = crossConnectionQueryVersion guard loadedCrossConnectionQueryVersion != version else { return } @@ -716,6 +720,36 @@ internal final class QuickSwitcherViewModel: ObservableObject { return covered } + /// Run when the panel closes. Loads still in flight see the bumped ids and drop what they bring. + func releaseResults() { + isReleased = true + activeLoadId = UUID() + activeCrossConnectionLoadId = UUID() + activeCrossConnectionQueryLoadId = UUID() + listingRequestId = nil + tableSourceObservations = [] + tableSource = nil + tableInputs = nil + pendingTableBuild?.task.cancel() + pendingTableBuild = nil + filterTask?.cancel() + filterTask = nil + baseItems = nil + tableItems = [] + tableItemKeys = [] + allItems = [] + crossConnectionItems = [] + crossConnectionQueryItems = [] + groups = [] + selectedItemId = nil + isFiltering = false + isLoadingBrowseCatalog = false + isListingInFlight = false + isSharedListingLoading = false + isLoadingCrossConnections = false + isLoadingCrossConnectionQueries = false + } + func canOpenStructure(_ item: QuickSwitcherItem) -> Bool { guard let target = item.target else { return true } return target.connectionId == connectionId @@ -762,6 +796,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// build off the main actor so neither can stall the panel while it is being typed into. private func scheduleFilter(debounced: Bool) { filterTask?.cancel() + guard !isReleased else { return } let query = searchText.trimmingCharacters(in: .whitespaces) let items = scopedItems() let scope = scope diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 44ec500a21..5e1c694871 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -39,15 +39,22 @@ extension MainContentCoordinator { ) } ) + let viewModel = QuickSwitcherViewModel(connectionId: connectionId) let panelView = QuickSwitcherPanelView( - connectionId: connectionId, + viewModel: viewModel, databaseType: connection.type, openTables: openTables, browseSchema: browseSchema, onSelect: { [weak self] item, intent in self?.handleQuickSwitcherSelection(item, intent: intent) }, onDismiss: { [weak self] in self?.quickSwitcherPanel?.dismiss() } ) - quickSwitcherPanel.present(panelView, over: contentWindow, identity: Self.openQuicklyPanelIdentity) + quickSwitcherPanel.present( + panelView, + over: contentWindow, + identity: Self.openQuicklyPanelIdentity, + retainsPanel: true, + onClose: { [weak viewModel] in viewModel?.releaseResults() } + ) InteractionTracer.shared.end( .quickOpenPanel, key: connectionId.uuidString, after: quickSwitcherPanel.presentedWindow ) diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index c9fff03a8d..ce96a30661 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -114,7 +114,13 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { private static let topOffsetRatio: CGFloat = 0.20 + private struct RetainedPanel { + let panel: QuickSwitcherPanel + let hostingController: NSHostingController + } + private var panel: QuickSwitcherPanel? + private var retainedPanels: [String: RetainedPanel] = [:] private var anchor: Anchor? private var presentedIdentity: String? private var onClose: (() -> Void)? @@ -132,32 +138,43 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { /// `onClose` runs however the panel goes, including when another presentation replaces it, /// which is how a caller that keeps state of its own alongside the panel learns to drop it. + /// `retainsPanel` keeps the window and view tree for the next presentation under the same identity, + /// so content must take its model from the caller: a `@StateObject` would survive the root swap. func present( _ content: some View, over parentWindow: NSWindow?, identity: String? = nil, focus: QuickSwitcherPanelFocus = .key, accessibilityIdentifier: String = "quick-switcher-panel", + retainsPanel: Bool = false, onClose: (() -> Void)? = nil ) { dismiss() presentedIdentity = identity self.onClose = onClose - let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in + let root = AnyView(content.onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { [weak self] size in self?.contentSizeDidChange(size) + }) + let panel: QuickSwitcherPanel + if retainsPanel, let identity, let retained = retainedPanels[identity] { + retained.hostingController.rootView = root + panel = retained.panel + } else { + let hostingController = NSHostingController(rootView: root) + panel = QuickSwitcherPanel( + hostingController: hostingController, + surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius, + focus: focus, + accessibilityIdentifier: accessibilityIdentifier + ) + panel.delegate = self + if retainsPanel, let identity { + retainedPanels[identity] = RetainedPanel(panel: panel, hostingController: hostingController) + } } - let hostingController = NSHostingController(rootView: sizeReportingContent) - - let panel = QuickSwitcherPanel( - hostingController: hostingController, - surfaceCornerRadius: QuickSwitcherMetrics.cornerRadius, - focus: focus, - accessibilityIdentifier: accessibilityIdentifier - ) - panel.delegate = self self.panel = panel let reference = parentWindow?.frame @@ -182,7 +199,9 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { func windowWillClose(_ notification: Notification) { panel?.keyCommandHandler = nil - panel?.contentViewController = nil + if let panel, !retainedPanels.values.contains(where: { $0.panel === panel }) { + panel.contentViewController = nil + } panel = nil anchor = nil presentedIdentity = nil diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 4cfc871dd8..e2a2721207 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -7,30 +7,28 @@ import AppKit import SwiftUI struct QuickSwitcherPanelView: View { - let connectionId: UUID let databaseType: DatabaseType let openTables: Set let browseSchema: String? let onSelect: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void let onDismiss: () -> Void - @StateObject private var viewModel: QuickSwitcherViewModel + @ObservedObject private var viewModel: QuickSwitcherViewModel init( - connectionId: UUID, + viewModel: QuickSwitcherViewModel, databaseType: DatabaseType, openTables: Set = [], browseSchema: String? = nil, onSelect: @escaping (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void, onDismiss: @escaping () -> Void ) { - self.connectionId = connectionId self.databaseType = databaseType self.openTables = openTables self.browseSchema = browseSchema self.onSelect = onSelect self.onDismiss = onDismiss - self._viewModel = StateObject(wrappedValue: QuickSwitcherViewModel(connectionId: connectionId)) + self.viewModel = viewModel } var body: some View { @@ -39,17 +37,17 @@ struct QuickSwitcherPanelView: View { onSelect(item, intent) onDismiss() } - .task { + .task(id: ObjectIdentifier(viewModel)) { await viewModel.loadItems( databaseType: databaseType, openTables: openTables, browseSchema: browseSchema ) } - .task(id: viewModel.crossConnectionLoadVersion) { + .task(id: ModelTaskKey(model: ObjectIdentifier(viewModel), version: viewModel.crossConnectionLoadVersion)) { await viewModel.loadCrossConnectionItems() } - .task(id: viewModel.crossConnectionQueryLoadVersion) { + .task(id: ModelTaskKey(model: ObjectIdentifier(viewModel), version: viewModel.crossConnectionQueryLoadVersion)) { await viewModel.loadCrossConnectionQueryItems() } .onReceive(AppEvents.shared.queryHistoryDidUpdate) { _ in @@ -61,6 +59,39 @@ struct QuickSwitcherPanelView: View { } } +// The retained panel keeps this view and swaps its model, so each load is keyed by the model as well. +private struct ModelTaskKey: Equatable { + let model: ObjectIdentifier + let version: Version +} + +// Installed on every update, not only when joining the window: the retained panel swaps in a new model. +private struct PanelKeyCommandInstaller: NSViewRepresentable { + let handler: (NSEvent) -> Bool + + func makeNSView(context: Context) -> InstallerView { + InstallerView() + } + + func updateNSView(_ nsView: InstallerView, context: Context) { + nsView.handler = handler + nsView.install() + } + + final class InstallerView: NSView { + var handler: ((NSEvent) -> Bool)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + install() + } + + func install() { + (window as? QuickSwitcherPanel)?.keyCommandHandler = handler + } + } +} + struct QuickSwitcherPanelContent: View { @Environment(\.colorSchemeContrast) private var colorSchemeContrast @@ -89,9 +120,7 @@ struct QuickSwitcherPanelContent: View { .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) } .background { - WindowAccessor { window in - (window as? QuickSwitcherPanel)?.keyCommandHandler = handleKeyCommand - } + PanelKeyCommandInstaller(handler: handleKeyCommand) } } diff --git a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift index e08caec4f6..a5bc1c9767 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift @@ -1340,6 +1340,24 @@ struct QuickSwitcherViewModelTests { #expect(groups.isEmpty) } + @Test("A released model drops its results and ignores a later search") + func releasedModelDropsResults() async { + let vm = makeViewModel(items: sampleItems()) + vm.scope = .tables + await vm.flushPendingFilter() + #expect(!vm.groups.isEmpty) + + vm.releaseResults() + await vm.loadItems(databaseType: .mysql) + vm.searchText = "users" + await vm.flushPendingFilter() + + #expect(vm.allItems.isEmpty) + #expect(vm.groups.isEmpty) + #expect(vm.isFiltering == false) + #expect(vm.selectedItemId == nil) + } + @Test("A scope change the next one superseded stops listing and returns nothing") func cancelledBrowseStopsListing() async { let items = tableItems((0..<5_000).map { "orders_\($0)" }) diff --git a/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index 399065e871..aeda5bd07c 100644 --- a/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -182,6 +182,43 @@ struct QuickSwitcherPanelControllerTests { try await waitUntil { released == nil } } + @Test("A retained panel comes back as the same window, answering for the model it was given last") + func retainedPanelSwapsItsModel() async throws { + let controller = QuickSwitcherPanelController() + defer { controller.dismiss() } + weak var released: QuickSwitcherViewModel? + weak var firstPanel: QuickSwitcherPanel? + do { + let viewModel = QuickSwitcherViewModel(connectionId: UUID()) + released = viewModel + controller.present( + QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in }, + over: nil, + identity: "open-quickly", + retainsPanel: true + ) + let panel = try #require(controller.presentedWindow as? QuickSwitcherPanel) + firstPanel = panel + try await waitUntil { panel.keyCommandHandler != nil } + } + controller.dismiss() + + let viewModel = QuickSwitcherViewModel(connectionId: UUID()) + controller.present( + QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in }, + over: nil, + identity: "open-quickly", + retainsPanel: true + ) + let panel = try #require(controller.presentedWindow as? QuickSwitcherPanel) + #expect(panel === firstPanel) + try await waitUntil { panel.keyCommandHandler != nil } + try await waitUntil { released == nil } + + #expect(panel.performKeyEquivalent(with: try Self.key("2", modifiers: .command, window: panel))) + #expect(viewModel.scope == QuickSwitcherScope.allCases[1]) + } + @Test("The panel answers its own commands ahead of the search field and passes the rest on") func panelAnswersItsCommands() async throws { let controller = QuickSwitcherPanelController() From 18cbe2b373ed0c19d3b9a7686327cb49914bf65a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 00:36:15 +0700 Subject: [PATCH 2/3] docs(changelog): add the Open Quickly reuse fragment --- apple/changelog.d/3888.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3888.changed.md diff --git a/apple/changelog.d/3888.changed.md b/apple/changelog.d/3888.changed.md new file mode 100644 index 0000000000..b871197013 --- /dev/null +++ b/apple/changelog.d/3888.changed.md @@ -0,0 +1 @@ +Open Quickly opens faster after the first time in a window. From 92c0225f35c6dd472ef8ca7d5eaf44b8ed23c921 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 00:52:26 +0700 Subject: [PATCH 3/3] perf(sidebar): skip the reload when an all-schema listing lands without a search --- .../Sidebar/DatabaseTreeOutlineCoordinator.swift | 3 ++- .../Views/Sidebar/DatabaseTreeRenderInputs.swift | 7 +++++-- .../Views/Sidebar/DatabaseTreeReconcileTests.swift | 12 ++++++++---- apple/changelog.d/3888.changed.1.md | 1 + 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 apple/changelog.d/3888.changed.1.md diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 593654982c..af9b8bcbac 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -300,7 +300,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { schemaService: schemaService, temporaryTables: temporaryTables, sidebarState: sidebarState, - rootShape: rootShape + rootShape: rootShape, + searchText: searchText ) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift index c3560b069a..6dbbfc1048 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift @@ -97,7 +97,8 @@ struct DatabaseTreeRenderInputs: Equatable { schemaService: SchemaService, temporaryTables: TemporaryTableStore, sidebarState: SharedSidebarState?, - rootShape: SidebarRootShape + rootShape: SidebarRootShape, + searchText: String ) { let ownsObjects = { (key: ObjectsKey) in key.connectionId == connectionId } let tables = Self.slice(treeMetadata.tablesState, where: ownsObjects) { Rows($0) } @@ -114,7 +115,9 @@ struct DatabaseTreeRenderInputs: Equatable { ) } var listings: [DatabaseKey: Listing] = [:] - for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId { + // The outline reads the listing only under a search, and Open Quickly loads it with none: landing + // then reloaded every row. A search starting refreshes the outline anyway. + for (key, state) in treeMetadata.allSchemaTablesState where key.connectionId == connectionId && !searchText.isEmpty { let listing = Listing( state, covered: treeMetadata.allSchemaTablesSelection(connectionId: connectionId, database: key.database) ) diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift index 7711d8e089..7446b88e1c 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift @@ -162,7 +162,7 @@ struct DatabaseTreeReconcileTests { await tearDown(fixture) } - @Test("An all-schema listing starting to load changes nothing the outline draws, and its rows landing do") + @Test("An all-schema listing starting to load changes nothing the outline draws, and its rows landing do only under a search") func listingStartingToLoadIsNoChange() async throws { let connection = TestFixtures.makeConnection(database: "shop", type: .pglite) let driver = MockDatabaseDriver(connection: connection) @@ -172,17 +172,19 @@ struct DatabaseTreeReconcileTests { session.status = .connected DatabaseManager.shared.injectSession(session, for: connection.id) let service = DatabaseTreeMetadataService.shared - func inputs() -> DatabaseTreeRenderInputs { + func inputs(searchText: String = "time") -> DatabaseTreeRenderInputs { DatabaseTreeRenderInputs( connectionId: connection.id, treeMetadata: service, schemaService: .shared, temporaryTables: .shared, sidebarState: nil, - rootShape: .flat + rootShape: .flat, + searchText: searchText ) } let unread = inputs() + let unsearched = inputs(searchText: "") let load = Task { await service.loadAllSchemaTables(connectionId: connection.id, database: "shop") } try await waitUntil { @@ -196,6 +198,7 @@ struct DatabaseTreeReconcileTests { await load.value #expect(service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop").value != nil) #expect(inputs() != unread) + #expect(inputs(searchText: "") == unsearched) await service.handleDisconnect(connectionId: connection.id) DatabaseManager.shared.removeSession(for: connection.id) } @@ -216,7 +219,8 @@ struct DatabaseTreeReconcileTests { schemaService: .shared, temporaryTables: .shared, sidebarState: nil, - rootShape: shape + rootShape: shape, + searchText: "" ) } let flat = inputs(.flat) diff --git a/apple/changelog.d/3888.changed.1.md b/apple/changelog.d/3888.changed.1.md new file mode 100644 index 0000000000..afd7f77347 --- /dev/null +++ b/apple/changelog.d/3888.changed.1.md @@ -0,0 +1 @@ +Open Quickly no longer redraws the whole sidebar when it finishes listing the tables of every schema.