diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc9b05f5..58fb5dff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `postgres` database shows in the database list again. It was marked as a system database, which hid it from the sidebar, Cmd+K, the database filter, and the Backup and Restore Dump pickers. PostgreSQL creates it for users and applications, so nothing about it is internal. CockroachDB's `defaultdb` and Redshift's `dev` were hidden the same way and now show too. (#1967) - The database a connection is using always shows in the sidebar and the database switchers, even when it is a system database or the database filter excludes it. (#1967) - The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all. -- File > Import from Other App..., Open Project Folder..., Import Connections... and Export Connections... now work when no welcome window is open. They used to do nothing. +- File > New Connection... and ⌘N now open the connection chooser once you have a connection open. They did nothing at all, because the welcome window closes as soon as you connect and the command was addressed to it. Import from Other App..., Open Project Folder..., Import Connections..., Import from URL... and Export Connections... had the same fault and are fixed with it. (#1975) - The tooltip on the welcome screen's **+** button shows the shortcut you actually have bound for New Connection instead of always claiming ⌘N. - The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) - BigQuery `REPEATED` columns (including repeated `STRUCT`) no longer show every element as `null`. (#1963) diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index 5589f9387..ed74bd29b 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -18,16 +18,7 @@ final class AppCommands { // MARK: - File / Connection Import-Export let openSQLFiles = PassthroughSubject<[URL], Never>() - let exportConnections = PassthroughSubject() - let importConnections = PassthroughSubject() - let importConnectionsFromApp = PassthroughSubject() - let openProjectFolder = PassthroughSubject() - let importConnectionFromURL = PassthroughSubject() let exportQueryResults = PassthroughSubject() - // MARK: - Window / Sheet Commands - - let presentDatabaseTypeChooser = PassthroughSubject() - private init() {} } diff --git a/TablePro/Core/Services/AppServices.swift b/TablePro/Core/Services/AppServices.swift index e37b7321a..b3afa4d99 100644 --- a/TablePro/Core/Services/AppServices.swift +++ b/TablePro/Core/Services/AppServices.swift @@ -34,6 +34,7 @@ struct AppServices { let mcpServerManager: MCPServerManager let syncTracker: SyncChangeTracker let themeEngine: ThemeEngine + let welcomeRouter: WelcomeRouter static let live = AppServices( appEvents: .shared, @@ -62,7 +63,8 @@ struct AppServices { copilotService: .shared, mcpServerManager: .shared, syncTracker: .shared, - themeEngine: .shared + themeEngine: .shared, + welcomeRouter: .shared ) } diff --git a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift index 0c5f8af60..3653fbb97 100644 --- a/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift +++ b/TablePro/Core/Services/Infrastructure/WelcomeRouter.swift @@ -14,11 +14,32 @@ internal struct PendingConnectionError { let error: Error } +internal final class DatabaseTypeChooserPayload: Identifiable { + internal let id = UUID() + internal let initialType: DatabaseType? + internal let onSelected: (DatabaseType) -> Void + + internal init(initialType: DatabaseType?, onSelected: @escaping (DatabaseType) -> Void) { + self.initialType = initialType + self.onSelected = onSelected + } +} + +internal enum WelcomeRequest { + case chooseDatabaseType(DatabaseTypeChooserPayload) + case exportConnections + case importConnections + case importFromApp + case importFromURL + case openProjectFolder +} + @MainActor @Observable internal final class WelcomeRouter { internal static let shared = WelcomeRouter() + private(set) var pendingRequest: WelcomeRequest? private(set) var pendingImport: ExportableConnection? private(set) var pendingConnectionShare: URL? private(set) var pendingSQLFiles: [URL] = [] @@ -27,11 +48,11 @@ internal final class WelcomeRouter { @ObservationIgnored private var databaseDidConnectCancellable: AnyCancellable? - private init() { - databaseDidConnectCancellable = AppEvents.shared.databaseDidConnect + internal init(appEvents: AppEvents = .shared) { + databaseDidConnectCancellable = appEvents.databaseDidConnect .receive(on: RunLoop.main) - .sink { _ in - WelcomeRouter.shared.drainPendingSQLFiles() + .sink { [weak self] _ in + self?.drainPendingSQLFiles() } } @@ -41,6 +62,17 @@ internal final class WelcomeRouter { AppCommands.shared.openSQLFiles.send(urls) } + internal func route(_ request: WelcomeRequest) { + pendingRequest = request + showWelcomeWindow() + } + + internal func consumePendingRequest() -> WelcomeRequest? { + let value = pendingRequest + pendingRequest = nil + return value + } + internal func routeImport(_ exportable: ExportableConnection) { pendingImport = exportable showWelcomeWindow() diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 3c0e87045..3aaa8dc04 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -18,8 +18,6 @@ internal final class WindowOpener { @ObservationIgnored private var openConnectionFormAction: ((UUID?) -> Void)? @ObservationIgnored private var openIntegrationsActivityAction: (() -> Void)? @ObservationIgnored private var openSettingsAction: (() -> Void)? - @ObservationIgnored - private var presentTypeChooserAction: ((DatabaseType?, @escaping (DatabaseType) -> Void) -> Void)? @ObservationIgnored private var pendingCalls: [() -> Void] = [] @ObservationIgnored private var isWired = false @@ -53,10 +51,8 @@ internal final class WindowOpener { run { $0.openConnectionFormAction?(connectionId) } return } - run { opener in - opener.presentTypeChooser(initialType: nil) { selected in - opener.openConnectionForm(editing: nil, withType: selected) - } + presentTypeChooser(initialType: nil) { selected in + WindowOpener.shared.openConnectionForm(editing: nil, withType: selected) } } @@ -74,7 +70,8 @@ internal final class WindowOpener { initialType: DatabaseType?, onSelected: @escaping (DatabaseType) -> Void ) { - run { $0.presentTypeChooserAction?(initialType, onSelected) } + let payload = DatabaseTypeChooserPayload(initialType: initialType, onSelected: onSelected) + WelcomeRouter.shared.route(.chooseDatabaseType(payload)) } internal func openIntegrationsActivity() { @@ -85,14 +82,12 @@ internal final class WindowOpener { openWelcome: @escaping () -> Void, openConnectionForm: @escaping (UUID?) -> Void, openIntegrationsActivity: @escaping () -> Void, - openSettings: @escaping () -> Void, - presentTypeChooser: @escaping (DatabaseType?, @escaping (DatabaseType) -> Void) -> Void + openSettings: @escaping () -> Void ) { openWelcomeAction = openWelcome openConnectionFormAction = openConnectionForm openIntegrationsActivityAction = openIntegrationsActivity openSettingsAction = openSettings - presentTypeChooserAction = presentTypeChooser isWired = true let drained = pendingCalls pendingCalls.removeAll() diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index f358f3211..a6c005338 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -355,28 +355,23 @@ struct AppMenuCommands: Commands { Divider() Button(String(localized: "Export Connections...")) { - WindowOpener.shared.openWelcome() - AppCommands.shared.exportConnections.send(()) + WelcomeRouter.shared.route(.exportConnections) } Button(String(localized: "Import Connections...")) { - WindowOpener.shared.openWelcome() - AppCommands.shared.importConnections.send(()) + WelcomeRouter.shared.route(.importConnections) } Button(String(localized: "Import from URL...")) { - WindowOpener.shared.openWelcome() - AppCommands.shared.importConnectionFromURL.send(()) + WelcomeRouter.shared.route(.importFromURL) } Button(String(localized: "Import from Other App...")) { - WindowOpener.shared.openWelcome() - AppCommands.shared.importConnectionsFromApp.send(()) + WelcomeRouter.shared.route(.importFromApp) } Button(String(localized: "Open Project Folder...")) { - WindowOpener.shared.openWelcome() - AppCommands.shared.openProjectFolder.send(()) + WelcomeRouter.shared.route(.openProjectFolder) } Divider() diff --git a/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift b/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift new file mode 100644 index 000000000..84b01e64b --- /dev/null +++ b/TablePro/ViewModels/WelcomeViewModel+ConnectionCreation.swift @@ -0,0 +1,52 @@ +// +// WelcomeViewModel+ConnectionCreation.swift +// TablePro +// + +import Foundation + +extension WelcomeViewModel { + func handle(_ request: WelcomeRequest) { + switch request { + case .chooseDatabaseType(let payload): + databaseTypeChooser = payload + case .exportConnections: + guard !connections.isEmpty else { return } + exportConnections(connections) + case .importConnections: + importConnectionsFromFile() + case .importFromApp: + importConnectionsFromApp() + case .importFromURL: + urlImportPresented = true + case .openProjectFolder: + openProjectFolder() + } + } + + func selectDatabaseType(_ type: DatabaseType, for payload: DatabaseTypeChooserPayload) { + databaseTypeChooser = nil + guard services.pluginManager.isDriverInstalled(for: type) else { + pendingInstallPayload = payload + pendingInstallType = type + return + } + applySelectedDatabaseType(type, payload: payload) + } + + func completePendingInstall(for type: DatabaseType) { + guard let payload = pendingInstallPayload else { return } + pendingInstallPayload = nil + applySelectedDatabaseType(type, payload: payload) + } + + func presentURLImport() { + databaseTypeChooser = nil + urlImportPresented = true + } + + private func applySelectedDatabaseType(_ type: DatabaseType, payload: DatabaseTypeChooserPayload) { + PendingNewConnectionType.shared.set(type) + payload.onSelected(type) + } +} diff --git a/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift index 7470e3644..db88c62ef 100644 --- a/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift +++ b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift @@ -4,7 +4,6 @@ // import AppKit -import Combine import os extension WelcomeViewModel { @@ -13,14 +12,6 @@ extension WelcomeViewModel { category: "WelcomeProjectFolder" ) - func setUpProjectFolderCommand() { - openProjectFolderCancellable = AppCommands.shared.openProjectFolder - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.openProjectFolder() - } - } - func openProjectFolder() { Task { @MainActor in await presentProjectFolderPicker() diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 8ddf836d3..df9832408 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -59,6 +59,11 @@ final class WelcomeViewModel { var activeSheet: WelcomeActiveSheet? var pluginInstallConnection: DatabaseConnection? + var databaseTypeChooser: DatabaseTypeChooserPayload? + var urlImportPresented = false + var pendingInstallType: DatabaseType? + @ObservationIgnored var pendingInstallPayload: DatabaseTypeChooserPayload? + var renameGroupTarget: ConnectionGroup? var renameGroupName = "" var showRenameGroupAlert = false @@ -94,10 +99,6 @@ final class WelcomeViewModel { @ObservationIgnored private var connectionUpdatedCancellable: AnyCancellable? @ObservationIgnored private var linkedFoldersCancellable: AnyCancellable? @ObservationIgnored private var teamLibraryCancellable: AnyCancellable? - @ObservationIgnored private var exportConnectionsCancellable: AnyCancellable? - @ObservationIgnored private var importConnectionsCancellable: AnyCancellable? - @ObservationIgnored private var importFromAppCancellable: AnyCancellable? - @ObservationIgnored var openProjectFolderCancellable: AnyCancellable? @ObservationIgnored private var welcomeRouterTask: Task? @ObservationIgnored private var searchDebounceTask: Task? private static let searchDebounceNanoseconds: UInt64 = 150_000_000 @@ -195,27 +196,6 @@ final class WelcomeViewModel { self?.loadConnections() } - exportConnectionsCancellable = AppCommands.shared.exportConnections - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self, !self.connections.isEmpty else { return } - self.activeSheet = .exportConnections(self.connections) - } - - importConnectionsCancellable = AppCommands.shared.importConnections - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.importConnectionsFromFile() - } - - importFromAppCancellable = AppCommands.shared.importConnectionsFromApp - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.activeSheet = .importFromApp - } - - setUpProjectFolderCommand() - linkedFoldersCancellable = services.appEvents.linkedFoldersDidUpdate .receive(on: RunLoop.main) .sink { [weak self] _ in @@ -238,44 +218,50 @@ final class WelcomeViewModel { } private func consumePendingRouterActions() { - if let pendingURL = WelcomeRouter.shared.consumePendingShare() { + let router = services.welcomeRouter + if let request = router.consumePendingRequest() { + handle(request) + return + } + if let pendingURL = router.consumePendingShare() { activeSheet = .importFile(pendingURL) return } - if let pendingImport = WelcomeRouter.shared.consumePendingImport() { + if let pendingImport = router.consumePendingImport() { activeSheet = .deeplinkImport(pendingImport) return } - if let pendingInstall = WelcomeRouter.shared.consumePendingPluginInstall() { + if let pendingInstall = router.consumePendingPluginInstall() { pluginInstallConnection = pendingInstall return } - if let pendingError = WelcomeRouter.shared.consumePendingError() { + if let pendingError = router.consumePendingError() { presentConnectionFailure(pendingError.error, connection: pendingError.connection) } } private func startWelcomeRouterObservation() { welcomeRouterTask?.cancel() + let router = services.welcomeRouter welcomeRouterTask = Task { @MainActor [weak self] in while !Task.isCancelled { - let didChange = await Self.awaitWelcomeRouterChange() - guard didChange else { return } self?.consumePendingRouterActions() + guard await Self.awaitWelcomeRouterChange(router: router) else { return } } } } - private static func awaitWelcomeRouterChange() async -> Bool { + private static func awaitWelcomeRouterChange(router: WelcomeRouter) async -> Bool { let box = ContinuationBox() return await withTaskCancellationHandler { await withCheckedContinuation { continuation in box.set(continuation) withObservationTracking({ - _ = WelcomeRouter.shared.pendingImport - _ = WelcomeRouter.shared.pendingConnectionShare - _ = WelcomeRouter.shared.pendingError - _ = WelcomeRouter.shared.pendingPluginInstall + _ = router.pendingRequest + _ = router.pendingImport + _ = router.pendingConnectionShare + _ = router.pendingError + _ = router.pendingPluginInstall }, onChange: { box.resume(with: true) }) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 305bd4fce..221638be9 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -15,10 +15,6 @@ struct WelcomeWindowView: View { } @State var vm = WelcomeViewModel() - @State private var welcomeChooserState: WelcomeChooserState? - @State private var pendingInstallType: DatabaseType? - @State private var pendingInstallPayload: DatabaseTypeChooserPayload? - @State private var urlImportPresented: Bool = false @State private var searchFocusTrigger: Int = 0 @FocusState private var focus: FocusField? @@ -127,33 +123,9 @@ struct WelcomeWindowView: View { } } } - .modifier(ConnectionCreationOverlays( - chooserState: $welcomeChooserState, - urlImportPresented: $urlImportPresented - )) - .onReceive(AppCommands.shared.importConnectionFromURL) { _ in - urlImportPresented = true - } - .onReceive(AppCommands.shared.presentDatabaseTypeChooser) { payload in - welcomeChooserState = WelcomeChooserState( - initialType: payload.initialType, - onSelected: { type in - if PluginManager.shared.isDriverInstalled(for: type) { - PendingNewConnectionType.shared.set(type) - payload.onSelected(type) - } else { - pendingInstallPayload = payload - pendingInstallType = type - } - } - ) - } - .pluginInstallPromptForType(type: $pendingInstallType) { type in - if let payload = pendingInstallPayload { - PendingNewConnectionType.shared.set(type) - payload.onSelected(type) - pendingInstallPayload = nil - } + .modifier(ConnectionCreationOverlays(vm: vm)) + .pluginInstallPromptForType(type: $vm.pendingInstallType) { type in + vm.completePendingInstall(for: type) } .pluginInstallPrompt(connection: $vm.pluginInstallConnection) { connection in vm.connectAfterInstall(connection) @@ -219,7 +191,7 @@ struct WelcomeWindowView: View { WelcomeActionsPanel( onActivateLicense: { vm.activeSheet = .activation }, onCreateConnection: { WindowOpener.shared.openConnectionForm() }, - onImportFromURL: { urlImportPresented = true }, + onImportFromURL: { vm.urlImportPresented = true }, onImportFromApp: { vm.importConnectionsFromApp() }, onOpenProjectFolder: { vm.openProjectFolder() }, onImportConnectionsFile: { vm.importConnectionsFromFile() } @@ -723,44 +695,31 @@ private struct TreeRowsView: View { } } -// MARK: - Welcome Chooser State - -private struct WelcomeChooserState: Identifiable { - let id = UUID() - let initialType: DatabaseType? - let onSelected: (DatabaseType) -> Void -} - // MARK: - Connection Creation Overlays private struct ConnectionCreationOverlays: ViewModifier { - @Binding var chooserState: WelcomeChooserState? - @Binding var urlImportPresented: Bool + @Bindable var vm: WelcomeViewModel func body(content: Content) -> some View { content - .sheet(item: $chooserState) { state in + .sheet(item: $vm.databaseTypeChooser) { payload in DatabaseTypeChooserSheet( - initialType: state.initialType, + initialType: payload.initialType, onSelected: { type in - state.onSelected(type) - chooserState = nil - }, - onImportFromURL: { - chooserState = nil - urlImportPresented = true + vm.selectDatabaseType(type, for: payload) }, - onCancel: { chooserState = nil } + onImportFromURL: { vm.presentURLImport() }, + onCancel: { vm.databaseTypeChooser = nil } ) } - .sheet(isPresented: $urlImportPresented) { + .sheet(isPresented: $vm.urlImportPresented) { ImportFromURLSheet( onImported: { parsed in - urlImportPresented = false + vm.urlImportPresented = false WindowOpener.shared.openConnectionFormFromURL(parsed) }, onCancel: { - urlImportPresented = false + vm.urlImportPresented = false } ) } diff --git a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift index 547ecaa66..3ea2c86a7 100644 --- a/TablePro/Views/Infrastructure/WindowOpenerBridge.swift +++ b/TablePro/Views/Infrastructure/WindowOpenerBridge.swift @@ -3,7 +3,6 @@ // TablePro // -import Combine import SwiftUI internal struct WindowOpenerBridge: View { @@ -21,24 +20,7 @@ internal struct WindowOpenerBridge: View { openWelcome: { openWindow(id: SceneId.welcome) }, openConnectionForm: { id in openWindow(id: SceneId.connectionForm, value: id) }, openIntegrationsActivity: { openWindow(id: SceneId.integrationsActivity) }, - openSettings: { openSettings() }, - presentTypeChooser: { initialType, onSelected in - let payload = DatabaseTypeChooserPayload( - initialType: initialType, - onSelected: onSelected - ) - AppCommands.shared.presentDatabaseTypeChooser.send(payload) - } + openSettings: { openSettings() } ) } } - -internal final class DatabaseTypeChooserPayload { - let initialType: DatabaseType? - let onSelected: (DatabaseType) -> Void - - init(initialType: DatabaseType?, onSelected: @escaping (DatabaseType) -> Void) { - self.initialType = initialType - self.onSelected = onSelected - } -} diff --git a/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift b/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift new file mode 100644 index 000000000..cc7bd3f09 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/WelcomeRouterTests.swift @@ -0,0 +1,82 @@ +// +// WelcomeRouterTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +@MainActor +final class WelcomeRouterTests: XCTestCase { + private var router: WelcomeRouter! + + override func setUp() { + super.setUp() + router = WelcomeRouter() + } + + override func tearDown() { + router = nil + super.tearDown() + } + + private func makeChooserPayload( + initialType: DatabaseType? = nil, + onSelected: @escaping (DatabaseType) -> Void = { _ in } + ) -> DatabaseTypeChooserPayload { + DatabaseTypeChooserPayload(initialType: initialType, onSelected: onSelected) + } + + func testRouteStoresRequestWithoutASubscriber() { + router.route(.exportConnections) + + guard case .exportConnections = router.pendingRequest else { + return XCTFail("Expected the request to survive without a mounted Welcome window") + } + } + + func testConsumeReturnsRequestOnceThenNil() { + router.route(.importFromApp) + + guard case .importFromApp = router.consumePendingRequest() else { + return XCTFail("Expected the routed request") + } + XCTAssertNil(router.consumePendingRequest()) + XCTAssertNil(router.pendingRequest) + } + + func testSecondRouteReplacesAnUnconsumedRequest() { + router.route(.exportConnections) + router.route(.openProjectFolder) + + guard case .openProjectFolder = router.consumePendingRequest() else { + return XCTFail("Expected the most recent request to win") + } + XCTAssertNil(router.consumePendingRequest()) + } + + func testChooserRequestCarriesItsPayload() { + var selected: DatabaseType? + let payload = makeChooserPayload(initialType: .postgresql) { selected = $0 } + + router.route(.chooseDatabaseType(payload)) + + guard case .chooseDatabaseType(let delivered) = router.consumePendingRequest() else { + return XCTFail("Expected a chooser request") + } + XCTAssertEqual(delivered.initialType, .postgresql) + delivered.onSelected(.mysql) + XCTAssertEqual(selected, .mysql) + } + + func testRequestSlotIsIndependentOfTheOtherPendingSlots() { + let connection = DatabaseConnection(name: "Test", type: .mysql) + router.routePluginInstall(connection) + router.route(.importConnections) + + guard case .importConnections = router.consumePendingRequest() else { + return XCTFail("Expected the request slot to be untouched by routePluginInstall") + } + XCTAssertEqual(router.consumePendingPluginInstall()?.id, connection.id) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift new file mode 100644 index 000000000..ed595ab02 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift @@ -0,0 +1,38 @@ +// +// WindowOpenerTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +@MainActor +final class WindowOpenerTests: XCTestCase { + override func setUp() { + super.setUp() + _ = WelcomeRouter.shared.consumePendingRequest() + } + + override func tearDown() { + _ = WelcomeRouter.shared.consumePendingRequest() + super.tearDown() + } + + func testNewConnectionRoutesTheChooserWithoutABridge() { + WindowOpener.shared.openConnectionForm() + + guard case .chooseDatabaseType(let payload) = WelcomeRouter.shared.pendingRequest else { + return XCTFail("New Connection must queue a chooser request even with no window open") + } + XCTAssertNil(payload.initialType) + } + + func testPresentTypeChooserCarriesTheInitialType() { + WindowOpener.shared.presentTypeChooser(initialType: .sqlite) { _ in } + + guard case .chooseDatabaseType(let payload) = WelcomeRouter.shared.pendingRequest else { + return XCTFail("Expected a chooser request") + } + XCTAssertEqual(payload.initialType, .sqlite) + } +} diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift index 27ad0ead7..dc91a3474 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -16,6 +16,7 @@ final class WelcomeViewModelTests: XCTestCase { private var connectionFileURL: URL! private var groupStorage: GroupStorage! private var connectionStorage: ConnectionStorage! + private var welcomeRouter: WelcomeRouter! private var viewModel: WelcomeViewModel! override func setUp() { @@ -48,6 +49,7 @@ final class WelcomeViewModelTests: XCTestCase { syncTracker: tracker, connectionStorage: self.connectionStorage ) + welcomeRouter = WelcomeRouter() viewModel = WelcomeViewModel(services: makeServices()) } @@ -56,6 +58,7 @@ final class WelcomeViewModelTests: XCTestCase { syncDefaults.removePersistentDomain(forName: syncSuiteName) try? FileManager.default.removeItem(at: connectionFileURL) viewModel = nil + welcomeRouter = nil groupStorage = nil connectionStorage = nil defaults = nil @@ -95,7 +98,8 @@ final class WelcomeViewModelTests: XCTestCase { copilotService: live.copilotService, mcpServerManager: live.mcpServerManager, syncTracker: live.syncTracker, - themeEngine: live.themeEngine + themeEngine: live.themeEngine, + welcomeRouter: welcomeRouter ) } @@ -138,4 +142,76 @@ final class WelcomeViewModelTests: XCTestCase { } XCTAssertEqual(stagingNodes.count, 1) } + + // MARK: - Welcome Router Requests + + private func waitForChooser(timeout: TimeInterval = 2) async -> DatabaseTypeChooserPayload? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let chooser = viewModel.databaseTypeChooser { + return chooser + } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return viewModel.databaseTypeChooser + } + + func testChooserRoutedWhileWelcomeWindowIsClosedSurvivesUntilItMounts() { + let payload = DatabaseTypeChooserPayload(initialType: .postgresql) { _ in } + welcomeRouter.route(.chooseDatabaseType(payload)) + + viewModel.setUp() + + XCTAssertEqual(viewModel.databaseTypeChooser?.id, payload.id) + XCTAssertNil(welcomeRouter.pendingRequest) + } + + func testChooserRoutedWhileWelcomeWindowIsOpenIsDelivered() async { + viewModel.setUp() + XCTAssertNil(viewModel.databaseTypeChooser) + + let payload = DatabaseTypeChooserPayload(initialType: .mysql) { _ in } + welcomeRouter.route(.chooseDatabaseType(payload)) + + let delivered = await waitForChooser() + XCTAssertEqual(delivered?.id, payload.id) + } + + func testImportFromURLRequestPresentsTheURLSheet() { + welcomeRouter.route(.importFromURL) + + viewModel.setUp() + + XCTAssertTrue(viewModel.urlImportPresented) + } + + func testImportFromAppRequestPresentsTheImportSheet() { + welcomeRouter.route(.importFromApp) + + viewModel.setUp() + + guard case .importFromApp = viewModel.activeSheet else { + return XCTFail("Expected the Import from Other App sheet") + } + } + + func testExportConnectionsRequestIsIgnoredWhenThereAreNoConnections() { + welcomeRouter.route(.exportConnections) + + viewModel.setUp() + + XCTAssertNil(viewModel.activeSheet) + } + + func testRequestIsDrainedAheadOfABackgroundPluginInstall() { + let connection = DatabaseConnection(name: "Pending", type: .mysql) + welcomeRouter.routePluginInstall(connection) + welcomeRouter.route(.importFromURL) + + viewModel.setUp() + + XCTAssertTrue(viewModel.urlImportPresented) + XCTAssertNil(viewModel.pluginInstallConnection) + XCTAssertEqual(welcomeRouter.pendingPluginInstall?.id, connection.id) + } } diff --git a/TableProUITests/NewConnectionCommandUITests.swift b/TableProUITests/NewConnectionCommandUITests.swift new file mode 100644 index 000000000..5a33f0001 --- /dev/null +++ b/TableProUITests/NewConnectionCommandUITests.swift @@ -0,0 +1,39 @@ +import XCTest + +final class NewConnectionCommandUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + return app + } + + func testNewConnectionOpensTheChooserAfterTheWelcomeWindowIsClosed() throws { + let app = launchApp() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + + app.typeKey("w", modifierFlags: .command) + XCTAssertTrue( + app.windows.firstMatch.waitForNonExistence(timeout: 10), + "The welcome window should close, which is the state that broke New Connection" + ) + + let newConnection = app.menuBars.menuItems["New Connection..."] + XCTAssertTrue(newConnection.waitForExistence(timeout: 5)) + XCTAssertTrue(newConnection.isEnabled) + newConnection.click() + + XCTAssertTrue( + app.staticTexts["Choose a Database"].waitForExistence(timeout: 10), + "New Connection must present the database chooser with no welcome window open" + ) + } +}