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
39 changes: 37 additions & 2 deletions apple/TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -252,6 +254,8 @@ internal final class QuickSwitcherViewModel: ObservableObject {
openTables: Set<QuickSwitcherOpenTable> = [],
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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
41 changes: 30 additions & 11 deletions apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyView>
}

private var panel: QuickSwitcherPanel?
private var retainedPanels: [String: RetainedPanel] = [:]
private var anchor: Anchor?
private var presentedIdentity: String?
private var onClose: (() -> Void)?
Expand All @@ -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
Expand All @@ -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
Expand Down
51 changes: 40 additions & 11 deletions apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,28 @@ import AppKit
import SwiftUI

struct QuickSwitcherPanelView: View {
let connectionId: UUID
let databaseType: DatabaseType
let openTables: Set<QuickSwitcherOpenTable>
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<QuickSwitcherOpenTable> = [],
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 {
Expand All @@ -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
Expand All @@ -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<Version: Equatable>: 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

Expand Down Expand Up @@ -89,9 +120,7 @@ struct QuickSwitcherPanelContent: View {
.quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius)
}
.background {
WindowAccessor { window in
(window as? QuickSwitcherPanel)?.keyCommandHandler = handleKeyCommand
}
PanelKeyCommandInstaller(handler: handleKeyCommand)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate {
schemaService: schemaService,
temporaryTables: temporaryTables,
sidebarState: sidebarState,
rootShape: rootShape
rootShape: rootShape,
searchText: searchText
)
}

Expand Down
7 changes: 5 additions & 2 deletions apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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)
)
Expand Down
18 changes: 18 additions & 0 deletions apple/TableProTests/ViewModels/QuickSwitcherViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)" })
Expand Down
37 changes: 37 additions & 0 deletions apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading