From 45da8678e5c85290322dadb272d24013a8b7c10d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 20:34:18 +0700 Subject: [PATCH 1/2] refactor(toolbar): make Active Connections a toolbar popover instead of a modal (#1350) --- CHANGELOG.md | 4 + .../MainWindowToolbar+Buttons.swift | 5 +- .../Main/MainContentCommandActions.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 3 +- TablePro/Views/Main/MainContentView.swift | 2 - .../Toolbar/ConnectionSwitcherPopover.swift | 320 ++++++++++++++++++ .../Toolbar/ConnectionSwitcherSheet.swift | 200 ----------- .../ConnectionSwitcherFilterTests.swift | 38 +++ docs/databases/overview.mdx | 6 +- 9 files changed, 371 insertions(+), 209 deletions(-) create mode 100644 TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift delete mode 100644 TablePro/Views/Toolbar/ConnectionSwitcherSheet.swift create mode 100644 TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c132178f5..5f2a1feb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374) +### Changed + +- Active Connections is now a searchable popover anchored to the toolbar that closes on Escape or clicking outside, instead of a modal dialog. (#1350) + ### Fixed - Raw SQL filter now suggests columns and keywords at every position in the expression, including after AND and OR, instead of only the first column. (#1346) diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index ef1d3a9c2..3f17ccaf6 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -9,7 +9,7 @@ import SwiftUI import TableProPluginKit struct ConnectionToolbarButton: View { - let coordinator: MainContentCoordinator + @Bindable var coordinator: MainContentCoordinator var body: some View { Button { @@ -18,6 +18,9 @@ struct ConnectionToolbarButton: View { Label("Connection", systemImage: "network") } .help(String(localized: "Switch Connection (⌘⌥C)")) + .popover(isPresented: $coordinator.isConnectionSwitcherShown, arrowEdge: .bottom) { + ConnectionSwitcherPopover() + } } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index bc9d6e663..2e4a65feb 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -815,7 +815,7 @@ final class MainContentCommandActions { } func openConnectionSwitcher() { - coordinator?.activeSheet = .connectionSwitcher + coordinator?.isConnectionSwitcherShown = true } // MARK: - Undo/Redo (Group A — Called Directly) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 91b2c42a5..8d7ca362c 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -43,7 +43,6 @@ struct DisplayFormatsCacheEntry { /// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { case quickSwitcher - case connectionSwitcher case sqlPreview case exportDialog case importDialog @@ -56,7 +55,6 @@ enum ActiveSheet: Identifiable { var id: String { switch self { case .quickSwitcher: "quickSwitcher" - case .connectionSwitcher: "connectionSwitcher" case .sqlPreview: "sqlPreview" case .exportDialog: "exportDialog" case .importDialog: "importDialog" @@ -157,6 +155,7 @@ final class MainContentCoordinator { var tableMetadata: TableMetadata? var activeSheet: ActiveSheet? var isDatabaseSwitcherShown = false + var isConnectionSwitcherShown = false var databaseToDrop: String? var importFileURL: URL? var exportPreselectedTableNames: Set? diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a7027a6f9..ae2d9c426 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -257,8 +257,6 @@ struct MainContentView: View { databaseType: connection.type, onSelect: coordinator.handleQuickSwitcherSelection ) - case .connectionSwitcher: - ConnectionSwitcherSheet(isPresented: dismissBinding) case .sqlPreview: SQLReviewSheet( isPresented: dismissBinding, diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift new file mode 100644 index 000000000..532ba6f74 --- /dev/null +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -0,0 +1,320 @@ +// +// ConnectionSwitcherPopover.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +enum ConnectionSwitcherFilter { + static func matches(_ connection: DatabaseConnection, query: String) -> Bool { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return true } + let needle = trimmed.lowercased() + return connection.name.lowercased().contains(needle) + || connection.host.lowercased().contains(needle) + || connection.database.lowercased().contains(needle) + } +} + +struct ConnectionSwitcherPopover: View { + @Environment(\.dismiss) private var dismiss + + @State private var savedConnections: [DatabaseConnection] = [] + @State private var selectedConnectionId: UUID? + @State private var searchText = "" + @FocusState private var searchFocused: Bool + + private static let popoverWidth: CGFloat = 400 + private static let popoverHeight: CGFloat = 460 + + private var activeSessions: [UUID: ConnectionSession] { + DatabaseManager.shared.activeSessions + } + + private var currentSessionId: UUID? { + DatabaseManager.shared.currentSessionId + } + + private var sortedSessions: [ConnectionSession] { + Array(activeSessions.values).sorted { $0.lastActiveAt > $1.lastActiveAt } + } + + private var inactiveSaved: [DatabaseConnection] { + savedConnections.filter { activeSessions[$0.id] == nil } + } + + private var filteredSessions: [ConnectionSession] { + sortedSessions.filter { ConnectionSwitcherFilter.matches($0.connection, query: searchText) } + } + + private var filteredSaved: [DatabaseConnection] { + inactiveSaved.filter { ConnectionSwitcherFilter.matches($0, query: searchText) } + } + + private var orderedIds: [UUID] { + filteredSessions.map(\.id) + filteredSaved.map(\.id) + } + + var body: some View { + VStack(spacing: 0) { + searchField + + Divider() + + content + + Divider() + + manageButton + } + .frame(width: Self.popoverWidth, height: Self.popoverHeight) + .onAppear { + savedConnections = ConnectionStorage.shared.loadConnections() + if selectedConnectionId == nil { + selectedConnectionId = currentSessionId ?? orderedIds.first + } + searchFocused = true + } + .onChange(of: searchText) { _, _ in + let ids = orderedIds + if let id = selectedConnectionId, ids.contains(id) { return } + selectedConnectionId = ids.first + } + } + + private var searchField: some View { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .imageScale(.small) + .foregroundStyle(.secondary) + .frame(width: 14) + + TextField( + "", + text: $searchText, + prompt: Text(String(localized: "Search connections")) + .foregroundStyle(.tertiary) + ) + .textFieldStyle(.plain) + .font(.body) + .focused($searchFocused) + .onKeyPress(.downArrow) { + moveSelection(by: 1) + return .handled + } + .onKeyPress(.upArrow) { + moveSelection(by: -1) + return .handled + } + .onKeyPress(.return) { + activateSelected() + return .handled + } + .onKeyPress(.escape) { + if searchText.isEmpty { return .ignored } + searchText = "" + return .handled + } + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .imageScale(.small) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.35)) + ) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + @ViewBuilder + private var content: some View { + if orderedIds.isEmpty { + emptyState + } else { + list + } + } + + private var list: some View { + ScrollViewReader { proxy in + List(selection: $selectedConnectionId) { + if !filteredSessions.isEmpty { + Section { + ForEach(filteredSessions) { session in + connectionRow( + connection: session.connection, + isActive: session.id == currentSessionId, + isConnected: session.status.isConnected + ) + .tag(session.id) + .id(session.id) + } + } header: { + Text("ACTIVE CONNECTIONS") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + if !filteredSaved.isEmpty { + Section { + ForEach(filteredSaved) { connection in + connectionRow(connection: connection, isActive: false, isConnected: false) + .tag(connection.id) + .id(connection.id) + } + } header: { + Text("SAVED CONNECTIONS") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: selectedConnectionId) { _, newValue in + guard let id = newValue else { return } + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(id) + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.title3) + .foregroundStyle(.secondary) + if searchText.isEmpty { + Text(String(localized: "No connections")) + .font(.callout.weight(.medium)) + } else { + Text(String(format: String(localized: "No connections match “%@”"), searchText)) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 12) + } + + private var manageButton: some View { + Button { + dismiss() + WindowOpener.shared.openWelcome() + } label: { + HStack { + Image(systemName: "gear") + .foregroundStyle(.secondary) + Text("Manage Connections...") + .foregroundStyle(.primary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func connectionRow( + connection: DatabaseConnection, + isActive: Bool, + isConnected: Bool + ) -> some View { + HStack(spacing: 8) { + Circle() + .fill(connection.displayColor) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 1) { + Text(connection.name) + .font(.body.weight(isActive ? .semibold : .regular)) + .lineLimit(1) + + Text(connectionSubtitle(connection)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if isActive { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.body) + } else if isConnected { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + } + + Text(connection.type.rawValue.uppercased()) + .font(.system(.caption2, design: .monospaced).weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color(nsColor: .separatorColor), in: RoundedRectangle(cornerRadius: 3)) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { activate(connectionId: connection.id) } + } + + // MARK: - Selection + + private func moveSelection(by offset: Int) { + let ids = orderedIds + guard !ids.isEmpty else { return } + let currentIndex = selectedConnectionId.flatMap { ids.firstIndex(of: $0) } ?? 0 + let newIndex = max(0, min(ids.count - 1, currentIndex + offset)) + selectedConnectionId = ids[newIndex] + } + + private func activateSelected() { + guard let id = selectedConnectionId else { return } + activate(connectionId: id) + } + + private func activate(connectionId: UUID) { + dismiss() + Task { + do { + try await TabRouter.shared.route(.openConnection(connectionId)) + } catch { + await MainActor.run { + AlertHelper.showErrorSheet( + title: String(localized: "Connection Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + } + } + + private func connectionSubtitle(_ connection: DatabaseConnection) -> String { + if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { + return connection.database + } + let port = connection.port != connection.type.defaultPort ? ":\(connection.port)" : "" + return "\(connection.host)\(port)/\(connection.database)" + } +} diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherSheet.swift b/TablePro/Views/Toolbar/ConnectionSwitcherSheet.swift deleted file mode 100644 index e3d681e8f..000000000 --- a/TablePro/Views/Toolbar/ConnectionSwitcherSheet.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// ConnectionSwitcherSheet.swift -// TablePro -// - -import AppKit -import SwiftUI -import TableProPluginKit - -struct ConnectionSwitcherSheet: View { - @Binding var isPresented: Bool - @Environment(\.dismiss) private var dismiss - - @State private var savedConnections: [DatabaseConnection] = [] - @State private var selectedConnectionId: UUID? - - private var activeSessions: [UUID: ConnectionSession] { - DatabaseManager.shared.activeSessions - } - - private var currentSessionId: UUID? { - DatabaseManager.shared.currentSessionId - } - - private var sortedSessions: [ConnectionSession] { - Array(activeSessions.values).sorted { $0.lastActiveAt > $1.lastActiveAt } - } - - private var inactiveSaved: [DatabaseConnection] { - savedConnections.filter { activeSessions[$0.id] == nil } - } - - var body: some View { - VStack(spacing: 0) { - List(selection: $selectedConnectionId) { - if !sortedSessions.isEmpty { - Section { - ForEach(sortedSessions) { session in - connectionRow( - connection: session.connection, - isActive: session.id == currentSessionId, - isConnected: session.status.isConnected - ) - .tag(session.id) - } - } header: { - Text("ACTIVE CONNECTIONS") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - - if !inactiveSaved.isEmpty { - Section { - ForEach(inactiveSaved) { connection in - connectionRow(connection: connection, isActive: false, isConnected: false) - .tag(connection.id) - } - } header: { - Text("SAVED CONNECTIONS") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - .listStyle(.sidebar) - .scrollContentBackground(.hidden) - - Divider() - - Button { - dismiss() - WindowOpener.shared.openWelcome() - } label: { - HStack { - Image(systemName: "gear") - .foregroundStyle(.secondary) - Text("Manage Connections...") - .foregroundStyle(.primary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - .frame(width: 420, height: 500) - .onAppear { - savedConnections = ConnectionStorage.shared.loadConnections() - if selectedConnectionId == nil { - selectedConnectionId = currentSessionId ?? sortedSessions.first?.id ?? inactiveSaved.first?.id - } - } - .onExitCommand { dismiss() } - .onKeyPress(.return) { - activateSelected() - return .handled - } - .onKeyPress(characters: .init(charactersIn: "j"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - moveSelection(by: 1) - return .handled - } - .onKeyPress(characters: .init(charactersIn: "k"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - moveSelection(by: -1) - return .handled - } - } - - private func connectionRow( - connection: DatabaseConnection, - isActive: Bool, - isConnected: Bool - ) -> some View { - HStack(spacing: 8) { - Circle() - .fill(connection.displayColor) - .frame(width: 8, height: 8) - - VStack(alignment: .leading, spacing: 1) { - Text(connection.name) - .font(.body.weight(isActive ? .semibold : .regular)) - .lineLimit(1) - - Text(connectionSubtitle(connection)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if isActive { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.body) - } else if isConnected { - Circle() - .fill(.green) - .frame(width: 6, height: 6) - } - - Text(connection.type.rawValue.uppercased()) - .font(.system(.caption2, design: .monospaced).weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(Color(nsColor: .separatorColor), in: RoundedRectangle(cornerRadius: 3)) - } - .padding(.vertical, 2) - .contentShape(Rectangle()) - .onTapGesture { activate(connectionId: connection.id) } - } - - // MARK: - Selection - - private var allConnectionIds: [UUID] { - sortedSessions.map(\.id) + inactiveSaved.map(\.id) - } - - private func moveSelection(by offset: Int) { - let ids = allConnectionIds - guard !ids.isEmpty else { return } - let currentIndex = ids.firstIndex(of: selectedConnectionId ?? UUID()) ?? 0 - let newIndex = max(0, min(ids.count - 1, currentIndex + offset)) - selectedConnectionId = ids[newIndex] - } - - private func activateSelected() { - guard let id = selectedConnectionId else { return } - activate(connectionId: id) - } - - private func activate(connectionId: UUID) { - dismiss() - Task { - do { - try await TabRouter.shared.route(.openConnection(connectionId)) - } catch { - await MainActor.run { - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: NSApp.keyWindow - ) - } - } - } - } - - private func connectionSubtitle(_ connection: DatabaseConnection) -> String { - if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { - return connection.database - } - let port = connection.port != connection.type.defaultPort ? ":\(connection.port)" : "" - return "\(connection.host)\(port)/\(connection.database)" - } -} diff --git a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift new file mode 100644 index 000000000..25ea376c2 --- /dev/null +++ b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift @@ -0,0 +1,38 @@ +// +// ConnectionSwitcherFilterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Connection Switcher Filter") +struct ConnectionSwitcherFilterTests { + @Test("Empty or whitespace query matches every connection") + func emptyQueryMatches() { + let connection = TestFixtures.makeConnection(name: "Production", database: "app") + #expect(ConnectionSwitcherFilter.matches(connection, query: "")) + #expect(ConnectionSwitcherFilter.matches(connection, query: " ")) + } + + @Test("Name match is case-insensitive and substring-based") + func nameMatchCaseInsensitive() { + let connection = TestFixtures.makeConnection(name: "Production DB", database: "app") + #expect(ConnectionSwitcherFilter.matches(connection, query: "prod")) + #expect(ConnectionSwitcherFilter.matches(connection, query: "DB")) + } + + @Test("Database name is searched") + func databaseMatch() { + let connection = TestFixtures.makeConnection(name: "Primary", database: "analytics") + #expect(ConnectionSwitcherFilter.matches(connection, query: "analy")) + } + + @Test("Non-matching query returns false") + func noMatch() { + let connection = TestFixtures.makeConnection(name: "Primary", database: "analytics") + #expect(!ConnectionSwitcherFilter.matches(connection, query: "zzz")) + } +} diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index 41a4ce42f..c1d251490 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -329,13 +329,13 @@ Groups support up to 3 levels of nesting. Right-click a group to create a subgro Switch connections from the toolbar: -1. Click the **connection name** button in the toolbar +1. Click the **connection name** button in the toolbar, or press **Cmd+Control+C** 2. A popover shows your active sessions and saved connections -3. Click any connection to switch immediately +3. Type to filter, use the arrow keys to move, and press Return to switch, or click any connection 4. Click **Manage Connections...** to open the full connection manager -Click the connection button again to dismiss the popover. +Press Escape or click outside the popover to dismiss it. ## Switching Databases From 2eaf226280352f3307bbdfed9e8b5a765c784a8e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 20:40:51 +0700 Subject: [PATCH 2/2] refactor(toolbar): hoist Return-to-activate and extract testable connection-switcher selection (#1350) --- .../Toolbar/ConnectionSwitcherPopover.swift | 21 +++++++--- .../ConnectionSwitcherFilterTests.swift | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 532ba6f74..6c75b2c5f 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -18,6 +18,15 @@ enum ConnectionSwitcherFilter { } } +enum ConnectionSwitcherSelection { + static func moved(in ids: [UUID], from current: UUID?, by offset: Int) -> UUID? { + guard !ids.isEmpty else { return nil } + let currentIndex = current.flatMap { ids.firstIndex(of: $0) } ?? 0 + let newIndex = max(0, min(ids.count - 1, currentIndex + offset)) + return ids[newIndex] + } +} + struct ConnectionSwitcherPopover: View { @Environment(\.dismiss) private var dismiss @@ -82,6 +91,10 @@ struct ConnectionSwitcherPopover: View { if let id = selectedConnectionId, ids.contains(id) { return } selectedConnectionId = ids.first } + .onKeyPress(.return) { + activateSelected() + return .handled + } } private var searchField: some View { @@ -281,11 +294,9 @@ struct ConnectionSwitcherPopover: View { // MARK: - Selection private func moveSelection(by offset: Int) { - let ids = orderedIds - guard !ids.isEmpty else { return } - let currentIndex = selectedConnectionId.flatMap { ids.firstIndex(of: $0) } ?? 0 - let newIndex = max(0, min(ids.count - 1, currentIndex + offset)) - selectedConnectionId = ids[newIndex] + if let next = ConnectionSwitcherSelection.moved(in: orderedIds, from: selectedConnectionId, by: offset) { + selectedConnectionId = next + } } private func activateSelected() { diff --git a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift index 25ea376c2..d860110cf 100644 --- a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift +++ b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift @@ -30,9 +30,48 @@ struct ConnectionSwitcherFilterTests { #expect(ConnectionSwitcherFilter.matches(connection, query: "analy")) } + @Test("Host is searched") + func hostMatch() { + let connection = TestFixtures.makeConnection(name: "Primary", database: "analytics") + #expect(ConnectionSwitcherFilter.matches(connection, query: "localhost")) + } + @Test("Non-matching query returns false") func noMatch() { let connection = TestFixtures.makeConnection(name: "Primary", database: "analytics") #expect(!ConnectionSwitcherFilter.matches(connection, query: "zzz")) } } + +@Suite("Connection Switcher Selection") +struct ConnectionSwitcherSelectionTests { + @Test("Empty list yields no selection") + func emptyList() { + #expect(ConnectionSwitcherSelection.moved(in: [], from: nil, by: 1) == nil) + } + + @Test("Moving down advances to the next id") + func movesDown() { + let (a, b, c) = (UUID(), UUID(), UUID()) + #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: a, by: 1) == b) + #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: b, by: 1) == c) + } + + @Test("Moving up retreats to the previous id") + func movesUp() { + let (a, b, c) = (UUID(), UUID(), UUID()) + #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: c, by: -1) == b) + } + + @Test("Moving past the top clamps to the first id") + func clampsAtTop() { + let (a, b, c) = (UUID(), UUID(), UUID()) + #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: a, by: -1) == a) + } + + @Test("Moving past the bottom clamps to the last id") + func clampsAtBottom() { + let (a, b, c) = (UUID(), UUID(), UUID()) + #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: c, by: 1) == c) + } +}